From 949e6bc868d027c3659a9703386d0dbc264a48e5 Mon Sep 17 00:00:00 2001 From: Wxw-Gu Date: Thu, 27 Aug 2026 17:08:09 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E5=AE=9A=E6=97=B6?= =?UTF-8?q?=E6=97=A5=E6=8A=A5=E4=B8=8E=E5=BE=AE=E4=BF=A1=E5=8F=91=E9=80=81?= =?UTF-8?q?=E8=83=BD=E5=8A=9B=E5=BA=95=E5=BA=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/index.ts | 31 ++ .../services/agent-group-report-service.ts | 7 +- .../personal-wechat-capability-service.ts | 81 ++++ src/main/services/scheduled-report-service.ts | 404 ++++++++++++++++++ src/preload/index.d.ts | 28 ++ src/preload/index.ts | 34 ++ src/shared/personal-wechat.ts | 23 + src/shared/scheduled-report.ts | 53 +++ ...personal-wechat-capability-service.test.ts | 48 +++ .../unit/personal-wechat-send-service.test.ts | 1 + tests/unit/scheduled-report-service.test.ts | 78 ++++ 11 files changed, 786 insertions(+), 2 deletions(-) create mode 100644 src/main/services/personal-wechat-capability-service.ts create mode 100644 src/main/services/scheduled-report-service.ts create mode 100644 src/shared/scheduled-report.ts create mode 100644 tests/unit/personal-wechat-capability-service.test.ts create mode 100644 tests/unit/scheduled-report-service.test.ts diff --git a/src/main/index.ts b/src/main/index.ts index 0509964..19cd9d3 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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), diff --git a/src/main/services/agent-group-report-service.ts b/src/main/services/agent-group-report-service.ts index 63bfe0b..dda423b 100644 --- a/src/main/services/agent-group-report-service.ts +++ b/src/main/services/agent-group-report-service.ts @@ -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: '所选时间范围没有可总结的消息' } diff --git a/src/main/services/personal-wechat-capability-service.ts b/src/main/services/personal-wechat-capability-service.ts new file mode 100644 index 0000000..739d8b4 --- /dev/null +++ b/src/main/services/personal-wechat-capability-service.ts @@ -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) {} + + async getPersonalWechatSendCapability(): Promise { + 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 => + personalWechatCapabilityService.getPersonalWechatSendCapability() diff --git a/src/main/services/scheduled-report-service.ts b/src/main/services/scheduled-report-service.ts new file mode 100644 index 0000000..1490943 --- /dev/null +++ b/src/main/services/scheduled-report-service.ts @@ -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 + generateReport: typeof generateAgentGroupReport + send: (request: PersonalWechatSendRequest) => ReturnType + 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(['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 = (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>() + + constructor(deps?: Partial) { + this.deps = { ...defaultDependencies(), ...deps } + } + + async start(): Promise { + 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 { + await this.load() + return this.tasks!.map((task) => ({ ...task })) + } + + async listExecutions(taskId?: string): Promise { + 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> { + 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> { + 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> { + 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> { + 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> { + 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 { + 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 { + 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 { + 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 => { + 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> + 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> + 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 { + if (this.tasks && this.executions) return + await fs.mkdir(this.deps.storageDir, { recursive: true }) + const [tasks, executions] = await Promise.all([ + this.readJson(TASKS_FILE), + this.readJson(EXECUTIONS_FILE) + ]) + this.tasks = asArray(tasks) + this.executions = asArray(executions) + } + + private async readJson(file: string): Promise { + 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 { + await fs.writeFile( + path.join(this.deps.storageDir, TASKS_FILE), + JSON.stringify(this.tasks, null, 2), + 'utf8' + ) + } + + private async saveExecutions(): Promise { + await fs.writeFile( + path.join(this.deps.storageDir, EXECUTIONS_FILE), + JSON.stringify(this.executions, null, 2), + 'utf8' + ) + } +} + +export const scheduledReportService = new ScheduledReportService() diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index d4715f6..9740bee 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -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 + getPersonalWechatSendCapability: () => Promise getPersonalWechatKeepOneBotProcess: () => Promise setPersonalWechatKeepOneBotProcess: (keep: boolean) => Promise getPersonalWechatRuntimeStatus: () => Promise @@ -598,6 +607,25 @@ declare global { sendPersonalWechatMessage: ( request: PersonalWechatSendRequest ) => Promise + listScheduledReports: () => Promise + listScheduledReportExecutions: (taskId?: string) => Promise + createScheduledReport: ( + request: ScheduledReportCreateInput + ) => Promise> + updateScheduledReport: ( + taskId: string, + request: ScheduledReportUpdateInput + ) => Promise> + deleteScheduledReport: ( + taskId: string + ) => Promise> + setScheduledReportEnabled: ( + taskId: string, + enabled: boolean + ) => Promise> + runScheduledReportNow: ( + taskId: string + ) => Promise> getPersonalWechatVoiceDiagnostic: () => Promise getAgentHubStatus: () => Promise getAgentHubLogs: () => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index 747f8b0..4914e28 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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 => ipcRenderer.invoke('wechat-personal:getStatus'), + getPersonalWechatSendCapability: (): Promise => + ipcRenderer.invoke('wechat-personal:getSendCapability'), getPersonalWechatKeepOneBotProcess: (): Promise => ipcRenderer.invoke('wechat-personal:getKeepProcess'), setPersonalWechatKeepOneBotProcess: (keep: boolean): Promise => @@ -386,6 +396,30 @@ const api = { sendPersonalWechatMessage: ( request: PersonalWechatSendRequest ): Promise => ipcRenderer.invoke('wechat-personal:send', request), + listScheduledReports: (): Promise => + ipcRenderer.invoke('scheduled-report:list'), + listScheduledReportExecutions: (taskId?: string): Promise => + ipcRenderer.invoke('scheduled-report:listExecutions', taskId), + createScheduledReport: ( + request: ScheduledReportCreateInput + ): Promise> => + ipcRenderer.invoke('scheduled-report:create', request), + updateScheduledReport: ( + taskId: string, + request: ScheduledReportUpdateInput + ): Promise> => + ipcRenderer.invoke('scheduled-report:update', taskId, request), + deleteScheduledReport: (taskId: string): Promise> => + ipcRenderer.invoke('scheduled-report:delete', taskId), + setScheduledReportEnabled: ( + taskId: string, + enabled: boolean + ): Promise> => + ipcRenderer.invoke('scheduled-report:setEnabled', taskId, enabled), + runScheduledReportNow: ( + taskId: string + ): Promise> => + ipcRenderer.invoke('scheduled-report:runNow', taskId), getPersonalWechatVoiceDiagnostic: (): Promise => ipcRenderer.invoke('wechat-personal:getVoiceDiagnostic'), getAgentHubStatus: () => ipcRenderer.invoke('agent-hub:getStatus'), diff --git a/src/shared/personal-wechat.ts b/src/shared/personal-wechat.ts index d7cbd1f..0925b30 100644 --- a/src/shared/personal-wechat.ts +++ b/src/shared/personal-wechat.ts @@ -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 diff --git a/src/shared/scheduled-report.ts b/src/shared/scheduled-report.ts new file mode 100644 index 0000000..bd673d5 --- /dev/null +++ b/src/shared/scheduled-report.ts @@ -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 { + success: boolean + data?: T + error?: string +} diff --git a/tests/unit/personal-wechat-capability-service.test.ts b/tests/unit/personal-wechat-capability-service.test.ts new file mode 100644 index 0000000..75d3b3d --- /dev/null +++ b/tests/unit/personal-wechat-capability-service.test.ts @@ -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 => ({ + 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' + }) + }) +}) diff --git a/tests/unit/personal-wechat-send-service.test.ts b/tests/unit/personal-wechat-send-service.test.ts index a6ef669..0a48657 100644 --- a/tests/unit/personal-wechat-send-service.test.ts +++ b/tests/unit/personal-wechat-send-service.test.ts @@ -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 } })) diff --git a/tests/unit/scheduled-report-service.test.ts b/tests/unit/scheduled-report-service.test.ts new file mode 100644 index 0000000..b69ef34 --- /dev/null +++ b/tests/unit/scheduled-report-service.test.ts @@ -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('微信发送成功') + }) +})