feat: 新增定时日报与微信发送能力底座

This commit is contained in:
Wxw-Gu
2026-08-27 17:08:09 +08:00
parent 62abd872ee
commit 949e6bc868
11 changed files with 786 additions and 2 deletions
+31
View File
@@ -112,8 +112,14 @@ import {
import { installSafeConsole } from './safe-log'
import { agentHubService } from './services/agent-hub-service'
import { personalWechatSendService } from './services/personal-wechat-send-service'
import { getPersonalWechatSendCapability } from './services/personal-wechat-capability-service'
import { scheduledReportService } from './services/scheduled-report-service'
import { PersonalWechatRuntimeManager } from './services/personal-wechat-runtime-manager'
import type { PersonalWechatSendRequest } from '../shared/personal-wechat'
import type {
ScheduledReportCreateInput,
ScheduledReportUpdateInput
} from '../shared/scheduled-report'
import { TextToSpeechSettingsService } from './services/text-to-speech-settings-service'
import type {
ListTextToSpeechVoicesRequest,
@@ -1280,6 +1286,29 @@ app.whenReady().then(async () => {
return deleteGeneratedReport(reportId)
})
ipcMain.handle('wechat-personal:getSendCapability', () => getPersonalWechatSendCapability())
ipcMain.handle('scheduled-report:list', () => scheduledReportService.listTasks())
ipcMain.handle('scheduled-report:listExecutions', (_, taskId?: string) =>
scheduledReportService.listExecutions(taskId)
)
ipcMain.handle('scheduled-report:create', (_, request: ScheduledReportCreateInput) =>
scheduledReportService.createTask(request)
)
ipcMain.handle(
'scheduled-report:update',
(_, taskId: string, request: ScheduledReportUpdateInput) =>
scheduledReportService.updateTask(taskId, request)
)
ipcMain.handle('scheduled-report:delete', (_, taskId: string) =>
scheduledReportService.deleteTask(taskId)
)
ipcMain.handle('scheduled-report:setEnabled', (_, taskId: string, enabled: boolean) =>
scheduledReportService.setTaskEnabled(taskId, Boolean(enabled))
)
ipcMain.handle('scheduled-report:runNow', (_, taskId: string) =>
scheduledReportService.runScheduledReportNow(taskId)
)
ipcMain.handle('report:reveal', async (_, filePath: string) => {
try {
shell.showItemInFolder(filePath)
@@ -1859,6 +1888,7 @@ app.whenReady().then(async () => {
}
await agentHubService.start(settings)
await scheduledReportService.start()
setupTray()
if (TRAY_MODE) app.dock?.hide()
@@ -1895,6 +1925,7 @@ app.on('before-quit', (event) => {
void (async () => {
agentHubService.stop()
scheduledReportService.stop()
flushBootstrapCacheWritesSync()
const [, nativeCallsDrained] = await Promise.all([
apiServer.stop().catch(() => undefined),
@@ -16,7 +16,7 @@ const aiProvider = new AIProviderService()
export interface AgentGroupReportRequest {
group: string
range?: SummaryDateRange
range?: SummaryDateRange | 'recent24h'
}
export interface AgentGroupReportResult {
@@ -42,7 +42,10 @@ export async function generateAgentGroupReport(
}
const range = request.range === 'yesterday' || request.range === '7days' ? request.range : 'today'
const { startTime, endTime } = getSummaryDateRange(range)
const { startTime, endTime } =
request.range === 'recent24h'
? { startTime: Math.floor(Date.now() / 1000) - 86400, endTime: Math.floor(Date.now() / 1000) }
: getSummaryDateRange(range)
let messages = listMessages(contact.md5, startTime, endTime) as Message[]
if (!messages.length) return { success: false, error: '所选时间范围没有可总结的消息' }
@@ -0,0 +1,81 @@
import type {
PersonalWechatSendCapability,
PersonalWechatSendCapabilityState,
PersonalWechatSenderStatus
} from '../../shared/personal-wechat'
import {
personalWechatSendService,
type PersonalWechatSendService
} from './personal-wechat-send-service'
/**
* Converts the detailed sender diagnostics into a small contract that other
* features can consume without knowing about OneBot, Hook or platform details.
*/
export class PersonalWechatCapabilityService {
constructor(private readonly sender: Pick<PersonalWechatSendService, 'getStatus'>) {}
async getPersonalWechatSendCapability(): Promise<PersonalWechatSendCapability> {
const senderStatus = await this.sender.getStatus()
return this.fromSenderStatus(senderStatus)
}
fromSenderStatus(senderStatus: PersonalWechatSenderStatus): PersonalWechatSendCapability {
const capabilities = {
text: Boolean(senderStatus.canSendText),
image: Boolean(senderStatus.canSendImage),
voice: Boolean(senderStatus.canSendVoice)
}
const status = this.mapState(senderStatus, capabilities.image)
const ready = status === 'ready'
return {
supported: status !== 'unsupported',
ready,
status,
capabilities,
senderStatus,
message:
status === 'unsupported'
? '微信消息发送目前仅支持 macOS'
: status === 'needs_binding' || status === 'unconfigured'
? '请先绑定个人微信'
: status === 'needs_verification'
? '请先完成微信消息能力检测'
: status === 'ready'
? '个人微信已准备好发送日报'
: senderStatus.message || senderStatus.error || '个人微信发送能力异常',
...(senderStatus.error ? { error: senderStatus.error } : {})
}
}
private mapState(
senderStatus: PersonalWechatSenderStatus,
canSendImage: boolean
): PersonalWechatSendCapabilityState {
if (senderStatus.platform !== 'darwin' || senderStatus.state === 'unsupported_platform') {
return 'unsupported'
}
if (senderStatus.state === 'error') return 'error'
const hasBinding = Boolean(senderStatus.boundWechatPid)
if (!hasBinding) {
return senderStatus.runtimeReady ? 'needs_binding' : 'unconfigured'
}
if (canSendImage) return 'ready'
if (
senderStatus.state === 'hook_not_ready' ||
senderStatus.state === 'online' ||
senderStatus.state === 'starting' ||
senderStatus.state === 'stopped'
) {
return 'needs_verification'
}
return 'error'
}
}
export const personalWechatCapabilityService = new PersonalWechatCapabilityService(
personalWechatSendService
)
export const getPersonalWechatSendCapability = (): Promise<PersonalWechatSendCapability> =>
personalWechatCapabilityService.getPersonalWechatSendCapability()
@@ -0,0 +1,404 @@
import { app } from 'electron'
import { randomUUID } from 'crypto'
import { promises as fs } from 'fs'
import path from 'path'
import type {
PersonalWechatSendCapability,
PersonalWechatSendRequest
} from '../../shared/personal-wechat'
import type {
ScheduledReportCreateInput,
ScheduledReportExecution,
ScheduledReportRange,
ScheduledReportResult,
ScheduledReportTask,
ScheduledReportUpdateInput
} from '../../shared/scheduled-report'
import { generateAgentGroupReport } from './agent-group-report-service'
import { personalWechatSendService } from './personal-wechat-send-service'
import { personalWechatCapabilityService } from './personal-wechat-capability-service'
import { isReady as isChatReady, resolveMd5 } from './chat-service'
const STORAGE_DIR = 'scheduled-reports'
const TASKS_FILE = 'tasks.json'
const EXECUTIONS_FILE = 'executions.json'
const TICK_MS = 15_000
export interface ScheduledReportDependencies {
getCapability: () => Promise<PersonalWechatSendCapability>
generateReport: typeof generateAgentGroupReport
send: (request: PersonalWechatSendRequest) => ReturnType<typeof personalWechatSendService.send>
storageDir: string
isDatabaseReady: () => boolean
now?: () => Date
}
const defaultDependencies = (): ScheduledReportDependencies => ({
getCapability: () => personalWechatCapabilityService.getPersonalWechatSendCapability(),
generateReport: generateAgentGroupReport,
send: (request) => personalWechatSendService.send(request),
storageDir: path.join(app.getPath('userData'), STORAGE_DIR),
isDatabaseReady: () => isChatReady()
})
const rangeValues = new Set<ScheduledReportRange>(['yesterday', 'recent24h'])
export function validateScheduleTime(value: string): boolean {
return /^(?:[01]\d|2[0-3]):[0-5]\d$/.test(String(value || '').trim())
}
export function calculateNextRunAt(scheduleTime: string, from = new Date()): string {
if (!validateScheduleTime(scheduleTime)) throw new Error('执行时间必须是 HH:mm')
const [hour, minute] = scheduleTime.split(':').map(Number)
const next = new Date(from)
next.setHours(hour, minute, 0, 0)
if (next.getTime() <= from.getTime()) next.setDate(next.getDate() + 1)
return next.toISOString()
}
const asArray = <T>(value: unknown): T[] => (Array.isArray(value) ? (value as T[]) : [])
export class ScheduledReportService {
private readonly deps: ScheduledReportDependencies
private tasks: ScheduledReportTask[] | null = null
private executions: ScheduledReportExecution[] | null = null
private timer: NodeJS.Timeout | null = null
private readonly running = new Map<string, Promise<ScheduledReportExecution>>()
constructor(deps?: Partial<ScheduledReportDependencies>) {
this.deps = { ...defaultDependencies(), ...deps }
}
async start(): Promise<void> {
await this.load()
if (this.timer) return
this.timer = setInterval(() => {
void this.tick().catch((error) => console.warn('[ScheduledReport] tick failed:', error))
}, TICK_MS)
void this.tick().catch((error) => console.warn('[ScheduledReport] initial tick failed:', error))
}
stop(): void {
if (this.timer) clearInterval(this.timer)
this.timer = null
}
async listTasks(): Promise<ScheduledReportTask[]> {
await this.load()
return this.tasks!.map((task) => ({ ...task }))
}
async listExecutions(taskId?: string): Promise<ScheduledReportExecution[]> {
await this.load()
const items = taskId
? this.executions!.filter((item) => item.taskId === taskId)
: this.executions!
return items.map((item) => ({ ...item }))
}
async createTask(
input: ScheduledReportCreateInput
): Promise<ScheduledReportResult<ScheduledReportTask>> {
const capability = await this.deps.getCapability()
if (!capability.ready) return { success: false, error: this.creationError(capability) }
const normalized = this.normalizeInput(input)
if (!normalized.success) return { success: false, error: normalized.error }
const values = normalized.data!
await this.load()
const now = this.deps.now?.() || new Date()
const task: ScheduledReportTask = {
id: `scheduled_report_${randomUUID()}`,
name: values.name,
group: values.group,
scheduleTime: values.scheduleTime,
reportRange: values.reportRange,
target: values.target,
enabled: values.enabled,
createdAt: now.toISOString(),
updatedAt: now.toISOString(),
nextRunAt: calculateNextRunAt(values.scheduleTime, now)
}
this.tasks!.push(task)
await this.saveTasks()
return { success: true, data: { ...task } }
}
async updateTask(
taskId: string,
input: ScheduledReportUpdateInput
): Promise<ScheduledReportResult<ScheduledReportTask>> {
await this.load()
const index = this.tasks!.findIndex((task) => task.id === taskId)
if (index < 0) return { success: false, error: '未找到定时日报任务' }
const current = this.tasks![index]
const normalized = this.normalizeInput({ ...current, ...input })
if (!normalized.success) return { success: false, error: normalized.error }
const values = normalized.data!
const now = this.deps.now?.() || new Date()
const scheduleChanged = values.scheduleTime !== current.scheduleTime
const updated: ScheduledReportTask = {
...current,
...values,
updatedAt: now.toISOString(),
...(scheduleChanged ? { nextRunAt: calculateNextRunAt(values.scheduleTime, now) } : {})
}
this.tasks![index] = updated
await this.saveTasks()
return { success: true, data: { ...updated } }
}
async deleteTask(taskId: string): Promise<ScheduledReportResult<{ deletedId: string }>> {
await this.load()
const before = this.tasks!.length
this.tasks = this.tasks!.filter((task) => task.id !== taskId)
if (this.tasks.length === before) return { success: false, error: '未找到定时日报任务' }
await this.saveTasks()
return { success: true, data: { deletedId: taskId } }
}
async setTaskEnabled(
taskId: string,
enabled: boolean
): Promise<ScheduledReportResult<ScheduledReportTask>> {
if (!enabled) return this.updateTask(taskId, { enabled: false })
await this.load()
const task = this.tasks!.find((item) => item.id === taskId)
if (!task) return { success: false, error: '未找到定时日报任务' }
const now = this.deps.now?.() || new Date()
const updated = {
...task,
enabled: true,
nextRunAt: calculateNextRunAt(task.scheduleTime, now),
updatedAt: now.toISOString()
}
this.tasks![this.tasks!.findIndex((item) => item.id === taskId)] = updated
await this.saveTasks()
return { success: true, data: { ...updated } }
}
async runScheduledReportNow(
taskId: string
): Promise<ScheduledReportResult<ScheduledReportExecution>> {
await this.load()
const task = this.tasks!.find((item) => item.id === taskId)
if (!task) return { success: false, error: '未找到定时日报任务' }
const execution = await this.runTask(task)
return {
success: execution.status === 'success',
data: execution,
...(execution.error ? { error: execution.error } : {})
}
}
async tick(at = this.deps.now?.() || new Date()): Promise<void> {
await this.load()
if (!this.deps.isDatabaseReady()) return
const nowMs = at.getTime()
for (const task of [...this.tasks!]) {
if (!task.enabled || Date.parse(task.nextRunAt) > nowMs) continue
const slot = task.nextRunAt
if (task.lastScheduledSlot === slot) continue
const slotMs = Date.parse(slot)
const recentExecution = this.executions!.filter((item) => item.taskId === task.id).sort(
(left, right) => Date.parse(right.startedAt) - Date.parse(left.startedAt)
)[0]
const executionOverlapsSlot = Boolean(
recentExecution &&
Date.parse(recentExecution.startedAt) >= slotMs - 60_000 &&
Date.parse(recentExecution.startedAt) <= nowMs
)
const index = this.tasks!.findIndex((item) => item.id === task.id)
if (index < 0 || !this.tasks![index].enabled) continue
const claimed: ScheduledReportTask = {
...this.tasks![index],
lastScheduledSlot: slot,
nextRunAt: calculateNextRunAt(task.scheduleTime, new Date(nowMs + 60_000)),
updatedAt: at.toISOString()
}
this.tasks![index] = claimed
await this.saveTasks()
if (executionOverlapsSlot) continue
void this.runTask(claimed, slot).catch((error) =>
console.warn('[ScheduledReport] execution failed:', error)
)
}
}
private async runTask(
task: ScheduledReportTask,
scheduledSlot?: string
): Promise<ScheduledReportExecution> {
const existing = this.running.get(task.id)
if (existing) return existing
const promise = this.executeTask(task, scheduledSlot).finally(() =>
this.running.delete(task.id)
)
this.running.set(task.id, promise)
return promise
}
private async executeTask(
task: ScheduledReportTask,
scheduledSlot?: string
): Promise<ScheduledReportExecution> {
await this.load()
const startedAt = (this.deps.now?.() || new Date()).toISOString()
const execution: ScheduledReportExecution = {
id: `scheduled_report_execution_${randomUUID()}`,
taskId: task.id,
startedAt,
status: 'running',
...(scheduledSlot ? { scheduledSlot } : {})
}
this.executions!.push(execution)
await this.saveExecutions()
const finish = async (
status: ScheduledReportExecution['status'],
error?: string,
message?: string
): Promise<ScheduledReportExecution> => {
const completed: ScheduledReportExecution = {
...execution,
status,
finishedAt: (this.deps.now?.() || new Date()).toISOString(),
...(error ? { error } : {}),
...(message ? { message } : {})
}
const index = this.executions!.findIndex((item) => item.id === execution.id)
if (index >= 0) this.executions![index] = completed
await this.saveExecutions()
const taskIndex = this.tasks!.findIndex((item) => item.id === task.id)
if (taskIndex >= 0) {
this.tasks![taskIndex] = {
...this.tasks![taskIndex],
...(status !== 'running' ? { lastRunAt: completed.finishedAt } : {}),
updatedAt: completed.finishedAt!
}
await this.saveTasks()
}
return completed
}
try {
const capability = await this.deps.getCapability()
if (!capability.ready) {
return finish('failed', `wechat_not_ready:${capability.status}`, capability.message)
}
let generated: Awaited<ReturnType<typeof this.deps.generateReport>>
try {
generated = await this.deps.generateReport({ group: task.group, range: task.reportRange })
} catch (error) {
return finish(
'failed',
`report_generation_failed:${error instanceof Error ? error.message : String(error)}`
)
}
if (!generated.success || !generated.pngPath) {
return finish('failed', `report_generation_failed:${generated.error || '日报生成失败'}`)
}
const target = this.resolveTarget(task)
if (!target) return finish('failed', 'wechat_send_failed:未找到指定微信群')
let sent: Awaited<ReturnType<typeof this.deps.send>>
try {
sent = await this.deps.send({
type: 'image',
to: target,
isGroup: true,
filePath: generated.pngPath
})
} catch (error) {
return finish(
'failed',
`wechat_send_failed:${error instanceof Error ? error.message : String(error)}`
)
}
if (!sent.success)
return finish('failed', `wechat_send_failed:${sent.error || '微信发送失败'}`)
return finish('success', undefined, '日报生成成功,微信发送成功')
} catch (error) {
return finish(
'failed',
`wechat_not_ready:${error instanceof Error ? error.message : String(error)}`
)
}
}
private resolveTarget(task: ScheduledReportTask): string | undefined {
const explicit = String(task.target || '').trim()
if (explicit.endsWith('@chatroom')) return explicit
const contact = resolveMd5(task.group || explicit)
return contact?.m_nsUsrName?.endsWith('@chatroom') ? contact.m_nsUsrName : undefined
}
private creationError(capability: PersonalWechatSendCapability): string {
if (capability.status === 'unsupported') return '微信消息发送目前仅支持 macOS'
if (capability.status === 'needs_binding' || capability.status === 'unconfigured')
return '请先绑定个人微信'
if (capability.status === 'needs_verification') return '请先完成微信消息能力检测'
return capability.error || capability.message || '个人微信发送能力异常'
}
private normalizeInput(input: ScheduledReportCreateInput): ScheduledReportResult<{
name: string
group: string
scheduleTime: string
reportRange: ScheduledReportRange
target: string
enabled: boolean
}> {
const name = String(input.name || '').trim()
const group = String(input.group || '').trim()
const scheduleTime = String(input.scheduleTime || '').trim()
const reportRange = input.reportRange || 'yesterday'
const target = String(input.target || group).trim()
if (!name) return { success: false, error: '日报名称不能为空' }
if (!group) return { success: false, error: '微信群不能为空' }
if (!validateScheduleTime(scheduleTime))
return { success: false, error: '执行时间必须是 HH:mm' }
if (!rangeValues.has(reportRange)) return { success: false, error: '日报范围不受支持' }
return {
success: true,
data: { name, group, scheduleTime, reportRange, target, enabled: input.enabled !== false }
}
}
private async load(): Promise<void> {
if (this.tasks && this.executions) return
await fs.mkdir(this.deps.storageDir, { recursive: true })
const [tasks, executions] = await Promise.all([
this.readJson<ScheduledReportTask[]>(TASKS_FILE),
this.readJson<ScheduledReportExecution[]>(EXECUTIONS_FILE)
])
this.tasks = asArray<ScheduledReportTask>(tasks)
this.executions = asArray<ScheduledReportExecution>(executions)
}
private async readJson<T>(file: string): Promise<T | undefined> {
try {
return JSON.parse(await fs.readFile(path.join(this.deps.storageDir, file), 'utf8')) as T
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
console.warn(`[ScheduledReport] failed to read ${file}:`, error)
}
return undefined
}
}
private async saveTasks(): Promise<void> {
await fs.writeFile(
path.join(this.deps.storageDir, TASKS_FILE),
JSON.stringify(this.tasks, null, 2),
'utf8'
)
}
private async saveExecutions(): Promise<void> {
await fs.writeFile(
path.join(this.deps.storageDir, EXECUTIONS_FILE),
JSON.stringify(this.executions, null, 2),
'utf8'
)
}
}
export const scheduledReportService = new ScheduledReportService()
+28
View File
@@ -61,6 +61,14 @@ import type {
PersonalWechatSenderStatus,
PersonalWechatVoiceDiagnostic
} from '../shared/personal-wechat'
import type { PersonalWechatSendCapability } from '../shared/personal-wechat'
import type {
ScheduledReportCreateInput,
ScheduledReportExecution,
ScheduledReportResult,
ScheduledReportTask,
ScheduledReportUpdateInput
} from '../shared/scheduled-report'
import type {
PersonalWechatRuntimeDownloadResult,
PersonalWechatRuntimeProgressEvent,
@@ -582,6 +590,7 @@ declare global {
limit?: number
) => Promise<{ success: boolean; insights: ImageInsight[] }>
getPersonalWechatSenderStatus: () => Promise<PersonalWechatSenderStatus>
getPersonalWechatSendCapability: () => Promise<PersonalWechatSendCapability>
getPersonalWechatKeepOneBotProcess: () => Promise<boolean>
setPersonalWechatKeepOneBotProcess: (keep: boolean) => Promise<boolean>
getPersonalWechatRuntimeStatus: () => Promise<PersonalWechatRuntimeStatus>
@@ -598,6 +607,25 @@ declare global {
sendPersonalWechatMessage: (
request: PersonalWechatSendRequest
) => Promise<PersonalWechatSendResult>
listScheduledReports: () => Promise<ScheduledReportTask[]>
listScheduledReportExecutions: (taskId?: string) => Promise<ScheduledReportExecution[]>
createScheduledReport: (
request: ScheduledReportCreateInput
) => Promise<ScheduledReportResult<ScheduledReportTask>>
updateScheduledReport: (
taskId: string,
request: ScheduledReportUpdateInput
) => Promise<ScheduledReportResult<ScheduledReportTask>>
deleteScheduledReport: (
taskId: string
) => Promise<ScheduledReportResult<{ deletedId: string }>>
setScheduledReportEnabled: (
taskId: string,
enabled: boolean
) => Promise<ScheduledReportResult<ScheduledReportTask>>
runScheduledReportNow: (
taskId: string
) => Promise<ScheduledReportResult<ScheduledReportExecution>>
getPersonalWechatVoiceDiagnostic: () => Promise<PersonalWechatVoiceDiagnostic | null>
getAgentHubStatus: () => Promise<AgentHubStatus>
getAgentHubLogs: () => Promise<AgentHubLogEntry[]>
+34
View File
@@ -33,6 +33,14 @@ import type {
PersonalWechatSenderStatus,
PersonalWechatVoiceDiagnostic
} from '../shared/personal-wechat'
import type { PersonalWechatSendCapability } from '../shared/personal-wechat'
import type {
ScheduledReportCreateInput,
ScheduledReportExecution,
ScheduledReportResult,
ScheduledReportTask,
ScheduledReportUpdateInput
} from '../shared/scheduled-report'
import type {
PersonalWechatRuntimeDownloadResult,
PersonalWechatRuntimeProgressEvent,
@@ -353,6 +361,8 @@ const api = {
ipcRenderer.invoke('image:listInsights', sessionId, limit),
getPersonalWechatSenderStatus: (): Promise<PersonalWechatSenderStatus> =>
ipcRenderer.invoke('wechat-personal:getStatus'),
getPersonalWechatSendCapability: (): Promise<PersonalWechatSendCapability> =>
ipcRenderer.invoke('wechat-personal:getSendCapability'),
getPersonalWechatKeepOneBotProcess: (): Promise<boolean> =>
ipcRenderer.invoke('wechat-personal:getKeepProcess'),
setPersonalWechatKeepOneBotProcess: (keep: boolean): Promise<boolean> =>
@@ -386,6 +396,30 @@ const api = {
sendPersonalWechatMessage: (
request: PersonalWechatSendRequest
): Promise<PersonalWechatSendResult> => ipcRenderer.invoke('wechat-personal:send', request),
listScheduledReports: (): Promise<ScheduledReportTask[]> =>
ipcRenderer.invoke('scheduled-report:list'),
listScheduledReportExecutions: (taskId?: string): Promise<ScheduledReportExecution[]> =>
ipcRenderer.invoke('scheduled-report:listExecutions', taskId),
createScheduledReport: (
request: ScheduledReportCreateInput
): Promise<ScheduledReportResult<ScheduledReportTask>> =>
ipcRenderer.invoke('scheduled-report:create', request),
updateScheduledReport: (
taskId: string,
request: ScheduledReportUpdateInput
): Promise<ScheduledReportResult<ScheduledReportTask>> =>
ipcRenderer.invoke('scheduled-report:update', taskId, request),
deleteScheduledReport: (taskId: string): Promise<ScheduledReportResult<{ deletedId: string }>> =>
ipcRenderer.invoke('scheduled-report:delete', taskId),
setScheduledReportEnabled: (
taskId: string,
enabled: boolean
): Promise<ScheduledReportResult<ScheduledReportTask>> =>
ipcRenderer.invoke('scheduled-report:setEnabled', taskId, enabled),
runScheduledReportNow: (
taskId: string
): Promise<ScheduledReportResult<ScheduledReportExecution>> =>
ipcRenderer.invoke('scheduled-report:runNow', taskId),
getPersonalWechatVoiceDiagnostic: (): Promise<PersonalWechatVoiceDiagnostic | null> =>
ipcRenderer.invoke('wechat-personal:getVoiceDiagnostic'),
getAgentHubStatus: () => ipcRenderer.invoke('agent-hub:getStatus'),
+23
View File
@@ -44,6 +44,29 @@ export interface PersonalWechatSenderStatus {
error?: string
}
/** Stable, feature-facing send capability derived from the low-level sender status. */
export type PersonalWechatSendCapabilityState =
| 'unsupported'
| 'unconfigured'
| 'needs_binding'
| 'needs_verification'
| 'ready'
| 'error'
export interface PersonalWechatSendCapability {
supported: boolean
ready: boolean
status: PersonalWechatSendCapabilityState
capabilities: {
text: boolean
image: boolean
voice: boolean
}
senderStatus: PersonalWechatSenderStatus
message: string
error?: string
}
interface PersonalWechatSendBaseRequest {
to: string
isGroup: boolean
+53
View File
@@ -0,0 +1,53 @@
export type ScheduledReportRange = 'yesterday' | 'recent24h'
export type ScheduledReportExecutionStatus = 'running' | 'success' | 'failed'
export interface ScheduledReportTask {
id: string
name: string
/** Human-readable group name or WeChat room id. */
group: string
scheduleTime: string
reportRange: ScheduledReportRange
/** Current implementation targets one specified WeChat group. */
target: string
enabled: boolean
createdAt: string
updatedAt: string
lastRunAt?: string
nextRunAt: string
/** Internal idempotency marker for a daily scheduled slot. */
lastScheduledSlot?: string
}
export interface ScheduledReportExecution {
id: string
taskId: string
startedAt: string
finishedAt?: string
status: ScheduledReportExecutionStatus
error?: string
message?: string
scheduledSlot?: string
}
export interface ScheduledReportCreateInput {
name: string
group: string
scheduleTime: string
reportRange?: ScheduledReportRange
target?: string
enabled?: boolean
}
export type ScheduledReportUpdateInput = Partial<
Pick<
ScheduledReportCreateInput,
'name' | 'group' | 'scheduleTime' | 'reportRange' | 'target' | 'enabled'
>
>
export interface ScheduledReportResult<T> {
success: boolean
data?: T
error?: string
}
@@ -0,0 +1,48 @@
import { describe, expect, it, vi } from 'vitest'
vi.mock('electron', () => ({ app: { getPath: () => '/tmp/tracememo-test-user-data' } }))
import { PersonalWechatCapabilityService } from '../../src/main/services/personal-wechat-capability-service'
import type { PersonalWechatSenderStatus } from '../../src/shared/personal-wechat'
const status = (
overrides: Partial<PersonalWechatSenderStatus> = {}
): PersonalWechatSenderStatus => ({
state: 'hook_not_ready',
platform: 'darwin',
arch: 'arm64',
sipDisabled: true,
wechatRunning: true,
endpoint: '127.0.0.1:58080',
endpointReady: true,
runtimeReady: true,
attachReady: true,
baseAddressReady: true,
textHookInstalled: true,
textHookReady: false,
imageHookInstalled: true,
imageHookReady: false,
messageListenerReady: true,
canSend: false,
canSendText: false,
canSendImage: false,
canSendVoice: false,
message: 'pending',
...overrides
})
describe('PersonalWechatCapabilityService', () => {
it.each([
['unsupported', status({ platform: 'win32' })],
['unconfigured', status({ runtimeReady: false, boundWechatPid: undefined })],
['needs_binding', status({ runtimeReady: true, boundWechatPid: undefined })],
['needs_verification', status({ boundWechatPid: 123 })],
['ready', status({ state: 'online', boundWechatPid: 123, canSendImage: true, canSend: true })],
['error', status({ state: 'error', boundWechatPid: 123, error: 'hook failed' })]
])('maps %s', (expected, senderStatus) => {
const service = new PersonalWechatCapabilityService({ getStatus: async () => senderStatus })
return expect(service.getPersonalWechatSendCapability()).resolves.toMatchObject({
status: expected,
ready: expected === 'ready',
supported: expected !== 'unsupported'
})
})
})
@@ -6,6 +6,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
vi.mock('electron', () => ({
app: {
getAppPath: () => '/fixture/app',
getPath: () => '/tmp/tracememo-test-user-data',
isPackaged: false
}
}))
@@ -0,0 +1,78 @@
import { mkdtemp, readFile } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import { describe, expect, it, vi } from 'vitest'
vi.mock('electron', () => ({ app: { getPath: () => '/tmp/tracememo-test-user-data' } }))
import {
calculateNextRunAt,
ScheduledReportService,
validateScheduleTime
} from '../../src/main/services/scheduled-report-service'
import type { PersonalWechatSendCapability } from '../../src/shared/personal-wechat'
const capability: PersonalWechatSendCapability = {
supported: true,
ready: true,
status: 'ready',
capabilities: { text: true, image: true, voice: true },
senderStatus: {} as never,
message: 'ready'
}
describe('scheduled report scheduling', () => {
it('validates daily HH:mm and computes the next local occurrence', () => {
expect(validateScheduleTime('09:05')).toBe(true)
expect(validateScheduleTime('24:00')).toBe(false)
const from = new Date('2026-08-27T10:00:00+08:00')
expect(calculateNextRunAt('09:05', from)).toBe('2026-08-28T01:05:00.000Z')
expect(calculateNextRunAt('11:05', from)).toBe('2026-08-27T03:05:00.000Z')
})
it('persists lifecycle, executes generation and image sending, and restores tasks', async () => {
const storageDir = await mkdtemp(join(tmpdir(), 'tracememo-scheduled-report-'))
const now = new Date('2026-08-27T01:00:00.000Z')
const generatedPath = join(storageDir, 'report.png')
let generated = 0
let sent = 0
const service = new ScheduledReportService({
storageDir,
now: () => now,
getCapability: async () => capability,
generateReport: async () => {
generated += 1
return { success: true, pngPath: generatedPath }
},
send: async () => {
sent += 1
return { success: true, status: capability.senderStatus }
},
isDatabaseReady: () => true
})
const created = await service.createTask({
name: '每日群报',
group: '研发群',
target: '研发群@chatroom',
scheduleTime: '09:00'
})
expect(created.success).toBe(true)
expect(created.data?.reportRange).toBe('yesterday')
const taskId = created.data!.id
const run = await service.runScheduledReportNow(taskId)
expect(run.data?.status).toBe('success')
expect(generated).toBe(1)
expect(sent).toBe(1)
expect(await service.listExecutions(taskId)).toHaveLength(1)
const restored = new ScheduledReportService({
storageDir,
getCapability: async () => capability
})
expect((await restored.listTasks())[0].id).toBe(taskId)
await service.setTaskEnabled(taskId, false)
expect((await service.listTasks())[0].enabled).toBe(false)
await service.deleteTask(taskId)
expect(await service.listTasks()).toHaveLength(0)
expect(
JSON.parse(await readFile(join(storageDir, 'executions.json'), 'utf8'))[0].message
).toContain('微信发送成功')
})
})