diff --git a/src/main/index.ts b/src/main/index.ts index aee0e7e..8cc1012 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -135,10 +135,6 @@ import type { PersonalWechatSendResult, PersonalWechatSenderStatus } from '../shared/personal-wechat' -import type { - ScheduledReportCreateInput, - ScheduledReportUpdateInput -} from '../shared/scheduled-report' import { isTruthyDebugFlag } from '../shared/debug-flags' import { TextToSpeechSettingsService } from './services/text-to-speech-settings-service' import type { @@ -1047,6 +1043,30 @@ app.whenReady().then(async () => { */ let automationListening = false initAutomationService(wcdb4Client, { isListening: () => automationListening }) + + /** + * 旧「定时日报任务」迁移所需的会话解析器。 + * + * **必须在数据库就绪之后注入**:旧 `task.group` 可能是会话 md5、群名或 roomId + * 三种形态,只有 `resolveMd5` 能收敛成稳定会话 id。 + * 注入前若渲染层已经读过一次规则(`automation:listRules`),迁移会**整体推迟** + * 而不落盘 —— 注入这一步会立刻补跑(见 `AutomationRuleStore.setLegacyConversationResolver`)。 + * + * 这样设计的原因:拿不到解析器时如果把旧数据判成「无法无损映射」, + * 用户的定时日报会被**无辜停用**,而事实只是"数据库还没打开"。 + */ + automationRuleStore.setLegacyConversationResolver((raw) => { + const key = String(raw || '').trim() + if (!key) return undefined + // 已经是稳定会话 id 就直接用,不查库。 + if (key.endsWith('@chatroom')) return key + try { + const username = String(chat.resolveMd5(key)?.m_nsUsrName || '').trim() + return username || undefined + } catch { + return undefined + } + }) /** * 退群通知的唯一通路:**退群监控只负责产生事件,自动化负责发送**。 * @@ -1993,44 +2013,58 @@ app.whenReady().then(async () => { }) ipcMain.handle('wechat-personal:getSendCapability', () => getPersonalWechatSendCapability()) - ipcMain.handle('scheduled-report:list', () => scheduledReportService.listTasks()) - ipcMain.handle('scheduled-report:listExecutions', (_, taskId?: string) => - scheduledReportService.listExecutions(taskId) + + /** + * 定时日报(Automation 的 `scheduled_report` 规则类型)。 + * + * 规则本身的 CRUD 走上面的 `automation:*`;这里只补三块**专属**能力: + * 1. 「立即执行」—— 走 `AutomationService.executeScheduledRule(ruleId,'manual')`, + * 与 scheduler 触发**同一条链路**(生成 → 落库 → 解析目标 → Gateway 发图); + * 2. 微信异常通知 —— 定时日报失败时的 Agent Hub 推送,随功能保留; + * 3. 旧执行记录**只读存档** —— 旧记录无法无损转换,原样保留给 UI 展示。 + */ + ipcMain.handle('automation:runScheduledReportRule', async (_, ruleId: string) => { + const service = tryAutomationService() + if (!service) return { success: false, error: '数据库尚未就绪,暂时无法执行定时日报' } + const outcome = await service.executeScheduledRule(String(ruleId || ''), { trigger: 'manual' }) + if (outcome.executed) { + return { success: outcome.status !== 'failed', data: outcome } + } + // 「没执行」的每一种原因都要能翻译成用户看得懂的一句话 —— 否则点了按钮没反应,无从排查。 + const skipMessages: Record = { + missing_rule: '规则 id 无效', + rule_not_found: '未找到这条定时日报规则', + disabled: '这条定时日报已停用,请先启用再执行', + target_needs_review: '这条规则的发送目标需要重新选择后才能执行', + in_flight: '这条规则正在执行中,请稍后再试', + store_error: '读取规则失败,请稍后再试' + } + return { + success: false, + error: skipMessages[outcome.reason || ''] || '定时日报未执行', + data: outcome + } + }) + ipcMain.handle('automation:listScheduledReportLegacyExecutions', (_, ruleId?: string) => + scheduledReportService.listLegacyExecutions(ruleId) ) - ipcMain.handle('scheduled-report:getNotificationSettings', () => + ipcMain.handle('automation:getScheduledReportNotificationSettings', () => scheduledReportService.getNotificationSettings() ) - ipcMain.handle('scheduled-report:setNotificationEnabled', (_, enabled: boolean) => + ipcMain.handle('automation:getScheduledReportNotificationCapability', () => + scheduledReportService.checkNotificationCapability() + ) + ipcMain.handle('automation:setScheduledReportNotificationEnabled', (_, enabled: boolean) => scheduledReportService.setNotificationEnabled(Boolean(enabled)) ) - 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('scheduled-report:retrySend', (_, executionId: string) => - scheduledReportService.retryScheduledReportSend(executionId) - ) - ipcMain.handle('scheduled-report:testErrorNotification', (_, taskId: string) => { + ipcMain.handle('automation:testScheduledReportErrorNotification', (_, ruleId: string) => { if (!isTruthyDebugFlag(import.meta.env.VITE_SCHEDULED_REPORT_DEBUG)) { return Promise.resolve({ success: false, error: '调试测试按钮未开启,请在 .env 中设置 VITE_SCHEDULED_REPORT_DEBUG=true。' }) } - return scheduledReportService.testScheduledReportErrorNotification(taskId) + return scheduledReportService.testScheduledReportErrorNotification(ruleId) }) ipcMain.handle('report:reveal', async (_, filePath: string) => { diff --git a/src/main/services/automation-action-runner.ts b/src/main/services/automation-action-runner.ts index eb9cc08..ad5229d 100644 --- a/src/main/services/automation-action-runner.ts +++ b/src/main/services/automation-action-runner.ts @@ -5,24 +5,30 @@ import { DEFAULT_REPLY_TEXT, automationIdempotencyKey, normalizeReplyDelaySeconds, + scheduledReportRangeLabel, type AutomationAction, + type AutomationExecutionTrigger, type AutomationRule, type AutomationStep, type AutomationStepKey, - type LeaveNotificationConfig + type LeaveNotificationConfig, + type ScheduledReportAutomationConfig } from '../../shared/automation' import { renderLeaveNotificationText, type GroupMemberExitedEvent } from '../../shared/group-exit-event' import type { WechatActionContent, WechatActionRequest, WechatActionResult } from '../../shared/wechat-action' +import type { SaveGeneratedReportRequest, SaveGeneratedReportResult } from '../../shared/report-history' import { generateAgentGroupReport, type AgentGroupReportRequest, type AgentGroupReportResult } from './agent-group-report-service' import { wechatActionGateway } from './wechat-action-gateway' -import type { LeaveNotificationTargetResolution } from './leave-notification-target' +import { saveGeneratedReport } from '../report-history-service' +import { getContactAvatars, resolveMd5 } from './chat-service' +import type { AutomationTargetResolution } from './automation-wechat-target' /** * AutomationActionRunner —— 把一次命中跑成**步骤序列**。 @@ -46,6 +52,20 @@ const LEAVE_NOTIFICATION_STEP_ORDER: AutomationStepKey[] = [ 'exit_send' ] +/** + * 定时日报的步骤顺序(与另外两族**不共用**)。 + * + * 「生成中 / 已生成」与「确定目标 / 已发送」**刻意拆成四步**: + * 用户必须能一眼看出"日报确实生成了,只是没发出去" —— 合成两步就表达不了。 + */ +const SCHEDULED_REPORT_STEP_ORDER: AutomationStepKey[] = [ + 'schedule_triggered', + 'report_generating', + 'report_generated', + 'send_resolved', + 'report_sent' +] + /** * 前置步骤失败时,后续步骤的 `skipReason`。 * @@ -54,6 +74,15 @@ const LEAVE_NOTIFICATION_STEP_ORDER: AutomationStepKey[] = [ const SKIPPED_AFTER_REPLY_FAILURE = '前置步骤失败(回复确认未成功),本次不再继续。' const SKIPPED_AFTER_REPORT_FAILURE = '前置步骤失败(日报未生成),没有图片可发送。' +/** + * 定时日报「生成 / 落库」失败时,后续步骤的 `skipReason`。 + * + * 与 `SKIPPED_AFTER_REPORT_FAILURE` 的区别:那条说的是「@我日报没图可发」, + * 这条说的是「定时日报这一步就没跑起来」—— 文案分开,避免用户混淆两条链路。 + */ +const SKIPPED_AFTER_SCHEDULED_GENERATION_FAILURE = + '前置步骤失败(定时日报未生成或未保存),本次未发送。' + /** * 规则启用了「发送日报图片」,但手上没有图片文件。 * @@ -90,7 +119,7 @@ export interface LeaveNotificationRunInput { executionId: string event: GroupMemberExitedEvent config: LeaveNotificationConfig - resolution: LeaveNotificationTargetResolution + resolution: AutomationTargetResolution /** 事件来源的显示名(群名 / 「群聊」)。**不含 wxid**。 */ sourceDisplayName: string /** 发送能力缺失时的一句话说明;有值时 `exit_send` 直接判失败。 */ @@ -103,12 +132,69 @@ export interface LeaveNotificationRunResult { errorSummary?: string } +/** + * 定时日报的执行输入。 + * + * 与退群通知同构:目标解析、来源显示名、发送能力预检都在 `AutomationService` + * 调用前算好,runner 只负责"跑成步骤序列"。 + */ +export interface ScheduledReportRunInput { + executionId: string + rule: AutomationRule + config: ScheduledReportAutomationConfig + resolution: AutomationTargetResolution + /** 日报来源群的显示名(群名 / 「日报来源群」)。**不含 roomId**。 */ + sourceDisplayName: string + /** + * 本次触发方式。 + * + * 只影响**发送节流口径**:定时触发算 `automation`(纳入发送节流), + * 用户在页面上点「立即执行」算 `user`(与旧定时日报的手动执行一致)。 + * 执行日志里的 `trigger` 由 `AutomationService` 单独记录,不从这里读。 + */ + trigger: Extract + /** 发送能力缺失时的一句话说明;有值时 `report_sent` 直接判失败。 */ + sendBlockedReason?: string +} + +export interface ScheduledReportRunResult { + steps: AutomationStep[] + status: 'success' | 'failed' + errorSummary?: string + /** + * 日报是否**已经生成并落库**。 + * + * 这是「生成成功但发送失败」的判据:`reportGenerated === true && status === 'failed'` + * 就是"日报在,只是没发出去"。日报本身已进日报历史,不会被丢掉。 + */ + reportGenerated: boolean + /** 生成出来的 PNG(仅在内存里传递,**不落执行日志**)。 */ + pngPath?: string + /** + * 底层生成错误的**机器可读码**(例如 `NO_MESSAGES`)。 + * + * 用途只有一个:让上层区分「真的失败了」和「这一天没有消息可生成」—— + * 后者不该给用户推微信异常通知。 + */ + errorCode?: string +} + export interface AutomationActionRunnerDependencies { generateReport?: (request: AgentGroupReportRequest) => Promise executeAction?: (request: WechatActionRequest) => Promise now?: () => number /** 延迟实现。默认真 sleep;单测注入即时 resolve 的假实现,避免真的等 2 秒。 */ delay?: (ms: number) => Promise + /** 日报落库(日报历史)。与旧定时日报**复用同一个**实现,不复制。 */ + saveGeneratedReport?: (request: SaveGeneratedReportRequest) => Promise + /** 把日报来源群标识解析成联系人(拿头像 / 显示名)。 */ + resolveReportContact?: (raw: string) => { + md5?: string + m_nsUsrName?: string + m_nsNickName?: string + avatar?: string + } | null + getContactAvatars?: (usernames: string[]) => Promise> } /** 策略层的错误码 → 用户可读短句。UI 直接展示这些文案,不做二次翻译。 */ @@ -161,6 +247,13 @@ export class AutomationActionRunner { private readonly executeAction: (request: WechatActionRequest) => Promise private readonly now: () => number private readonly delay: (ms: number) => Promise + private readonly saveGeneratedReport: ( + request: SaveGeneratedReportRequest + ) => Promise + private readonly resolveReportContact: NonNullable< + AutomationActionRunnerDependencies['resolveReportContact'] + > + private readonly getContactAvatars: (usernames: string[]) => Promise> constructor(dependencies: AutomationActionRunnerDependencies = {}) { this.generateReport = dependencies.generateReport ?? generateAgentGroupReport @@ -169,6 +262,9 @@ export class AutomationActionRunner { this.delay = dependencies.delay ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))) + this.saveGeneratedReport = dependencies.saveGeneratedReport ?? saveGeneratedReport + this.resolveReportContact = dependencies.resolveReportContact ?? ((raw) => resolveMd5(raw)) + this.getContactAvatars = dependencies.getContactAvatars ?? ((ids) => getContactAvatars(ids)) } async run(input: AutomationRunInput): Promise { @@ -273,6 +369,224 @@ export class AutomationActionRunner { return { steps, status: 'success', pngPath } } + /** + * 定时日报:把一次「到点触发 / 手动立即执行」跑成一次生成 + 一次发送。 + * + * 与旧 `ScheduledReportService.executeTask` 的行为对齐(**不重写日报能力**): + * 1. 先生成(含落库进日报历史)—— 发送能力不足**也照常生成**(旧语义如此); + * 2. 再解析目标 —— 解析失败**不 fallback**,直接判失败; + * 3. 最后经 `WechatActionGateway` 发图片 —— 仍然是**发图片**,不退化成纯文本。 + * + * 「生成成功但发送失败」是可表达的:`report_generated` 为 success、 + * `report_sent` 为 failed,整体 `failed`,且 `reportGenerated === true`。 + */ + async runScheduledReport(input: ScheduledReportRunInput): Promise { + const steps = SCHEDULED_REPORT_STEP_ORDER.map((key) => createStep(key)) + const stepAt = (key: AutomationStepKey): AutomationStep => + steps.find((step) => step.key === key) as AutomationStep + + markSuccess(stepAt('schedule_triggered'), this.now()) + + // ---- 生成日报(含落进日报历史)---- + const generatingStep = stepAt('report_generating') + generatingStep.status = 'running' + generatingStep.startedAt = this.now() + let generated: AgentGroupReportResult + try { + generated = await this.generateReport({ + group: input.config.report.sourceConversationId, + range: input.config.report.range, + messageTypes: input.config.report.messageTypes, + templateId: input.config.report.templateId, + memberNameMode: input.config.report.memberNameMode, + timeoutSeconds: input.config.report.timeoutSeconds + }) + } catch (error) { + generated = { + success: false, + error: error instanceof Error ? error.message : String(error) + } + } + if (!generated.success || !generated.pngPath) { + // 生成失败就**不发**:绝不生成空图片、也绝不退而求其次发上一张旧图。 + const reason = generated.error || '日报生成失败' + markFailed(generatingStep, this.now(), reason) + markSkipped(stepAt('report_generated'), SKIPPED_AFTER_SCHEDULED_GENERATION_FAILURE) + markSkipped(stepAt('send_resolved'), SKIPPED_AFTER_SCHEDULED_GENERATION_FAILURE) + markSkipped(stepAt('report_sent'), SKIPPED_AFTER_SCHEDULED_GENERATION_FAILURE) + return { + steps, + status: 'failed', + errorSummary: reason, + reportGenerated: false, + ...(generated.errorCode ? { errorCode: generated.errorCode } : {}) + } + } + + let pngPath = generated.pngPath + try { + const reportContact = this.resolveReportContact(input.config.report.sourceConversationId) + let contactAvatar = reportContact?.avatar + if (!contactAvatar && reportContact?.m_nsUsrName) { + try { + const avatars = await this.getContactAvatars([reportContact.m_nsUsrName]) + contactAvatar = avatars[reportContact.m_nsUsrName] + } catch (error) { + console.warn('[Automation] 日报群头像补全失败:', error) + } + } + const savedHistory = await this.saveGeneratedReport({ + contactId: reportContact?.md5 || input.config.report.sourceConversationId, + contactName: + generated.groupName || reportContact?.m_nsNickName || input.sourceDisplayName || '群聊', + contactAvatar, + source: 'scheduled', + dateRange: generated.reportMetadata?.dateRange || scheduledReportRangeLabel(input.config.report.range), + reportDate: generated.reportMetadata?.reportDate, + messageCount: generated.messageCount ?? generated.reportMetadata?.messageCount ?? 0, + generatedAt: new Date(this.now()).toISOString(), + htmlPath: generated.htmlPath, + pngPath: generated.pngPath, + duration: generated.duration, + modelName: generated.modelName, + tokenUsage: generated.tokenUsage, + reportSnapshot: generated.reportSnapshot, + reportMetadata: generated.reportMetadata, + templateId: input.config.report.templateId + }) + if (!savedHistory.success) { + throw new Error(savedHistory.error || '日报历史保存失败') + } + const recordPath = savedHistory.record?.pngPath || generated.pngPath + if (!recordPath) throw new Error('日报历史未返回可发送的 PNG 文件') + pngPath = recordPath + } catch (error) { + const reason = error instanceof Error ? error.message : String(error) + markFailed(generatingStep, this.now(), reason) + markSkipped(stepAt('report_generated'), SKIPPED_AFTER_SCHEDULED_GENERATION_FAILURE) + markSkipped(stepAt('send_resolved'), SKIPPED_AFTER_SCHEDULED_GENERATION_FAILURE) + markSkipped(stepAt('report_sent'), SKIPPED_AFTER_SCHEDULED_GENERATION_FAILURE) + return { steps, status: 'failed', errorSummary: reason, reportGenerated: false } + } + markSuccess(generatingStep, this.now()) + + const generatedStep = stepAt('report_generated') + markSuccess(generatedStep, this.now()) + const messageCount = generated.messageCount ?? 0 + if (messageCount > 0) generatedStep.detail = `共 ${messageCount} 条消息` + + // ---- 目标解析失败:不许 fallback 到任何地方,直接判失败 ---- + const targetStep = stepAt('send_resolved') + if (!input.resolution.ok) { + markFailed(targetStep, this.now(), input.resolution.error) + markSkipped(stepAt('report_sent'), '没有可用的发送目标,本次未发送。') + return { + steps, + status: 'failed', + errorSummary: input.resolution.error, + reportGenerated: true, + pngPath + } + } + targetStep.status = 'success' + targetStep.startedAt = this.now() + targetStep.finishedAt = targetStep.startedAt + targetStep.durationMs = 0 + // 用户可读的目标名(例如「文件传输助手」「张三」「我」)—— 不带任何 id。 + targetStep.detail = input.resolution.target.displayName + + const sendStep = stepAt('report_sent') + + // ---- 发送能力缺失:日报已生成,如实判失败(不回滚、不隐藏)---- + if (input.sendBlockedReason) { + markFailed(sendStep, this.now(), input.sendBlockedReason) + return { + steps, + status: 'failed', + errorSummary: input.sendBlockedReason, + reportGenerated: true, + pngPath + } + } + + sendStep.status = 'running' + sendStep.startedAt = this.now() + const sent = await this.sendScheduledReportImage(input, pngPath) + if (!sent.ok) { + markFailed(sendStep, this.now(), sent.error || '发送日报失败') + return { + steps, + status: 'failed', + errorSummary: sendStep.error, + reportGenerated: true, + pngPath + } + } + markSuccess(sendStep, this.now()) + return { steps, status: 'success', reportGenerated: true, pngPath } + } + + /** + * 定时日报的统一发送出口。 + * + * **必须走 `WechatActionGateway`**:幂等 + 3 秒发送间隔 + 审计落盘都在那里。 + * Automation 层不允许知道 OneBot / WCHook / native host / Windows hook 的存在。 + */ + private async sendScheduledReportImage( + input: ScheduledReportRunInput, + pngPath: string + ): Promise<{ ok: boolean; error?: string }> { + if (!input.resolution.ok) return { ok: false, error: input.resolution.error } + // 定时触发走 automation(纳入发送节流);用户手动执行走 user(不纳入节流)。 + const triggerType = input.trigger === 'manual' ? 'user' : 'automation' + try { + const result = await this.executeAction({ + idempotencyKey: `${AUTOMATION_SEND_PURPOSE.scheduledReport}:${input.executionId}`, + origin: AUTOMATION_SEND_ORIGIN, + purpose: AUTOMATION_SEND_PURPOSE.scheduledReport, + triggerType, + executionId: input.executionId, + recipient: input.resolution.target.recipient, + content: { type: 'image', path: pngPath } + }) + if (result.status !== 'sent') { + return { ok: false, error: actionErrorMessage(result.errorCode, result.reason) } + } + + /* + * 后置词:**只在图片明确 sent 之后**才发,图片失败时严格短路 —— + * 与 `WechatActionGateway.executeReportImageSequence`(手动发送)同一口径。 + * 空字符串 = 用户只要图片,什么都不补发。 + * + * 独立 purpose + 独立幂等位:共用一个 key 会让「图片发成功、后置词被 + * 幂等短路」变成常态。 + */ + const postfixText = String(input.config.postfixText || '').trim() + if (!postfixText) return { ok: true } + + const postfix = await this.executeAction({ + idempotencyKey: `${AUTOMATION_SEND_PURPOSE.scheduledReportPostfix}:${input.executionId}`, + origin: AUTOMATION_SEND_ORIGIN, + purpose: AUTOMATION_SEND_PURPOSE.scheduledReportPostfix, + triggerType, + executionId: input.executionId, + recipient: input.resolution.target.recipient, + content: { type: 'text', text: postfixText } + }) + if (postfix.status === 'sent') return { ok: true } + // 绝不吞掉:图片确实发出去了,但这一次执行**没有完成**,必须如实报出来。 + return { + ok: false, + error: `日报图片已发送,但后置词发送失败:${actionErrorMessage( + postfix.errorCode, + postfix.reason + )}` + } + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : String(error) } + } + } + /** * 退群通知:把「检测到成员退出」这件事跑成一次发送。 * diff --git a/src/main/services/automation-execution-log-service.ts b/src/main/services/automation-execution-log-service.ts index 2ee8ae0..0901ae4 100644 --- a/src/main/services/automation-execution-log-service.ts +++ b/src/main/services/automation-execution-log-service.ts @@ -1,7 +1,10 @@ import path from 'node:path' import { app } from 'electron' import fs from 'fs-extra' -import type { AutomationExecution } from '../../shared/automation' +import type { + AutomationExecution, + AutomationExecutionTrigger +} from '../../shared/automation' /** * AutomationExecutionLogService —— **用户层**执行日志。 @@ -24,6 +27,8 @@ export interface AutomationExecutionLogDependencies { const EXECUTION_STATUSES: AutomationExecution['status'][] = ['running', 'success', 'failed'] +const EXECUTION_TRIGGERS: AutomationExecutionTrigger[] = ['message', 'exit', 'schedule', 'manual'] + function normalizeExecution(value: unknown): AutomationExecution | null { if (!value || typeof value !== 'object') return null const record = value as Partial @@ -34,6 +39,17 @@ function normalizeExecution(value: unknown): AutomationExecution | null { ruleId: String(record.ruleId || ''), ruleName: String(record.ruleName || ''), triggerTime: Number(record.triggerTime) || 0, + /* + * `trigger` 必须在这里**显式透传**。 + * + * 这个归一化是白名单式的:没列出来的字段会被静默丢掉。定时日报的 + * 「本次是定时跑的还是用户点的立即执行」全靠这个字段区分, + * 漏掉它就会变成"写的时候有、读出来永远没有"—— 而且没有任何报错。 + * 旧记录没有这个字段,保持 `undefined`(读盘不猜测)。 + */ + ...(EXECUTION_TRIGGERS.includes(record.trigger as AutomationExecutionTrigger) + ? { trigger: record.trigger as AutomationExecutionTrigger } + : {}), sourceDisplayName: String(record.sourceDisplayName || ''), // 不认识的 status(含历史遗留值)一律降级成 `running`,绝不凭空造出成功/失败。 status: EXECUTION_STATUSES.includes(record.status as AutomationExecution['status']) diff --git a/src/main/services/automation-rule-store.ts b/src/main/services/automation-rule-store.ts index 226fa3f..06212c3 100644 --- a/src/main/services/automation-rule-store.ts +++ b/src/main/services/automation-rule-store.ts @@ -6,15 +6,22 @@ import { BUILTIN_LEAVE_NOTIFICATION_RULE_ID, createDefaultDailyReportRule, createDefaultLeaveNotificationRule, + createDefaultScheduledReportRule, normalizeRuleDraft, type AutomationRule, - type AutomationRuleType + type AutomationRuleType, + type ScheduledReportAutomationConfig } from '../../shared/automation' import { planLeaveNotificationMigration, type LeaveNotificationMigrationPlan, type LegacyLeaveNotificationState } from './leave-notification-migration' +import { + planScheduledReportMigrationBatch, + type LegacyScheduledReportTask, + type ScheduledReportMigrationSummary +} from './scheduled-report-migration' import { GROUP_EXIT_NOTIFICATION_TEMPLATE_MAX_LENGTH, insertGroupNamePlaceholder @@ -26,12 +33,10 @@ import { * 存储形态刻意做到最简:一个 JSON 文件(`{userData}/automation/rules.json`)。 * 规则总量是个位数到几十条,引入数据库只会增加迁移负担。 * - * **内置规则的播种标记(`builtinSeeded`)单独存**: + * **一次性标记全部单独存**(`builtinSeeded` / `leaveNotificationMigrated` / + * `notificationTemplateUpgraded` / `scheduledReportMigrated`): * 如果靠"文件里有没有那条内置规则"来判断是否播种,用户一旦删掉它, - * 下次启动就会被重新塞回来 —— 用户会认为删除功能坏了。 - * - * 退群通知的迁移标记(`leaveNotificationMigrated`)同理:迁移**只允许跑一次**, - * 否则用户删掉退群通知规则后重启又被塞回来。 + * 下次启动就会被重新塞回来 —— 用户会认为删除功能坏了。迁移同理。 */ const STORAGE_DIR = 'automation' @@ -39,7 +44,11 @@ const RULES_FILE = 'rules.json' const MIGRATION_BACKUP_FILE = 'leave-notification-migration-backup.json' /** 旧退群监控的状态文件(迁移时读一次,之后运行期不再读)。 */ const LEGACY_MONITOR_FILE = 'group-exit-monitor.json' -const CURRENT_VERSION = 3 +/** 旧定时日报的**任务**文件(迁移时读一次,之后运行期不再读)。 */ +const LEGACY_SCHEDULED_DIR = 'scheduled-reports' +const LEGACY_SCHEDULED_TASKS_FILE = 'tasks.json' +const SCHEDULED_MIGRATION_BACKUP_FILE = 'scheduled-report-migration-backup.json' +const CURRENT_VERSION = 4 interface StoredRules { version: number @@ -54,12 +63,27 @@ interface StoredRules { * 于是用户主动删掉那一行后、下次启动又会被补回来 —— 删不掉的东西最烦人。 */ notificationTemplateUpgraded: boolean + /** + * 旧「定时日报」任务是否已经迁移过。 + * + * ⚠️ 与其它标记有一点不同:迁移需要**把旧群标识解析成稳定会话 id**, + * 而那需要数据库。数据库没就绪时这一位**保持 false**,等解析器注入后再补跑 —— + * 否则会把每一条规则都误判成"目标无法确认"。 + */ + scheduledReportMigrated: boolean rules: AutomationRule[] } export interface AutomationRuleStoreDependencies { userDataPath?: () => string now?: () => number + /** + * 迁移旧定时日报时,把 legacy 群标识(会话 md5 / 群名 / roomId)解析成 + * **稳定会话 id**(`xxx@chatroom`)。解析不到返回 `undefined`。 + * + * 不注入 ⇒ 迁移**推迟**(不写任何规则、不置标记)。 + */ + resolveLegacyConversationId?: (raw: string) => string | undefined } function emptyState(): StoredRules { @@ -68,19 +92,24 @@ function emptyState(): StoredRules { builtinSeeded: false, leaveNotificationMigrated: false, notificationTemplateUpgraded: false, + scheduledReportMigrated: false, rules: [] } } function normalizeRuleType(value: unknown): AutomationRuleType { - return value === 'leave_notification' ? 'leave_notification' : 'daily_report' + return value === 'leave_notification' + ? 'leave_notification' + : value === 'scheduled_report' + ? 'scheduled_report' + : 'daily_report' } /** * 单条规则的读盘归一化。 * * 走 `normalizeRuleDraft` —— 它覆盖了 `AutomationRule` 除 id / 时间戳之外的**全部**字段, - * 所以这是无损的,同时自动补上历史 rules.json 缺失的 `ruleType` / `leaveNotification`。 + * 所以这是无损的,同时自动补上历史 rules.json 缺失的 `ruleType` / 各类 config。 */ function normalizeStoredRule(value: unknown): AutomationRule | null { if (!value || typeof value !== 'object') return null @@ -92,8 +121,9 @@ function normalizeStoredRule(value: unknown): AutomationRule | null { { ...raw, ruleType, - // 历史 daily_report 规则没有 leaveNotification;反过来也要避免脏字段落盘。 - ...(ruleType === 'leave_notification' ? { leaveNotification: raw.leaveNotification } : {}) + // 历史规则没有这些字段;反过来也要避免脏字段落盘。 + ...(ruleType === 'leave_notification' ? { leaveNotification: raw.leaveNotification } : {}), + ...(ruleType === 'scheduled_report' ? { scheduledReport: raw.scheduledReport } : {}) }, String(raw.name || '').trim() || '未命名自动化' ) @@ -106,6 +136,9 @@ function normalizeStoredRule(value: unknown): AutomationRule | null { updatedAt, ...(normalized.ruleType === 'leave_notification' && normalized.leaveNotification ? { leaveNotification: normalized.leaveNotification } + : {}), + ...(normalized.ruleType === 'scheduled_report' && normalized.scheduledReport + ? { scheduledReport: normalized.scheduledReport } : {}) } } @@ -115,7 +148,6 @@ function normalizeStoredRule(value: unknown): AutomationRule | null { * * **为什么替他填**:模板里原先根本没有群名变量(`{groupRemark}` 是成员在本群的昵称)。 * 目标一旦不是「当前群聊」,通知就等于「张三退群了」—— 收件人不知道是哪个群。 - * 模板是用户已保存的数据,让他自己去发现"少一个变量"是不合理的。 * * **为什么只跑一次**:靠"模板里有没有 `{groupName}`"判断会导致用户删掉后被反复补回来。 * 所以由 `notificationTemplateUpgraded` 标记保证一次性;用户之后删掉不会回来。 @@ -139,7 +171,24 @@ function upgradeLeaveNotificationTemplate(rules: AutomationRule[]): { return { rules: next, count } } -/** 读盘容错:任何字段可疑都降级成安全值,绝不因为一个坏文件让功能整体不可用。 */function normalizeStored(value: unknown): StoredRules { +/** + * 调度游标(`lastRunAt` / `lastScheduledSlot`)**由 scheduler 拥有**,草稿改不动它们。 + * + * 保存规则时一律从 `current` 取,而不是从草稿取 —— 否则用户进一次编辑页保存, + * 就会把"已消费的槽位"抹掉,导致当天重复补跑一次日报。 + */ +function pickScheduledReportRuntime( + current: ScheduledReportAutomationConfig | undefined +): Partial { + if (!current) return {} + return { + ...(current.lastRunAt ? { lastRunAt: current.lastRunAt } : {}), + ...(current.lastScheduledSlot ? { lastScheduledSlot: current.lastScheduledSlot } : {}) + } +} + +/** 读盘容错:任何字段可疑都降级成安全值,绝不因为一个坏文件让功能整体不可用。 */ +function normalizeStored(value: unknown): StoredRules { if (!value || typeof value !== 'object') return emptyState() const input = value as Partial const rules = Array.isArray(input.rules) @@ -152,6 +201,7 @@ function upgradeLeaveNotificationTemplate(rules: AutomationRule[]): { builtinSeeded: input.builtinSeeded === true, leaveNotificationMigrated: input.leaveNotificationMigrated === true, notificationTemplateUpgraded: input.notificationTemplateUpgraded === true, + scheduledReportMigrated: input.scheduledReportMigrated === true, rules } } @@ -163,10 +213,31 @@ export class AutomationRuleStore { private loaded = false /** 迁移结果,供启动日志/报告读取(不含任何 id)。 */ private lastMigrationPlan: LeaveNotificationMigrationPlan | null = null + private lastScheduledMigrationSummary: ScheduledReportMigrationSummary | null = null + private legacyConversationResolver: ((raw: string) => string | undefined) | null constructor(dependencies: AutomationRuleStoreDependencies = {}) { this.userDataPath = dependencies.userDataPath ?? (() => app.getPath('userData')) this.now = dependencies.now ?? (() => Date.now()) + this.legacyConversationResolver = dependencies.resolveLegacyConversationId ?? null + } + + /** + * 注入 / 替换旧定时日报迁移所需的会话解析器(数据库就绪后由 main 调用)。 + * + * 若之前因为"解析器不可用"推迟了迁移,这里会**立刻补跑**并落盘。 + */ + setLegacyConversationResolver(resolver: (raw: string) => string | undefined): void { + this.legacyConversationResolver = resolver + if (!this.loaded) return + if (this.state.scheduledReportMigrated) return + if (this.runScheduledReportMigration(this.state)) this.persist() + } + + /** 旧定时日报迁移是否仍在等待解析器(仅用于启动日志与测试断言)。 */ + hasPendingScheduledReportMigration(): boolean { + this.ensureLoaded() + return !this.state.scheduledReportMigrated && this.readLegacyScheduledTasks().length > 0 } listRules(): AutomationRule[] { @@ -187,6 +258,12 @@ export class AutomationRuleStore { return this.lastMigrationPlan ? { ...this.lastMigrationPlan } : null } + /** 上一次旧定时日报迁移的统计(未迁移时为 null)。 */ + getLastScheduledReportMigration(): ScheduledReportMigrationSummary | null { + this.ensureLoaded() + return this.lastScheduledMigrationSummary ? { ...this.lastScheduledMigrationSummary } : null + } + createRule(draft: unknown): AutomationRule { this.ensureLoaded() const timestamp = this.now() @@ -219,11 +296,26 @@ export class AutomationRuleStore { ...(current.ruleType === 'leave_notification' && normalized.leaveNotification ? { leaveNotification: normalized.leaveNotification } : {}), + ...(current.ruleType === 'scheduled_report' && normalized.scheduledReport + ? { + scheduledReport: { + ...normalized.scheduledReport, + ...pickScheduledReportRuntime(current.scheduledReport) + } + } + : {}), createdAt: current.createdAt, updatedAt: this.now() } // 切类型时不能留下另一种类型才认识的字段。 - if (rule.ruleType === 'daily_report') delete rule.leaveNotification + if (rule.ruleType === 'daily_report') { + delete rule.leaveNotification + delete rule.scheduledReport + } else if (rule.ruleType === 'leave_notification') { + delete rule.scheduledReport + } else { + delete rule.leaveNotification + } this.state.rules = this.state.rules.map((item, at) => (at === index ? rule : item)) this.persist() return structuredClone(rule) @@ -275,6 +367,38 @@ export class AutomationRuleStore { return this.updateRule(id, { ...rule, enabled: enabled === true }) } + /** + * 写**调度游标**(`lastRunAt` / `lastScheduledSlot`)。 + * + * 刻意不走 `updateRule`:这不是用户配置编辑,走草稿归一化会顺带触碰别的字段, + * 也可能被"草稿里没这个字段"给抹掉。这里只允许改这两个键。 + */ + setScheduledReportRuntime( + id: string, + patch: { lastRunAt?: string; lastScheduledSlot?: string } + ): AutomationRule | undefined { + this.ensureLoaded() + const key = String(id || '').trim() + const index = this.state.rules.findIndex((rule) => rule.id === key) + if (index < 0) return undefined + const current = this.state.rules[index] + if (current.ruleType !== 'scheduled_report' || !current.scheduledReport) return undefined + const next: AutomationRule = { + ...current, + scheduledReport: { + ...current.scheduledReport, + ...(patch.lastRunAt !== undefined ? { lastRunAt: patch.lastRunAt } : {}), + ...(patch.lastScheduledSlot !== undefined + ? { lastScheduledSlot: patch.lastScheduledSlot } + : {}) + }, + updatedAt: this.now() + } + this.state.rules = this.state.rules.map((item, at) => (at === index ? next : item)) + this.persist() + return structuredClone(next) + } + private ensureLoaded(): void { if (this.loaded) return this.loaded = true @@ -301,6 +425,7 @@ export class AutomationRuleStore { console.log(`[Automation] 退群通知模板已补上群名变量(${upgraded.count} 条)`) } } + this.runScheduledReportMigration(stored) stored.version = CURRENT_VERSION this.state = stored this.persist() @@ -326,11 +451,110 @@ export class AutomationRuleStore { template: plan.template, target: plan.target, enabled: plan.enabled, - targetNeedsReview: plan.targetNeedsReview + // 二次勾选(旧「通知群聊」)逐字保留,迁移不再是有损的。 + notifyScope: plan.notifyScope, + notifyRoomIds: plan.notifyRoomIds }) ] } + /** + * 旧「定时日报」任务 → `scheduled_report` 规则。**只跑一次**。 + * + * 返回 `true` 表示本次真的做了迁移并需要落盘。 + * **解析器不可用时返回 `false` 且不置标记** —— 迁移推迟到数据库就绪后补跑。 + */ + private runScheduledReportMigration(stored: StoredRules): boolean { + if (stored.scheduledReportMigrated) return false + const tasks = this.readLegacyScheduledTasks() + if (!tasks.length) { + // 全新安装 / 从没用过旧定时日报:没有历史可迁,直接置位。 + stored.scheduledReportMigrated = true + console.log('[Automation] 定时日报迁移 outcome=fresh_install') + return true + } + const resolver = this.legacyConversationResolver + if (!resolver) { + console.log( + `[Automation] 定时日报迁移已推迟:数据库尚未就绪,无法解析旧群标识(待迁 ${tasks.length} 条)` + ) + return false + } + const { plans, summary } = planScheduledReportMigrationBatch(tasks, resolver, this.now()) + this.lastScheduledMigrationSummary = summary + this.writeScheduledMigrationBackup(tasks, summary) + const existing = new Set(stored.rules.map((rule) => rule.id)) + const migrated: AutomationRule[] = [] + for (const plan of plans) { + // 幂等:同一 id 已存在就不再插入(重复执行迁移也不会多出规则)。 + if (existing.has(plan.ruleId)) continue + existing.add(plan.ruleId) + migrated.push({ + ...createDefaultScheduledReportRule(plan.createdAt, { + id: plan.ruleId, + name: plan.name, + enabled: plan.enabled, + config: plan.config + }), + createdAt: plan.createdAt, + updatedAt: plan.updatedAt + }) + } + stored.rules = [...stored.rules, ...migrated] + stored.scheduledReportMigrated = true + // 只记数量与判定分布,**禁止**出现群名 / roomId / 群主昵称。 + console.log( + `[Automation] 定时日报迁移 outcome=done total=${summary.total} migrated=${migrated.length}` + + ` lossless=${summary.lossless} needsReview=${summary.needsReview}` + + ` duplicatesSkipped=${summary.duplicatesSkipped}` + ) + return true + } + + /** + * 读旧定时日报**任务**文件。 + * + * 只读、只在这里读一次;运行期其余代码**不再读** legacy(避免双读)。 + * 读失败视为"没有历史任务",不抛。 + */ + private readLegacyScheduledTasks(): LegacyScheduledReportTask[] { + try { + const value = fs.readJsonSync( + path.join(this.userDataPath(), LEGACY_SCHEDULED_DIR, LEGACY_SCHEDULED_TASKS_FILE) + ) as unknown + return Array.isArray(value) ? (value as LegacyScheduledReportTask[]) : [] + } catch { + return [] + } + } + + /** 迁移前把旧任务**原样**落一份 backup(不做不可逆覆盖)。 */ + private writeScheduledMigrationBackup( + tasks: LegacyScheduledReportTask[], + summary: ScheduledReportMigrationSummary + ): void { + try { + const filePath = path.join( + this.userDataPath(), + STORAGE_DIR, + SCHEDULED_MIGRATION_BACKUP_FILE + ) + fs.ensureDirSync(path.dirname(filePath)) + fs.writeJsonSync( + filePath, + { + migratedAt: this.now(), + summary, + // 备份里保留原始字段:这是**用户数据**,不是日志,不进任何用户可见界面。 + legacyTasks: tasks + }, + { spaces: 2 } + ) + } catch (error) { + console.warn(`[Automation] 写入定时日报迁移备份失败: ${errorText(error)}`) + } + } + /** * 读旧退群监控状态文件里的通知相关字段。 * @@ -358,7 +582,7 @@ export class AutomationRuleStore { /** * 迁移前把旧配置原样落一份 backup。 * - * §「不要不可逆直接覆盖」:旧状态文件本身也**不删**,只是不再被运行时代码读取。 + * 不做不可逆覆盖:旧状态文件本身也**不删**,只是不再被运行时代码读取。 */ private writeMigrationBackup( legacy: LegacyLeaveNotificationState, @@ -381,15 +605,14 @@ export class AutomationRuleStore { applied: { enabled: plan.enabled, targetType: plan.target.type, - targetNeedsReview: plan.targetNeedsReview + notifyScope: plan.notifyScope, + notifyRoomIds: plan.notifyRoomIds } }, { spaces: 2 } ) } catch (error) { - console.warn( - `[Automation] 写入退群通知迁移备份失败: ${errorText(error)}` - ) + console.warn(`[Automation] 写入退群通知迁移备份失败: ${errorText(error)}`) } } diff --git a/src/main/services/automation-service.ts b/src/main/services/automation-service.ts index b2e9ac2..3128119 100644 --- a/src/main/services/automation-service.ts +++ b/src/main/services/automation-service.ts @@ -6,9 +6,12 @@ import { import { AUTOMATION_SEND_PURPOSE, BUILTIN_LEAVE_NOTIFICATION_RULE_ID, + leaveNotificationCoversRoom, type AutomationExecution, + type AutomationExecutionTrigger, type AutomationRule, - type AutomationStatusSummary + type AutomationStatusSummary, + type ScheduledRuleRunOutcome } from '../../shared/automation' import type { GroupMemberExitedEvent } from '../../shared/group-exit-event' import type { PersonalWechatSendCapability } from '../../shared/personal-wechat' @@ -20,6 +23,11 @@ import { resolveLeaveNotificationTarget, type LeaveNotificationTargetResolution } from './leave-notification-target' +import { + resolveScheduledReportTarget, + scheduledReportSourceDisplayName +} from './scheduled-report-target' +import type { AutomationTargetContact } from './automation-wechat-target' import { automationRuleStore, type AutomationRuleStore @@ -32,7 +40,6 @@ import { automationActionRunner, type AutomationActionRunner } from './automation-action-runner' - /** * AutomationService —— Automation v1 的编排层。 * @@ -118,6 +125,26 @@ export function leaveNotificationIdempotencyKey(eventId: string): string { return `${AUTOMATION_SEND_PURPOSE.leaveNotification}:${eventId}` } +/** + * 定时日报的**确定性** executionId。 + * + * 由 `ruleId + 槽位` 推出(不是 `randomUUID`):同一个每日槽位即使因为 + * 崩溃重启被重复触发,也算出同一个 executionId —— + * `ExecutionLog.record()` 按 id 覆盖,`WechatActionGateway` 的落盘幂等键 + * (`automation_scheduled_report:`)直接把第二次发送短路掉。 + * + * **手动执行不算槽位**:每次都应该是独立的一次执行,否则用户连点两次 + * 「立即执行」会因为幂等而被静默吞掉。 + */ +export function scheduledReportExecutionId(ruleId: string, slot: string): string { + return createHash('sha1') + .update(`scheduled_report\u0001${ruleId}\u0001${slot}`) + .digest('hex') +} + +/** 定时日报一次执行的结果(定义在 shared 层,便于 IPC / 渲染层共用)。 */ +export type { ScheduledRuleRunOutcome } + export interface AutomationServiceDependencies { ruleStore?: AutomationRuleStore executionLog?: AutomationExecutionLogService @@ -159,6 +186,16 @@ export class AutomationService { */ private readonly gates = new Map() + /** + * 定时日报的 `ruleId inFlight` 锁。 + * + * **刻意不用 `gates`(conversation gate)**:定时日报的触发与"某个会话刚被触发过" + * 没有关系,套 60 秒 cooldown 会莫名其妙吞掉当天的补跑。 + * 这里只要一条最朴素的约束:**同一条规则同时最多一个 execution**; + * 不同规则之间互不影响(可以并行)。 + */ + private readonly scheduledInFlight = new Set() + /** 仅用于诊断统计(`[Automation] blockedMessages=N`),**不进用户执行日志**。 */ private blockedMessageCount = 0 @@ -300,10 +337,16 @@ export class AutomationService { * - 消息型规则:MessageListener → handleMessage → 动作链; * - 退群通知:GroupExitMonitor → **本入口** → 单次文本发送。 * - * 三层闸门(缺一不可): - * 1. `enabled === false` → 不执行,**且不创建 execution**(§「没有执行:不创建」); - * 2. `targetNeedsReview` → 迁移过来但目标无法无损映射,同样不执行、不创建记录; - * 3. `ruleId + eventId` 幂等 → 同一事件即使重复投递也只发送一次。 + * 四层闸门(缺一不可): + * 1. `enabled === false` → 不执行,**且不创建 execution**; + * 2. `targetNeedsReview` → 存量迁移遗留的"目标待重选",同样不执行、不创建记录; + * 3. **通知范围**(`notifyScope` / `notifyRoomIds`)→ 事件所在群不在二次勾选范围内则跳过。 + * 这是旧版「退群监控 → 通知群聊」的第二层,缺了它规则一启用就全量通知; + * 同样**不创建 execution** —— 被范围排除不是"一次失败的执行"。 + * 4. `ruleId + eventId` 幂等 → 同一事件即使重复投递也只发送一次。 + * + * 闸门顺序有意如此:范围判定放在幂等**之前**,否则一个被排除的群会白白占用 + * 该事件的幂等位(用户随后真的补勾了那个群,反而发不出去)。 */ async handleGroupExit(event: GroupMemberExitedEvent): Promise { const eventId = String(event?.eventId || '').trim() @@ -326,7 +369,12 @@ export class AutomationService { this.warn(`退群通知目标待重新选择,已跳过本次通知 ruleId=${rule.id}`) return } - // ③ 幂等(内存快路径)。跨重启那一层由确定性 executionId + gateway 审计兜住。 + // ③ 通知范围:这个群没被勾选就不归本规则管(与 UI 共用 shared 同一份判定)。 + if (!leaveNotificationCoversRoom(rule.leaveNotification, event.conversationId)) { + this.info(`退群通知范围外,已跳过本次通知 ruleId=${rule.id}`) + return + } + // ④ 幂等(内存快路径)。跨重启那一层由确定性 executionId + gateway 审计兜住。 if (!this.claimGroupExit(rule.id, eventId)) return const executionId = leaveNotificationExecutionId(rule.id, eventId) @@ -382,8 +430,26 @@ export class AutomationService { rule: AutomationRule, event: GroupMemberExitedEvent ): LeaveNotificationTargetResolution { - let contacts: ReturnType = [] + const { contacts, selfWxid } = this.resolveTargetContext() + return resolveLeaveNotificationTarget({ + config: rule.leaveNotification, + event, + contacts, + selfWxid + }) + } + + /** + * 联系人清单 + 当前登录 wxid。 + * + * 退群通知与定时日报**共用这一处**:两份都要"把目标解析成可发送对象", + * 解析所需的数据源(通讯录 / 当前账号)必须只有一个出口, + * 否则两边会慢慢长成两套判定。 + */ + private resolveTargetContext(): { contacts: AutomationTargetContact[]; selfWxid: string } { + let contacts: AutomationTargetContact[] = [] try { + // `FormattedContact` 结构上就满足 `AutomationTargetContact`,不需要断言。 contacts = listContacts() } catch (error) { this.warn(`读取联系人失败: ${errorText(error)}`) @@ -394,12 +460,172 @@ export class AutomationService { } catch (error) { this.warn(`读取当前账号失败: ${errorText(error)}`) } - return resolveLeaveNotificationTarget({ - config: rule.leaveNotification, - event, - contacts, - selfWxid - }) + return { contacts, selfWxid } + } + + /** + * 定时日报执行入口。**scheduler 与「立即执行」共用这一条**。 + * + * ```text + * Scheduler ─┐ + * ├─→ executeScheduledRule(ruleId, { trigger, scheduledSlot }) + * 手动执行 ───┘ ↓ resolveScheduledReportTarget + * ↓ runner.runScheduledReport(生成 → 落库 → 解析目标 → Gateway 发图) + * ↓ ExecutionLog.record(trigger = schedule | manual) + * ``` + * + * 三层闸门(与退群通知同口径,但**不套 conversation gate / cooldown**): + * 1. `enabled === false` → 不执行、不创建记录; + * 2. `targetNeedsReview` → 目标无法无损映射,不执行、不创建记录; + * 3. `ruleId inFlight` → 同一条规则同时最多一个 execution(不同规则可并行)。 + */ + async executeScheduledRule( + ruleId: string, + options: { trigger: 'schedule' | 'manual'; scheduledSlot?: string } + ): Promise { + const key = String(ruleId || '').trim() + if (!key) return { executed: false, reason: 'missing_rule' } + + let rule: AutomationRule | undefined + try { + rule = this.ruleStore.getRule(key) + } catch (error) { + this.warn(`读取定时日报规则失败: ${errorText(error)}`) + return { executed: false, reason: 'store_error' } + } + if (!rule || rule.ruleType !== 'scheduled_report' || !rule.scheduledReport) { + return { executed: false, reason: 'rule_not_found' } + } + if (!rule.enabled) return { executed: false, reason: 'disabled' } + if (rule.scheduledReport.targetNeedsReview) { + this.warn(`定时日报目标待重新选择,已跳过 ruleId=${rule.id}`) + return { executed: false, reason: 'target_needs_review' } + } + + // 并发保护:同一条规则同时最多一个执行。冲突时**直接返回**,不排队 —— + // 排队会让"18:00 那次没跑完"变成"18:00 和 18:01 各发一份"。 + if (this.scheduledInFlight.has(rule.id)) { + return { executed: false, reason: 'in_flight' } + } + this.scheduledInFlight.add(rule.id) + + const scheduledSlot = String(options.scheduledSlot || '').trim() + try { + const startedAt = this.now() + const executionId = + options.trigger === 'schedule' && scheduledSlot + ? scheduledReportExecutionId(rule.id, scheduledSlot) + : randomUUID() + + const { contacts, selfWxid } = this.resolveTargetContext() + const resolution = resolveScheduledReportTarget({ + config: rule.scheduledReport, + contacts, + selfWxid + }) + const sourceDisplayName = scheduledReportSourceDisplayName(rule.scheduledReport, contacts) + const sendBlockedReason = await this.scheduledReportCapabilityError() + + let result: Awaited> + try { + result = await this.runner.runScheduledReport({ + executionId, + rule, + config: rule.scheduledReport, + resolution, + sourceDisplayName, + trigger: options.trigger === 'manual' ? 'manual' : 'schedule', + ...(sendBlockedReason ? { sendBlockedReason } : {}) + }) + } catch (error) { + result = { + steps: [], + status: 'failed', + errorSummary: `执行过程异常:${errorText(error)}`, + reportGenerated: false + } + } + + this.executionLog.record( + this.buildExecution({ + executionId, + rule, + startedAt, + sourceDisplayName, + trigger: options.trigger, + status: result.status, + durationMs: this.now() - startedAt, + steps: result.steps, + ...(result.errorSummary ? { errorSummary: result.errorSummary } : {}) + }) + ) + + this.recordScheduledRun(rule.id, { + trigger: options.trigger, + ...(scheduledSlot ? { scheduledSlot } : {}), + finishedAt: new Date(this.now()).toISOString() + }) + + // 隐私红线:日志只允许 ruleId / executionId / 触发方式 / 状态 / 耗时 / 步骤状态。 + // 群名、昵称、wxid、日报正文、图片路径一律不进日志。 + const trail = result.steps.map((step) => `${step.key}=${step.status}`).join(' ') + this.info( + `executed ruleId=${rule.id} executionId=${executionId} trigger=${options.trigger}` + + ` status=${result.status} durationMs=${this.now() - startedAt}${trail ? ` ${trail}` : ''}` + ) + + return { + executed: true, + executionId, + status: result.status, + reportGenerated: result.reportGenerated, + ...(result.errorCode ? { errorCode: result.errorCode } : {}), + ...(result.errorSummary ? { errorSummary: result.errorSummary } : {}) + } + } finally { + this.scheduledInFlight.delete(rule.id) + } + } + + /** + * 写调度游标。 + * + * - `lastRunAt`:**任何一次执行**(含手动)都会更新 —— UI 的"最近执行"要如实反映; + * - `lastScheduledSlot`:**只有定时触发**才消费槽位。 + * 手动执行若也写它,会把今天已经安排好的那次定时执行一起吞掉。 + */ + private recordScheduledRun( + ruleId: string, + input: { trigger: 'schedule' | 'manual'; scheduledSlot?: string; finishedAt: string } + ): void { + try { + this.ruleStore.setScheduledReportRuntime(ruleId, { + lastRunAt: input.finishedAt, + ...(input.trigger === 'schedule' && input.scheduledSlot + ? { lastScheduledSlot: input.scheduledSlot } + : {}) + }) + } catch (error) { + // 游标写失败不影响本次执行 —— 但必须留痕,否则"同一天重复补跑"会变成无声故障。 + this.warn(`写入定时日报调度游标失败 ruleId=${ruleId}: ${errorText(error)}`) + } + } + + /** 定时日报只需要图片能力。缺失时给出用户能看懂的一句说明。 */ + private async scheduledReportCapabilityError(): Promise { + let capability: PersonalWechatSendCapability | null = null + try { + capability = await this.getCapability() + } catch { + return '暂时无法获知微信发送能力,本次定时日报未发送。' + } + if (!capability?.supported) { + return capability?.message || '当前系统不支持微信消息发送' + } + if (!capability.capabilities?.image) { + return '当前环境无法发送图片,本次定时日报未发送。' + } + return undefined } /** 退群通知只需要文字能力。缺失时给出用户能看懂的一句说明。 */ @@ -565,6 +791,8 @@ export class AutomationService { rule: AutomationRule startedAt: number sourceDisplayName: string + /** 触发方式。旧调用点不传时为 `undefined` —— 读盘不猜测。 */ + trigger?: AutomationExecutionTrigger status: AutomationExecution['status'] durationMs: number steps: AutomationExecution['steps'] @@ -575,6 +803,7 @@ export class AutomationService { ruleId: input.rule.id, ruleName: input.rule.name, triggerTime: input.startedAt, + ...(input.trigger ? { trigger: input.trigger } : {}), sourceDisplayName: input.sourceDisplayName, status: input.status, durationMs: input.durationMs, diff --git a/src/main/services/automation-wechat-target.ts b/src/main/services/automation-wechat-target.ts new file mode 100644 index 0000000..c024ac4 --- /dev/null +++ b/src/main/services/automation-wechat-target.ts @@ -0,0 +1,266 @@ +import { + LEAVE_NOTIFICATION_TARGET_OPTIONS, + SCHEDULED_REPORT_TARGET_OPTIONS, + leaveNotificationTargetLabel, + scheduledReportTargetLabel +} from '../../shared/automation' +import { WECHAT_FILE_HELPER_USERNAME, isFileHelperUsername } from '../../shared/wechat-identities' + +/** + * **中性的微信发送目标解析**。 + * + * 退群通知与定时日报的目标语义完全一致(来源会话 / 自己 / 文件传输助手 / 指定好友), + * 差的只是"来源会话"从哪来: + * - 退群通知 → 事件所在群(`event.conversationId`); + * - 定时日报 → 该规则配置的日报来源群(`config.report.sourceConversationId`)。 + * + * ⇒ 所以**只允许有一份实现**(只保留一套 resolve)。 + * 两个业务各自只有一层薄适配(把参数摊平后交给这里),不重复任何判定逻辑。 + * + * 五条规则(两边共用): + * 1. 目标解析失败 → **报错**,绝不 fallback 到别的目标(尤其不许偷偷发文件助手 / 来源群); + * 2. 只允许出现在选项表里的目标类型; + * 3. 显示名不带 wxid / roomId:日志与界面只出现用户能看懂的称呼; + * 4. 联系人必须**可发送**(排除群聊 / 公众号 / 文件传输助手 / 自己); + * 5. 只有 `source_chat` 用得到来源会话,其余目标完全不看它。 + */ + +/** 解析所需的最小联系人结构(main 侧 `FormattedContact` 结构上可赋值给它)。 */ +export interface AutomationTargetContact { + m_nsUsrName: string + m_nsNickName?: string + md5?: string + type: 'user' | 'group' + isOfficialAccount?: boolean + wechatNickname?: string + remark?: string +} + +/** 目标类型(两个业务共用同一组字面量)。 */ +export type AutomationTargetType = 'source_chat' | 'self' | 'file_transfer' | 'contact' + +export interface ResolvedAutomationTarget { + recipient: { type: 'group' | 'contact'; id: string; name: string } + /** 用户可读的目标名,直接进执行日志的 `detail`。 */ + displayName: string +} + +export type AutomationTargetResolution = + | { ok: true; target: ResolvedAutomationTarget } + | { ok: false; error: string } + +/** 联系人的展示名:备注 → 微信昵称 → 会话昵称 → 兜底称呼。**永不回落到 wxid**。 */ +export function automationContactDisplayName(contact: AutomationTargetContact): string { + return ( + contact.remark?.trim() || + contact.wechatNickname?.trim() || + contact.m_nsNickName?.trim() || + '指定好友' + ) +} + +/** + * 这个联系人能不能作为「指定好友」的发送目标。 + * + * 排除:群聊、公众号(`gh_`)、文件传输助手、自己。 + * UI 的联系人选择器与运行时的目标解析**共用这一条判定**, + * 否则会出现"能选但发不出去"。 + */ +export function isSendableFriendContact( + contact: AutomationTargetContact, + selfWxid?: string +): boolean { + if (contact.type !== 'user') return false + if (contact.isOfficialAccount) return false + const username = String(contact.m_nsUsrName || '').trim() + if (!username) return false + if (isFileHelperUsername(username)) return false + const self = String(selfWxid || '').trim() + if (self && username === self) return false + return true +} + +/** 供 UI 用的「可选好友」过滤(与运行时同一套判定)。 */ +export function filterSendableFriendContacts( + contacts: T[], + selfWxid?: string +): T[] { + return contacts.filter((contact) => isSendableFriendContact(contact, selfWxid)) +} + +/** 目标类型是否在(该业务的)选项表里。 */ +function isKnownTargetType( + type: string, + options: ReadonlyArray<{ type: string }> +): boolean { + return options.some((option) => option.type === type) +} + +/** + * 把群标识解析成**稳定会话 id**(`xxx@chatroom`)。 + * + * 兼容三种历史形态:roomId 本身 / 会话 md5 / 群名。 + * 解析不出来返回 `undefined` —— 调用方必须如实报错,**不许回落**。 + */ +export function resolveGroupConversationId( + raw: string, + contacts: AutomationTargetContact[] +): string | undefined { + const value = String(raw || '').trim() + if (!value) return undefined + if (value.endsWith('@chatroom')) return value + const matched = contacts.find( + (contact) => + (contact.type === 'group' || contact.m_nsUsrName.endsWith('@chatroom')) && + (contact.md5 === value || + contact.m_nsUsrName === value || + contact.m_nsNickName?.trim() === value) + ) + const conversationId = String(matched?.m_nsUsrName || '').trim() + return conversationId.endsWith('@chatroom') ? conversationId : undefined +} + +/** 群标识 → 可读群名(解析不到时返回空串,由调用方决定兜底文案)。 */ +export function resolveGroupDisplayName( + raw: string, + contacts: AutomationTargetContact[] +): string { + const value = String(raw || '').trim() + if (!value) return '' + const matched = contacts.find( + (contact) => + contact.m_nsUsrName === value || + contact.md5 === value || + contact.m_nsNickName?.trim() === value + ) + return String(matched?.m_nsNickName || '').trim() +} + +export interface ResolveAutomationTargetInput { + targetType: AutomationTargetType + /** + * `source_chat` 时使用:来源会话(退群事件所在群 / 日报来源群)。 + * 允许是 roomId / 会话 md5 / 群名 —— 一律经 `resolveGroupConversationId` 收敛。 + */ + sourceConversationId?: string + /** 来源会话的可读名(拿不到时留空,由这里给兜底称呼)。 */ + sourceDisplayName?: string + /** `contact` 时使用:稳定 id(wxid / username)。 */ + contactId?: string + contacts: AutomationTargetContact[] + /** 当前登录账号的 wxid;拿不到时传空串。 */ + selfWxid?: string +} + +/** + * 目标解析的**文案与类型表**(按业务注入)。 + * + * 为什么把文案参数化而不是各写一份 switch:两个业务的**判定逻辑必须一模一样**, + * 只有"主语"不同。文案集中放在这里,判定仍然只有一份。 + */ +export interface AutomationTargetMessages { + /** 目标类型表的单一来源(与 UI 选项同源)。 */ + options: ReadonlyArray<{ type: string }> + /** 类型非法(伪造值 / 未知值)。 */ + invalidTarget: string + /** `source_chat` 但来源会话拿不到。 */ + sourceMissing: string + /** `source_chat` 且来源会话没有可读名时,`recipient.name` 的兜底。 */ + sourceRecipientName: string + /** `source_chat` 且来源会话没有可读名时,`displayName` 的兜底。 */ + sourceDisplayName: string + /** `self` 但拿不到自身身份。 */ + selfMissing: string + /** `contact` 但没选联系人。 */ + contactNotChosen: string + /** `contact` 但联系人失效 / 不可发送。 */ + contactUnavailable: string +} + +/** + * 中性目标解析。**唯一的实现**。 + */ +export function resolveAutomationTarget( + input: ResolveAutomationTargetInput, + messages: AutomationTargetMessages +): AutomationTargetResolution { + const type = input.targetType + if (!type || !isKnownTargetType(type, messages.options)) { + return { ok: false, error: messages.invalidTarget } + } + + switch (type) { + case 'source_chat': { + const conversationId = resolveGroupConversationId( + String(input.sourceConversationId || ''), + input.contacts + ) + if (!conversationId) return { ok: false, error: messages.sourceMissing } + const explicitName = String(input.sourceDisplayName || '').trim() + const resolvedName = resolveGroupDisplayName(conversationId, input.contacts) + const name = explicitName || resolvedName || messages.sourceRecipientName + const displayName = explicitName || resolvedName || messages.sourceDisplayName + return { + ok: true, + target: { + recipient: { type: 'group', id: conversationId, name }, + displayName + } + } + } + + case 'self': { + const selfWxid = String(input.selfWxid || '').trim() + // 明确报错,不 fallback。 + if (!selfWxid) return { ok: false, error: messages.selfMissing } + return { + ok: true, + target: { + recipient: { type: 'contact', id: selfWxid, name: '我' }, + displayName: '我' + } + } + } + + case 'file_transfer': + return { + ok: true, + target: { + recipient: { + type: 'contact', + id: WECHAT_FILE_HELPER_USERNAME, + name: '文件传输助手' + }, + displayName: '文件传输助手' + } + } + + case 'contact': { + const contactId = String(input.contactId || '').trim() + if (!contactId) return { ok: false, error: messages.contactNotChosen } + const contact = input.contacts.find( + (item) => String(item.m_nsUsrName || '').trim() === contactId + ) + if (!contact || !isSendableFriendContact(contact, input.selfWxid)) { + // 之前选择的联系人被删除 / 不可发送 / 找不到时,规则不偷偷改发别处。 + return { ok: false, error: messages.contactUnavailable } + } + const displayName = automationContactDisplayName(contact) + return { + ok: true, + target: { + recipient: { type: 'contact', id: contactId, name: displayName }, + displayName + } + } + } + } +} + +/** 退群通知的已知目标类型表(与文案同源)。 */ +export const LEAVE_NOTIFICATION_TARGET_TYPES = LEAVE_NOTIFICATION_TARGET_OPTIONS + +/** 定时日报的已知目标类型表(与文案同源)。 */ +export const SCHEDULED_REPORT_TARGET_TYPES = SCHEDULED_REPORT_TARGET_OPTIONS + +export { leaveNotificationTargetLabel, scheduledReportTargetLabel } diff --git a/src/main/services/leave-notification-migration.ts b/src/main/services/leave-notification-migration.ts index d5191d6..180f2ad 100644 --- a/src/main/services/leave-notification-migration.ts +++ b/src/main/services/leave-notification-migration.ts @@ -1,6 +1,7 @@ import { - LEAVE_NOTIFICATION_DEFAULT_TARGET_TYPE, + normalizeLeaveNotificationRoomIds, normalizeLeaveNotificationTemplate, + type LeaveNotificationNotifyScope, type LeaveNotificationTarget } from '../../shared/automation' @@ -12,6 +13,11 @@ import { * * ## 旧语义(实测自 `group-exit-monitor-service.ts`) * + * 旧版是**两层**配置,缺一不可: + * 1. **管理群聊**(`monitoredRoomIds`)= 监测哪些群有人退出; + * 2. **通知群聊**(`notificationRoomIds`)= 上面这批群里,**哪些要真的发通知** + * —— 即对第 1 层的二次勾选。 + * * `notificationRoomIds` **不是**"统一通知接收目标列表",而是 * **"哪些被监控群要在自己群里通报成员退出"**(per-group 多值): * - `applyCurrentMemberships()` 只在 `notificationRoomIds.has(roomId)` 时通知; @@ -19,10 +25,13 @@ import { * * ## 新语义 * - * 单值目标:`source_chat` / `self` / `file_transfer` / `contact`。 + * 拆成**两个正交维度**,恰好一一对应旧版那两层: + * - `target` ← 旧版第 2 层里"发回本群"这个事实 ⇒ 恒为 `source_chat`; + * - `notifyScope` / `notifyRoomIds` ← 旧版第 2 层是"哪些群",逐字保留。 * - * ⇒ 两者**只在一种情况下等价**:旧配置把**每一个**被监控群都勾上了通知。 - * 严格子集无法表达,按保守策略处理,绝不"随便取第一个群"。 + * ⇒ **迁移现在是无损的**。早期版本把目标压成单值、丢掉了第 2 层的"哪些群", + * 于是部分勾选只能判成 `needs_review` 并强制停用;恢复 `notifyScope` 之后 + * 这个特例整体消失,用户不会再有"规则被无辜停用"的体验。 */ export interface LegacyLeaveNotificationState { @@ -34,30 +43,23 @@ export interface LegacyLeaveNotificationState { export type LeaveNotificationMigrationOutcome = /** 旧配置从不通知任何人 ⇒ 迁移成"关闭",行为完全一致。 */ | 'lossless_off' - /** 旧配置在**每个**被监控群里通报 ⇒ 等价于 `source_chat`,行为完全一致。 */ - | 'lossless_source_chat' - /** 旧配置只在**部分**群里通报 ⇒ 单值目标无法表达,需用户重选。 */ - | 'needs_review' + /** 旧配置在**每个**被监控群里通报 ⇒ `notifyScope: 'all'`,行为完全一致。 */ + | 'lossless_all_groups' + /** 旧配置只在**部分**群里通报 ⇒ `notifyScope: 'selected'` + 原样保留那份勾选。 */ + | 'lossless_selected_groups' export interface LeaveNotificationMigrationPlan { outcome: LeaveNotificationMigrationOutcome enabled: boolean target: LeaveNotificationTarget + notifyScope: LeaveNotificationNotifyScope + notifyRoomIds: string[] targetNeedsReview: boolean template: string } function uniqueRoomIds(values: unknown): string[] { - if (!Array.isArray(values)) return [] - const result: string[] = [] - const seen = new Set() - for (const value of values) { - const roomId = String(value || '').trim() - if (!roomId || seen.has(roomId)) continue - seen.add(roomId) - result.push(roomId) - } - return result + return normalizeLeaveNotificationRoomIds(values) } export function planLeaveNotificationMigration( @@ -71,36 +73,44 @@ export function planLeaveNotificationMigration( ) // 模板永远是无损迁移的那一项:它就是用户写的那段文字。 const template = normalizeLeaveNotificationTemplate(legacy.notificationTemplate) - const defaultTarget: LeaveNotificationTarget = { type: LEAVE_NOTIFICATION_DEFAULT_TARGET_TYPE } + // 旧版收件人恒为事件所在群 ⇒ 无论勾了几个群,目标都是 source_chat。 + const target: LeaveNotificationTarget = { type: 'source_chat' } if (!notifications.length) { // 旧语义是"从不通知" ⇒ 关掉,避免迁移后突然开始发消息。 + // 同时把范围写成 `selected` + 空集:配置本身也精确表达"一个都不通知"。 return { outcome: 'lossless_off', enabled: false, - target: defaultTarget, + target, + notifyScope: 'selected', + notifyRoomIds: [], targetNeedsReview: false, template } } if (monitored.length > 0 && notifications.length === monitored.length) { - // 每个被监控群都在自己群里通报 ⇔ 单值的 `source_chat`。 + // 每个被监控群都在自己群里通报 ⇒ 等价于"全部已监控群聊"。 return { - outcome: 'lossless_source_chat', + outcome: 'lossless_all_groups', enabled: true, - target: { type: 'source_chat' }, + target, + notifyScope: 'all', + notifyRoomIds: [], targetNeedsReview: false, template } } - // 部分勾选:**不能**无损映射。不取第一个群、不改写成当前群聊、保持关闭、要求重选。 + // 部分勾选:**无损**保留那份子集,不再降级成 needs_review。 return { - outcome: 'needs_review', - enabled: false, - target: defaultTarget, - targetNeedsReview: true, + outcome: 'lossless_selected_groups', + enabled: true, + target, + notifyScope: 'selected', + notifyRoomIds: notifications, + targetNeedsReview: false, template } } diff --git a/src/main/services/leave-notification-target.ts b/src/main/services/leave-notification-target.ts index ba09ad0..81e2ada 100644 --- a/src/main/services/leave-notification-target.ts +++ b/src/main/services/leave-notification-target.ts @@ -4,81 +4,65 @@ import { type LeaveNotificationConfig } from '../../shared/automation' import type { GroupMemberExitedEvent } from '../../shared/group-exit-event' -import { WECHAT_FILE_HELPER_USERNAME, isFileHelperUsername } from '../../shared/wechat-identities' +import { + automationContactDisplayName, + filterSendableFriendContacts as filterSendable, + isSendableFriendContact as isSendable, + resolveAutomationTarget, + type AutomationTargetContact, + type AutomationTargetResolution, + type ResolvedAutomationTarget +} from './automation-wechat-target' /** - * 退群通知的**目标解析**。 + * 退群通知的**目标解析**(薄适配层)。 * - * 纯函数(联系人清单与自身身份由调用方注入),因为这里最容易出错、 - * 也最需要被单测穷举:发错人是不可撤销的。 + * 真正的判定在 `automation-wechat-target.ts` —— 那里才是**唯一实现**, + * 定时日报与退群通知共用它。这里只有两件事: + * 1. 把 `LeaveNotificationConfig` + 退群事件摊平成中性输入; + * 2. 提供退群通知专属文案,保持既有调用方 / 测试不变。 * - * 四条规则: - * 1. `source_chat` 取**事件所在群**(`event.conversationId`), - * 不是"当前正在看的聊天",也不是"最后一个活跃会话"。 - * 2. 目标解析失败 → **报错**,绝不 fallback 到别的目标(尤其不许偷偷发文件助手)。 - * 3. 只允许出现在选项表里的目标类型。 - * 4. 显示名不带 wxid:日志与界面只出现用户能看懂的称呼。 + * ⚠️ 这里**不允许**再出现一份 switch / 判定逻辑 —— 两套并存迟早分叉。 */ /** 解析所需的最小联系人结构(`FormattedContact` 结构上可赋值给它)。 */ -export interface LeaveNotificationCandidateContact { - m_nsUsrName: string - m_nsNickName?: string - type: 'user' | 'group' - isOfficialAccount?: boolean - wechatNickname?: string - remark?: string -} +export type LeaveNotificationCandidateContact = AutomationTargetContact -export interface ResolvedLeaveNotificationTarget { - recipient: { type: 'group' | 'contact'; id: string; name: string } - /** 用户可读的目标名,直接进执行日志的 `detail`。 */ - displayName: string -} +export type ResolvedLeaveNotificationTarget = ResolvedAutomationTarget -export type LeaveNotificationTargetResolution = - | { ok: true; target: ResolvedLeaveNotificationTarget } - | { ok: false; error: string } +export type LeaveNotificationTargetResolution = AutomationTargetResolution + +/** 退群通知的目标文案(唯一一份)。 */ +export const LEAVE_NOTIFICATION_TARGET_MESSAGES = { + options: LEAVE_NOTIFICATION_TARGET_OPTIONS, + invalidTarget: '退群通知的发送目标无效,请重新选择', + sourceMissing: '无法确定发生退群的群聊,本次通知未发送', + sourceRecipientName: '当前群聊', + sourceDisplayName: '发生退群事件的群聊', + selfMissing: '无法确定当前登录的微信账号,本次通知未发送', + contactNotChosen: '还没有选择通知联系人,请重新选择', + contactUnavailable: '通知联系人已不存在或当前无法发送,请重新选择' +} as const /** 联系人显示名:备注 → 微信昵称 → 会话昵称 → 兜底称呼。**永不回落到 wxid**。 */ -export function leaveNotificationContactDisplayName( - contact: LeaveNotificationCandidateContact -): string { - return ( - contact.remark?.trim() || - contact.wechatNickname?.trim() || - contact.m_nsNickName?.trim() || - '指定好友' - ) +export function leaveNotificationContactDisplayName(contact: AutomationTargetContact): string { + return automationContactDisplayName(contact) } -/** - * 这个联系人能不能作为「指定好友」的发送目标。 - * - * 排除:群聊、公众号(`gh_`)、文件传输助手、自己。 - * UI 的联系人选择器与运行时的目标解析**共用这一条判定**, - * 否则会出现"能选但发不出去"。 - */ +/** 这个联系人能不能作为「指定好友」的发送目标(与定时日报共用同一条判定)。 */ export function isSendableFriendContact( - contact: LeaveNotificationCandidateContact, + contact: AutomationTargetContact, selfWxid?: string ): boolean { - if (contact.type !== 'user') return false - if (contact.isOfficialAccount) return false - const username = String(contact.m_nsUsrName || '').trim() - if (!username) return false - if (isFileHelperUsername(username)) return false - const self = String(selfWxid || '').trim() - if (self && username === self) return false - return true + return isSendable(contact, selfWxid) } /** 供 UI 用的「可选好友」过滤(与运行时同一套判定)。 */ -export function filterSendableFriendContacts( +export function filterSendableFriendContacts( contacts: T[], selfWxid?: string ): T[] { - return contacts.filter((contact) => isSendableFriendContact(contact, selfWxid)) + return filterSendable(contacts, selfWxid) } export function resolveLeaveNotificationTarget(input: { @@ -89,84 +73,19 @@ export function resolveLeaveNotificationTarget(input: { selfWxid?: string }): LeaveNotificationTargetResolution { const config = input.config - if (!config) { - return { ok: false, error: '退群通知尚未配置发送目标' } - } - const known = LEAVE_NOTIFICATION_TARGET_OPTIONS.some( - (option) => option.type === config.target?.type + if (!config) return { ok: false, error: '退群通知尚未配置发送目标' } + return resolveAutomationTarget( + { + targetType: config.target?.type as never, + sourceConversationId: String(input.event.conversationId || '').trim(), + sourceDisplayName: String(input.event.groupName || '').trim(), + ...(config.target?.contactId ? { contactId: config.target.contactId } : {}), + contacts: input.contacts, + ...(input.selfWxid ? { selfWxid: input.selfWxid } : {}) + }, + LEAVE_NOTIFICATION_TARGET_MESSAGES ) - if (!known) { - return { ok: false, error: '退群通知的发送目标无效,请重新选择' } - } - - switch (config.target.type) { - case 'source_chat': { - const conversationId = String(input.event.conversationId || '').trim() - if (!conversationId) { - return { ok: false, error: '无法确定发生退群的群聊,本次通知未发送' } - } - const groupName = String(input.event.groupName || '').trim() - return { - ok: true, - target: { - recipient: { - type: 'group', - id: conversationId, - name: groupName || '当前群聊' - }, - displayName: groupName || '发生退群事件的群聊' - } - } - } - - case 'self': { - const selfWxid = String(input.selfWxid || '').trim() - if (!selfWxid) { - // 明确报错,不 fallback。§「不要偷偷 fallback 文件传输助手」。 - return { ok: false, error: '无法确定当前登录的微信账号,本次通知未发送' } - } - return { - ok: true, - target: { - recipient: { type: 'contact', id: selfWxid, name: '我' }, - displayName: '我' - } - } - } - - case 'file_transfer': - return { - ok: true, - target: { - recipient: { - type: 'contact', - id: WECHAT_FILE_HELPER_USERNAME, - name: leaveNotificationTargetLabel('file_transfer') - }, - displayName: leaveNotificationTargetLabel('file_transfer') - } - } - - case 'contact': { - const contactId = String(config.target.contactId || '').trim() - if (!contactId) { - return { ok: false, error: '还没有选择通知联系人,请重新选择' } - } - const contact = input.contacts.find( - (item) => String(item.m_nsUsrName || '').trim() === contactId - ) - if (!contact || !isSendableFriendContact(contact, input.selfWxid)) { - // §「如果之前选择的联系人被删除 / 不可发送 / 找不到,规则不要偷偷发给其它地方」。 - return { ok: false, error: '通知联系人已不存在或当前无法发送,请重新选择' } - } - const displayName = leaveNotificationContactDisplayName(contact) - return { - ok: true, - target: { - recipient: { type: 'contact', id: contactId, name: displayName }, - displayName - } - } - } - } } + +/** 目标类型 → 界面短标签(保持既有导出面)。 */ +export { leaveNotificationTargetLabel } diff --git a/src/main/services/scheduled-report-api-service.ts b/src/main/services/scheduled-report-api-service.ts index d0dddb6..8e891e8 100644 --- a/src/main/services/scheduled-report-api-service.ts +++ b/src/main/services/scheduled-report-api-service.ts @@ -27,8 +27,7 @@ export interface ScheduledReportApiDependencies { | 'deleteTask' | 'setTaskEnabled' | 'runScheduledReportNow' - > & - Partial> + > getCapability: () => Promise listContacts: () => FormattedContact[] isDatabaseReady: () => boolean @@ -250,16 +249,20 @@ export class ScheduledReportApiService { return result.data } - async retrySend(executionId: string): Promise { - const retry = this.deps.service.retryScheduledReportSend - if (!retry) { - throw new ScheduledReportApiError(501, 'not_supported', '当前运行时不支持重新发送日报') - } - const result = await retry.call(this.deps.service, executionId) - if (!result.data) { - throw new ScheduledReportApiError(404, 'not_found', result.error || '未找到定时日报执行记录') - } - return result.data + /** + * 「重新发送」不再支持。 + * + * 日报图片归日报历史所有,发送目标由规则配置决定,「把某次执行记录里那张 PNG + * 就地再发一遍」这个语义已经不存在 —— 与其在此伪造一个只对部分规则有效的重发 + * 语义,不如**明确返回 501**。 + * 用户的替代路径:在日报历史里手动转发,或对该规则点「立即执行」重跑一次。 + */ + async retrySend(_executionId: string): Promise { + throw new ScheduledReportApiError( + 501, + 'not_supported', + '当前运行时不支持重新发送日报;请在 TraceMemo 的自动化页面使用「立即执行」,或从日报历史手动转发。' + ) } async executions(taskId: string): Promise { diff --git a/src/main/services/scheduled-report-migration.ts b/src/main/services/scheduled-report-migration.ts new file mode 100644 index 0000000..3c30b11 --- /dev/null +++ b/src/main/services/scheduled-report-migration.ts @@ -0,0 +1,207 @@ +import { createHash } from 'node:crypto' +import { + normalizeScheduledReportConfig, + type ScheduledReportAutomationConfig +} from '../../shared/automation' +import type { + ScheduledReportMemberNameMode, + ScheduledReportMessageType, + ScheduledReportRange +} from '../../shared/scheduled-report' +import type { SelectableReportTemplateId } from '../../shared/report-templates' + +/** + * 旧「定时日报任务」→ 新「AutomationRule(ruleType='scheduled_report')」的**纯映射器**。 + * + * 单独成文件是为了能直接单测:这是整轮迁移里**唯一会改变用户既有行为**的地方, + * 不允许只靠"跑一遍看看"来验证。 + * + * ## 旧 target 的真实语义(实测) + * + * `ScheduledReportService.resolveTarget()`: + * - `target` 以 `@chatroom` 结尾 ⇒ **直接发到那个群**(可能是另一个群); + * - 否则 ⇒ **静默回落到来源群**。 + * + * ⇒ 历史行为**事实上支持「生成 A 群日报 → 发 B 群」**,也存在一处静默 fallback。 + * 新产品明确不支持"指定另一个群聊",所以: + * **能证明同群 → 无损映射成 `source_chat`;不能证明 → 停用 + 要求用户重选**, + * 绝不自动改写、绝不偷偷丢掉目标。 + */ + +/** 旧任务里迁移真正用得到的字段(其余字段不参与,也不需要读)。 */ +export interface LegacyScheduledReportTask { + id?: unknown + name?: unknown + group?: unknown + scheduleTime?: unknown + reportRange?: unknown + messageTypes?: unknown + templateId?: unknown + memberNameMode?: unknown + timeoutSeconds?: unknown + target?: unknown + enabled?: unknown + createdAt?: unknown + updatedAt?: unknown + lastRunAt?: unknown + lastScheduledSlot?: unknown +} + +export type ScheduledReportMigrationOutcome = + /** 目标与来源群是同一群 ⇒ 等价于 `source_chat`,行为完全一致。 */ + | 'lossless_source_chat' + /** 目标是另一个群 / 无法确认是否同群 ⇒ 单值四选一表达不了,需用户重选。 */ + | 'needs_review' + +export interface ScheduledReportMigrationPlan { + outcome: ScheduledReportMigrationOutcome + /** 迁移后的规则 id(确定性,可重复执行且不产生新 id)。 */ + ruleId: string + name: string + enabled: boolean + config: ScheduledReportAutomationConfig + createdAt: number + updatedAt: number +} + +/** 把任意输入收敛成字符串。 */ +function asText(value: unknown): string { + return typeof value === 'string' ? value.trim() : String(value ?? '').trim() +} + +/** + * 确定性 ruleId。 + * + * 优先**原样复用旧 taskId**(旧 id 形如 `scheduled_report_`,与自动化现有 + * id 空间 —— `randomUUID()` / `builtin-*` —— 不可能冲突)。 + * 旧 id 缺失时(文件被手改坏),退化成一个**由稳定字段派生**的 id, + * 保证"重复迁移不会生成新规则"。 + */ +export function scheduledReportMigrationRuleId(task: LegacyScheduledReportTask): string { + const id = asText(task.id) + if (id) return id + const digest = createHash('sha1') + .update( + [asText(task.name), asText(task.group), asText(task.scheduleTime), asText(task.target)].join( + '\u0001' + ) + ) + .digest('hex') + return `scheduled-report:${digest.slice(0, 32)}` +} + +/** 旧 `createdAt` / `updatedAt` 是 ISO 字符串;非法时回落到迁移时刻。 */ +function parseIso(value: unknown, fallback: number): number { + const text = asText(value) + if (!text) return fallback + const parsed = Date.parse(text) + return Number.isFinite(parsed) ? parsed : fallback +} + +/** + * 单条旧任务 → 迁移计划。 + * + * `resolveConversationId` 由调用方注入(main 侧是 `chat-service.resolveMd5`, + * 单测里是假的联系人表)。**它拿不到就说明主进程还没准备好**, + * 调用方必须**推迟整批迁移**,而不是用"解析不到"去误判成 needs_review。 + */ +export function planScheduledReportMigration( + task: LegacyScheduledReportTask, + resolveConversationId: (raw: string) => string | undefined, + now: number +): ScheduledReportMigrationPlan { + const sourceRaw = asText(task.group) + const targetRaw = asText(task.target) + const sourceResolved = resolveConversationId(sourceRaw) + const targetResolved = resolveConversationId(targetRaw) + + /* + * 三种"能证明是同一个群"的情况: + * 1. target 为空 —— 旧 `normalizeInput` 本就把空 target 填成 group; + * 2. 两者文本完全相等 —— 无论能否解析,用户表达的就是同一个东西; + * 3. 两者都能解析且解析结果相同 —— 旧 UI 的常态(group=会话 md5,target=roomId)。 + */ + const sameGroup = + !targetRaw || targetRaw === sourceRaw || Boolean(sourceResolved && targetResolved && sourceResolved === targetResolved) + + const config = normalizeScheduledReportConfig({ + schedule: { time: asText(task.scheduleTime) }, + report: { + // 解析得到就用稳定会话 id;解析不到**保留原值** —— 运行期会如实失败, + // 绝不在这里"猜一个群"。 + sourceConversationId: sourceResolved ?? sourceRaw, + range: asText(task.reportRange) as ScheduledReportRange, + messageTypes: task.messageTypes as ScheduledReportMessageType[], + templateId: asText(task.templateId) as SelectableReportTemplateId, + memberNameMode: asText(task.memberNameMode) as ScheduledReportMemberNameMode, + timeoutSeconds: Number(task.timeoutSeconds) + }, + target: { type: 'source_chat' }, + ...(sameGroup ? {} : { targetNeedsReview: true }), + // 旧目标原文只作展示;`sameGroup` 时不保留,避免留下看起来像"还生效"的残值。 + ...(sameGroup || !targetRaw ? {} : { legacyTarget: targetRaw }), + // 来源群被改写过(md5/群名 → roomId)时留下原文,便于用户核对。 + ...(sourceRaw && sourceResolved && sourceRaw !== sourceResolved + ? { legacySourceGroup: sourceRaw } + : {}), + ...(asText(task.lastRunAt) ? { lastRunAt: asText(task.lastRunAt) } : {}), + ...(asText(task.lastScheduledSlot) ? { lastScheduledSlot: asText(task.lastScheduledSlot) } : {}) + }) + + const name = asText(task.name) || '定时日报' + return { + outcome: sameGroup ? 'lossless_source_chat' : 'needs_review', + ruleId: scheduledReportMigrationRuleId(task), + name, + // 不自动改写、也不自动启用:无法确认目标的规则一律停用。 + enabled: sameGroup ? task.enabled !== false : false, + config, + createdAt: parseIso(task.createdAt, now), + updatedAt: parseIso(task.updatedAt, now) + } +} + +export interface ScheduledReportMigrationSummary { + total: number + migrated: number + lossless: number + needsReview: number + duplicatesSkipped: number +} + +/** + * 整批任务的迁移结果:**按 id 去重**,保证幂等(同一份旧文件跑两次结果一致)。 + */ +export function planScheduledReportMigrationBatch( + tasks: LegacyScheduledReportTask[], + resolveConversationId: (raw: string) => string | undefined, + now: number +): { plans: ScheduledReportMigrationPlan[]; summary: ScheduledReportMigrationSummary } { + const plans: ScheduledReportMigrationPlan[] = [] + const seen = new Set() + let duplicatesSkipped = 0 + let lossless = 0 + let needsReview = 0 + for (const task of tasks) { + if (!task || typeof task !== 'object') continue + const plan = planScheduledReportMigration(task, resolveConversationId, now) + if (seen.has(plan.ruleId)) { + duplicatesSkipped += 1 + continue + } + seen.add(plan.ruleId) + plans.push(plan) + if (plan.outcome === 'lossless_source_chat') lossless += 1 + else needsReview += 1 + } + return { + plans, + summary: { + total: plans.length + duplicatesSkipped, + migrated: plans.length, + lossless, + needsReview, + duplicatesSkipped + } + } +} diff --git a/src/main/services/scheduled-report-service.ts b/src/main/services/scheduled-report-service.ts index 37342fb..fa3dff6 100644 --- a/src/main/services/scheduled-report-service.ts +++ b/src/main/services/scheduled-report-service.ts @@ -2,44 +2,70 @@ import { app } from 'electron' import { randomUUID } from 'crypto' import { promises as fs } from 'fs' import path from 'path' -import type { - PersonalWechatSendCapability, - PersonalWechatSendRequest, - PersonalWechatSendResult -} from '../../shared/personal-wechat' -import type { WechatActionRequest, WechatActionResult } from '../../shared/wechat-action' import type { AgentHubStatus } from '../../shared/agent-hub' +import { + AUTOMATION_RULE_TYPE_LABELS, + calculateNextRunAt, + isValidScheduleTime, + normalizeScheduledReportConfig, + type AutomationRule, + type ScheduledReportAutomationConfig, + type ScheduledReportTargetType +} from '../../shared/automation' import type { ScheduledReportCreateInput, ScheduledReportExecution, ScheduledReportExecutionStage, - ScheduledReportMessageType, - ScheduledReportMemberNameMode, ScheduledReportNotification, - ScheduledReportNotificationCapabilityReason, ScheduledReportNotificationCapability, + ScheduledReportNotificationCapabilityReason, ScheduledReportNotificationSettings, ScheduledReportNotificationSettingsResult, ScheduledReportNotificationSeverity, ScheduledReportNotificationType, - ScheduledReportRange, ScheduledReportResult, ScheduledReportSendStatus, ScheduledReportTask, ScheduledReportUpdateInput } from '../../shared/scheduled-report' import { - legacyScheduledReportError, - normalizeScheduledReportError -} from '../../shared/scheduled-report-error' -import type { SaveGeneratedReportRequest } from '../../shared/report-history' -import { saveGeneratedReport } from '../report-history-service' -import { generateAgentGroupReport } from './agent-group-report-service' -import { personalWechatCapabilityService } from './personal-wechat-capability-service' -import { WechatActionGateway, wechatActionGateway } from './wechat-action-gateway' -import { getContactAvatars, isReady as isChatReady, resolveMd5 } from './chat-service' + automationRuleStore +} from './automation-rule-store' +import { + automationExecutionLogService, + type AutomationExecutionLogService +} from './automation-execution-log-service' +import { + getAutomationService, + type ScheduledRuleRunOutcome +} from './automation-service' import { agentHubService, type AgentHubNotificationResult } from './agent-hub-service' +/** + * ScheduledReportService —— **退役后的定时日报服务**。 + * + * 职责被压到三件事,且**只有这三件**: + * + * 1. **调度器(scheduler)**:只改任务来源,不重写计时器。 + * ```text + * Before: ScheduledReportStore(tasks.json) → Scheduler + * After: AutomationRuleStore ─ ruleType='scheduled_report' ↓ Scheduler + * ``` + * 真正干活的一律交给 `AutomationService.executeScheduledRule()` —— + * 这个类**不认识微信、不认识 OCR、不认识日报生成**。 + * 2. **微信异常通知**:定时日报生成 / 发送异常时,经 Agent Hub 推一条消息。 + * 这是定时日报功能的**全局能力**(与具体任务无关),因此随功能一起保留。 + * 3. **旧数据只读存档**:旧 `tasks.json` / `executions.json` 不再参与运行期调度, + * 但历史执行记录**不能丢**,所以保留只读投影给 UI 展示。 + * + * 已移除的能力: + * - 任务 CRUD 的**存储**(改由 `AutomationRuleStore` 承担;这里只保留一层 + * 面向 HTTP API 的**只读兼容投影**,不落第二份数据); + * - 日报生成 / 发送(`generateAgentGroupReport` / `saveGeneratedReport` / + * `WechatActionGateway`)—— 全部搬进 `AutomationActionRunner.runScheduledReport()`; + * - `executions.json` 的写入(新执行记录进 Automation Execution Log)。 + */ + const STORAGE_DIR = 'scheduled-reports' const TASKS_FILE = 'tasks.json' const EXECUTIONS_FILE = 'executions.json' @@ -51,108 +77,44 @@ const NOTIFICATION_TEST_MESSAGE = `✅ TraceMemo 定时日报通知已开启 以后定时日报生成或发送出现异常时, 我会通过这里通知你。` +/** + * 生成失败但**不该打扰用户**的错误码。 + * + * 这类错误照常记为 `failed`(如实),只是不该打扰用户 —— + * 把"不通知"这个判断放在**通知桥**这一层。 + */ +const NON_NOTIFYING_ERROR_CODES = new Set(['NO_MESSAGES']) + export interface ScheduledReportDependencies { - getCapability: () => Promise - generateReport: typeof generateAgentGroupReport - saveGeneratedReport: ( - request: SaveGeneratedReportRequest - ) => ReturnType - send: (request: PersonalWechatSendRequest) => Promise - sendAction?: (input: ScheduledReportSendActionInput) => Promise + /** 执行一条定时日报规则。默认走 `AutomationService`。 */ + executeRule: ( + ruleId: string, + options: { trigger: 'schedule' | 'manual'; scheduledSlot?: string } + ) => Promise + /** 调度器的**唯一任务来源**。 */ + listRules: () => AutomationRule[] + /** Automation 执行日志(用于把新执行记录投影给 HTTP API)。 */ + listExecutions: (query?: { limit?: number }) => ReturnType sendNotification: (input: { to?: string; text: string }) => Promise getNotificationRecipient: () => string | undefined getAgentHubStatus: () => AgentHubStatus - getContactAvatars?: (usernames: string[]) => Promise> storageDir: string + /** 数据库未就绪时不调度:目标解析必然失败,跑了只会留下误导性的失败记录。 */ isDatabaseReady: () => boolean now?: () => Date } -export interface ScheduledReportSendActionInput { - target: string - filePath: string - executionId: string - triggerType: 'scheduled' | 'manual' - retryCount?: number - taskId?: string -} - -function buildScheduledReportActionRequest( - input: ScheduledReportSendActionInput -): WechatActionRequest { - return { - idempotencyKey: - input.retryCount && input.retryCount > 0 - ? `scheduled_report:${input.executionId}:retry:${input.retryCount}` - : `scheduled_report:${input.executionId}`, - origin: 'scheduled_report', - purpose: 'scheduled_report', - triggerType: input.triggerType === 'scheduled' ? 'automation' : 'user', - sourceId: input.executionId, - executionId: input.executionId, - recipient: { type: 'group', id: input.target }, - content: { type: 'image', path: input.filePath }, - metadata: { - taskId: input.taskId, - retryCount: input.retryCount || 0 - } - } -} - const defaultDependencies = (): ScheduledReportDependencies => ({ - getCapability: () => personalWechatCapabilityService.getPersonalWechatSendCapability(), - generateReport: generateAgentGroupReport, - saveGeneratedReport, - send: (request) => sendRequestThroughGateway(request), - sendAction: (input) => wechatActionGateway.execute(buildScheduledReportActionRequest(input)), + executeRule: (ruleId, options) => getAutomationService().executeScheduledRule(ruleId, options), + listRules: () => automationRuleStore.listRules(), + listExecutions: (query) => automationExecutionLogService.list(query), sendNotification: (input) => agentHubService.sendNotification(input), getNotificationRecipient: () => agentHubService.getNotificationRecipient(), getAgentHubStatus: () => agentHubService.getStatus(), - getContactAvatars: (usernames) => getContactAvatars(usernames), storageDir: path.join(app.getPath('userData'), STORAGE_DIR), - isDatabaseReady: () => isChatReady() + isDatabaseReady: () => true }) -async function sendRequestThroughGateway( - request: PersonalWechatSendRequest -): Promise { - const content = - request.type === 'text' - ? { type: 'text' as const, text: request.text } - : { type: request.type, path: request.filePath } - const action = await wechatActionGateway.execute({ - origin: 'scheduled_report', - purpose: 'scheduled_report', - triggerType: 'automation', - recipient: { - type: request.isGroup ? 'group' : 'contact', - id: request.to - }, - content - }) - const sendResult = action.sendResult - const status = - sendResult && typeof sendResult === 'object' && 'status' in sendResult - ? (sendResult as { status: PersonalWechatSendResult['status'] }).status - : ({} as PersonalWechatSendResult['status']) - return { - success: action.status === 'sent', - status, - ...(action.reason || action.errorCode ? { error: action.reason || action.errorCode } : {}) - } -} - -const rangeValues = new Set(['today', 'yesterday', '7days', 'recent24h']) - -const reportRangeLabel = (range: ScheduledReportRange): string => - range === 'recent24h' - ? '最近 24 小时' - : range === '7days' - ? '近 7 天' - : range === 'today' - ? '今日' - : '昨日' - interface ScheduledReportNotificationPayload { type: ScheduledReportNotificationType severity: ScheduledReportNotificationSeverity @@ -161,74 +123,45 @@ interface ScheduledReportNotificationPayload { suggestedAction?: string } -export function validateScheduleTime(value: string): boolean { - return /^(?:[01]\d|2[0-3]):[0-5]\d$/.test(String(value || '').trim()) -} +/** `HH:mm` 校验(保留既有导出名,与共享层同一实现)。 */ +export const validateScheduleTime = isValidScheduleTime +export { calculateNextRunAt, isValidScheduleTime } -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[]) : []) - -const executionStatuses = new Set([ - 'running', - 'success', - 'waiting_to_send', - 'partial_success', - 'failed', - 'waiting_for_recovery', - 'skipped' -]) - -const normalizeExecution = (value: ScheduledReportExecution): ScheduledReportExecution => { - const status = executionStatuses.has(value.status) - ? value.status - : value.error - ? 'failed' - : 'success' - const retryCount = Number(value.retryCount) - return { - ...value, - status, - triggerType: value.triggerType || 'scheduled', - retryCount: Number.isFinite(retryCount) && retryCount >= 0 ? retryCount : 0 - } +/** + * **已经到点、且最近的那个**每日槽位(ISO)。 + * + * 与 `calculateNextRunAt`(返回"下一个")配对使用: + * - 编辑页展示「下次执行」→ `calculateNextRunAt`; + * - 调度器判断「今天这一枪该不该开」→ 本函数。 + */ +export function resolveDueScheduledSlot(scheduleTime: string, at: Date = new Date()): string { + if (!isValidScheduleTime(scheduleTime)) throw new Error('执行时间必须是 HH:mm') + const [hour, minute] = String(scheduleTime).trim().split(':').map(Number) + const slot = new Date(at) + slot.setHours(hour, minute, 0, 0) + if (slot.getTime() > at.getTime()) slot.setDate(slot.getDate() - 1) + return slot.toISOString() } export class ScheduledReportService { private readonly deps: ScheduledReportDependencies - private tasks: ScheduledReportTask[] | null = null - private executions: ScheduledReportExecution[] | null = null + /** 旧存档:只在 `load()` 读一次,**永不被运行期调度使用**。 */ + private legacyTasks: ScheduledReportTask[] | null = null + private legacyExecutions: ScheduledReportExecution[] | null = null private notifications: ScheduledReportNotification[] | null = null private notificationSettings: ScheduledReportNotificationSettings | null = null private timer: NodeJS.Timeout | null = null - private readonly running = new Map>() - private readonly retrying = new Map>() + /** 已 fire-and-forget 起飞的执行(仅用于测试等待;不影响业务语义)。 */ + private readonly pending = new Set>() constructor(deps?: Partial) { - const defaults = defaultDependencies() - const merged = { ...defaults, ...deps } - if (deps?.send && !deps.sendAction) { - // 保留注入的传输实现,方便测试和内嵌场景,同时让日报发送继续使用统一的发送入口。 - const legacyGateway = new WechatActionGateway({ - getCapability: merged.getCapability, - send: merged.send, - getUserDataPath: () => merged.storageDir, - ...(merged.now ? { now: merged.now } : {}) - }) - merged.sendAction = (input) => legacyGateway.execute(buildScheduledReportActionRequest(input)) - } else if (!merged.sendAction) { - merged.sendAction = defaults.sendAction - } - this.deps = merged + this.deps = { ...defaultDependencies(), ...deps } } + // --------------------------------------------------------------------------- + // 生命周期 + // --------------------------------------------------------------------------- + async start(): Promise { await this.load() if (this.timer) return @@ -236,7 +169,11 @@ export class ScheduledReportService { 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)) + // 首轮扫描也登记进 `pending`:否则 `settle()` 会在它真正起飞**之前**就返回, + // 调用方(主要是测试)拿不到确定性。 + this.track( + this.tick().catch((error) => console.warn('[ScheduledReport] initial tick failed:', error)) + ) } stop(): void { @@ -244,29 +181,176 @@ export class ScheduledReportService { this.timer = null } - async listTasks(): Promise { - await this.load() - return this.tasks!.map((task) => ({ ...task })) + /** 等待所有 fire-and-forget 的执行结束(测试用;运行期不调用)。 */ + async settle(): Promise { + while (this.pending.size) { + await Promise.all([...this.pending]) + } } - async listExecutions(taskId?: string): Promise { + // --------------------------------------------------------------------------- + // 调度器 + // --------------------------------------------------------------------------- + + /** + * 一次调度扫描。**不重写计时器**:仍然是 15 秒一次的 `setInterval`。 + * + * 每个到点且未消费的槽位调用一次 `AutomationService.executeScheduledRule(ruleId, …)`: + * - 并发保护(同规则同时只跑一个)在 `AutomationService` 里; + * - 槽位游标(`lastScheduledSlot`)由 `AutomationService` 写; + * - 这里**只负责**"读规则 → 判断到点 → 触发 → 失败时推异常通知"。 + */ + async tick(at = this.deps.now?.() || new Date()): Promise { await this.load() - const items = taskId - ? this.executions!.filter((item) => item.taskId === taskId) - : this.executions! - return items.map((item) => ({ ...item })) + await this.flushNotifications() + if (!this.deps.isDatabaseReady()) return + + let rules: AutomationRule[] + try { + rules = this.deps.listRules() + } catch (error) { + console.warn('[ScheduledReport] 读取自动化规则失败:', error) + return + } + + for (const rule of rules) { + const config = rule.scheduledReport + if (rule.ruleType !== 'scheduled_report' || !config) continue + if (!rule.enabled) continue + // 迁移遗留的"目标待重选":不调度、不创建记录,由 UI 提示用户处理。 + if (config.targetNeedsReview) continue + if (!isValidScheduleTime(config.schedule.time)) continue + + // `resolveDueScheduledSlot` 承诺返回「已经到点、最近的那个」槽位 + // (一定是 `<= at`),所以不需要再自己判一次到点。 + let slot: string + try { + slot = resolveDueScheduledSlot(config.schedule.time, at) + } catch { + continue + } + const slotMs = Date.parse(slot) + if (!Number.isFinite(slotMs)) continue + + // 闸门 1:槽位必须晚于规则创建时间。 + // + // 新建规则时(例:今天 10:00 建、执行时间 18:30),"最近的槽位"是**昨天 18:30**, + // 那不属于这条规则 —— 少了这道闸,新建规则会在下一次 tick(15 秒内)就补发一份 + // 昨天的报告。这是绝不能出现的副作用。 + if (slotMs < rule.createdAt) continue + + // 闸门 2:槽位必须比**已消费的游标**更新。 + // + // 槽位时间单调递增,所以用数值比较而不是字符串相等: + // 游标比槽位新(用户改过执行时间等)同样说明这个槽位已经过期,不该再补。 + // 用 `===` 会漏掉"游标更新"这一类,用 `<=` 一并覆盖。 + const consumed = config.lastScheduledSlot ? Date.parse(config.lastScheduledSlot) : NaN + if (Number.isFinite(consumed) && slotMs <= consumed) continue + + const promise = this.runRule(rule, { + trigger: 'schedule', + scheduledSlot: slot + }).catch((error) => console.warn('[ScheduledReport] execution failed:', error)) + this.track(promise) + } } - async listNotifications(): Promise { + /** + * 立即执行(用户点「立即执行」)。 + * + * **不消耗槽位**:手动执行不该导致今天不再定时跑。 + */ + async runScheduledReportNow(ruleId: string): Promise> { await this.load() - return this.notifications!.map((notification) => ({ ...notification })) + const rule = this.findRule(ruleId) + if (!rule) return { success: false, error: '未找到定时日报规则' } + const outcome = await this.runRule(rule, { trigger: 'manual' }) + const execution = this.projectOutcome(outcome, rule, 'manual') + return { + success: outcome.status !== 'failed', + data: execution, + ...(outcome.status === 'failed' && outcome.errorSummary + ? { error: outcome.errorSummary } + : {}) + } } + /** + * 一次执行结果 → 旧 `ScheduledReportExecution` 形状(HTTP API 契约用)。 + * + * 新状态模型只有 3 态,这里做**有损但方向明确**的投影: + * 未执行(停用 / 目标待重选 / 并发占用)→ `skipped`; + * 执行了但失败 → `failed`;成功 → `success`。 + */ + private projectOutcome( + outcome: ScheduledRuleRunOutcome, + rule: AutomationRule, + trigger: 'schedule' | 'manual' + ): ScheduledReportExecution { + const nowMs = (this.deps.now?.() || new Date()).getTime() + const startedAt = new Date(nowMs).toISOString() + const status = !outcome.executed + ? ('skipped' as const) + : outcome.status === 'success' + ? ('success' as const) + : ('failed' as const) + return { + id: outcome.executionId || `${rule.id}:${nowMs}`, + taskId: rule.id, + // 旧存档的字段名是 `scheduled`(不是新的 `schedule`)—— 投影时如实转换。 + triggerType: trigger === 'manual' ? 'manual' : 'scheduled', + startedAt, + finishedAt: startedAt, + status, + currentStage: 'send', + message: outcome.errorSummary || '日报执行完成', + ...(outcome.errorSummary ? { error: outcome.errorSummary } : {}), + retryCount: 0, + sendStatus: status === 'success' ? 'success' : 'failed', + notificationStatus: 'not_needed' + } + } + + private track(promise: Promise): void { + this.pending.add(promise) + void promise.finally(() => this.pending.delete(promise)) + } + + /** + * 触发一次执行 + 失败时推异常通知。 + * + * **手动执行不推通知**:只有定时任务失败才打扰用户。 + */ + private async runRule( + rule: AutomationRule, + options: { trigger: 'schedule' | 'manual'; scheduledSlot?: string } + ): Promise { + const outcome = await this.deps.executeRule(rule.id, options) + if ( + options.trigger === 'schedule' && + outcome.executed && + outcome.status === 'failed' && + !NON_NOTIFYING_ERROR_CODES.has(outcome.errorCode || '') + ) { + await this.notifyRuleFailure(rule, outcome) + } + return outcome + } + + // --------------------------------------------------------------------------- + // 微信异常通知(独立能力,随定时日报功能保留) + // --------------------------------------------------------------------------- + async getNotificationSettings(): Promise { await this.load() return { ...this.notificationSettings! } } + async listNotifications(): Promise { + await this.load() + return this.notifications!.map((item) => ({ ...item })) + } + async checkNotificationCapability(): Promise { let status: AgentHubStatus try { @@ -396,676 +480,76 @@ export class ScheduledReportService { return { success: true, data: { enabled: true } } } - async createTask( - input: ScheduledReportCreateInput - ): Promise> { - 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, - messageTypes: values.messageTypes, - templateId: values.templateId, - memberNameMode: values.memberNameMode, - timeoutSeconds: values.timeoutSeconds, - 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 !== 'failed', - data: execution, - ...(execution.error ? { error: execution.error } : {}) - } - } - - async retryScheduledReportSend( - executionId: string - ): Promise> { - await this.load() - const execution = this.executions!.find((item) => item.id === executionId) - if (!execution) return { success: false, error: '未找到定时日报执行记录' } - const existing = this.retrying.get(executionId) - if (existing) return { success: true, data: await existing } - const promise = this.retrySend(execution).finally(() => this.retrying.delete(executionId)) - this.retrying.set(executionId, promise) - const result = await promise - return { - success: result.status !== 'failed', - data: result, - ...(result.error ? { error: result.error } : {}) - } - } - + /** + * 发一条**模拟**异常通知,验证 Agent Hub 推送链路。 + * + * 迁移前这是旧「定时日报」页面上的调试入口。页面退役后入口挪到 + * Automation 的定时日报区域,能力本身**一字不改**(否则等于把功能删了)。 + */ async testScheduledReportErrorNotification( - taskId: string - ): Promise> { + ruleId: string + ): Promise> { await this.load() - const task = this.tasks!.find((item) => item.id === taskId) - if (!task) return { success: false, error: '未找到定时日报任务' } if (!this.notificationSettings!.enabled) { - return { - success: false, - error: '请先开启微信异常通知,再发送测试错误信息。' - } + return { success: false, error: '请先开启微信异常通知,再发送测试错误信息。' } } - + const rule = this.findRule(ruleId) + const ruleName = rule?.name || AUTOMATION_RULE_TYPE_LABELS.scheduled_report const now = (this.deps.now?.() || new Date()).toISOString() - const execution: ScheduledReportExecution = { - id: `scheduled_report_execution_${randomUUID()}`, - taskId: task.id, - triggerType: 'scheduled', - startedAt: now, - finishedAt: now, - status: 'failed', - currentStage: 'report', - failedStage: 'report', - error: 'debug_test_notification:模拟定时日报生成错误', - errorCode: 'DEBUG_TEST_NOTIFICATION', - technicalMessage: '这是调试用的模拟错误信息;本次没有调用 AI,也没有生成或发送日报图片。', - userTitle: '定时日报错误通知测试', - userMessage: '这是一条调试用的模拟错误通知,用于验证 Agent Hub 推送链路。', - suggestedAction: '确认微信中是否收到这条测试通知。', - retryable: false, - retryCount: 0, - sendStatus: 'unavailable', - notificationStatus: 'pending' - } - this.executions!.unshift(execution) - this.executions = this.executions!.slice(0, 500) - await this.saveExecutions() - - const notified = await this.notifyExecution(task, execution, { - type: 'failure', - severity: 'error', - title: execution.userTitle || '定时日报错误通知测试', - message: - execution.userMessage || '这是一条调试用的模拟错误通知,用于验证 Agent Hub 推送链路。', - suggestedAction: execution.suggestedAction || '确认微信中是否收到这条测试通知。' + const notification = await this.enqueueNotification({ + ruleId: rule?.id || String(ruleId || '').trim(), + ruleName, + dedupeKey: `debug:${randomUUID()}`, + payload: { + type: 'failure', + severity: 'error', + title: '定时日报错误通知测试', + message: '这是一条调试用的模拟错误通知,用于验证 Agent Hub 推送链路。', + suggestedAction: '确认微信中是否收到这条测试通知。' + }, + createdAt: now }) return { - success: notified.notificationStatus === 'sent', - data: notified, - ...(notified.notificationStatus === 'sent' - ? {} - : { error: '测试错误已创建,但通知尚未送达。' }) + success: notification.status === 'sent', + data: { ...notification }, + ...(notification.status === 'sent' ? {} : { error: '测试错误已创建,但通知尚未送达。' }) } } - async tick(at = this.deps.now?.() || new Date()): Promise { + /** 定时日报失败 → 入队 + 立刻尝试送达。 */ + private async notifyRuleFailure( + rule: AutomationRule, + outcome: ScheduledRuleRunOutcome + ): Promise { await this.load() - await this.flushNotifications() - 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, 'scheduled').catch((error) => - console.warn('[ScheduledReport] execution failed:', error) - ) - } - } - - private async runTask( - task: ScheduledReportTask, - scheduledSlot?: string, - triggerType: 'scheduled' | 'manual' = 'manual' - ): Promise { - const existing = this.running.get(task.id) - if (existing) return existing - const promise = this.executeTask(task, scheduledSlot, triggerType).finally(() => - this.running.delete(task.id) - ) - this.running.set(task.id, promise) - return promise - } - - private async executeTask( - task: ScheduledReportTask, - scheduledSlot?: string, - triggerType: 'scheduled' | 'manual' = 'manual' - ): Promise { - await this.load() - const startedAt = (this.deps.now?.() || new Date()).toISOString() - const execution: ScheduledReportExecution = { - id: `scheduled_report_execution_${randomUUID()}`, - taskId: task.id, - triggerType, - startedAt, - status: 'running', - currentStage: 'precheck', - retryCount: 0, - sendStatus: 'pending', - notificationStatus: 'not_needed', - ...(scheduledSlot ? { scheduledSlot } : {}) - } - this.executions!.push(execution) - await this.saveExecutions() - const update = async (patch: Partial): Promise => { - Object.assign(execution, patch) - await this.persistExecution(execution) - } - const finishError = async ( - rawError: unknown, - fallbackStage: ScheduledReportExecutionStage, - status: 'failed' | 'waiting_to_send' | 'partial_success' = 'failed', - patch: Partial = {}, - notificationType: ScheduledReportNotificationType | null = 'failure', - code?: string, - errorStatus?: number, - errorType?: string - ): Promise => { - const normalized = normalizeScheduledReportError( - { - error: rawError, - code, - status: errorStatus, - type: errorType, - stage: fallbackStage - }, - fallbackStage - ) - if (normalized.code === 'NO_MESSAGES') { - return this.finalizeExecution(task, execution, { - ...patch, - status: 'skipped', - currentStage: 'data', - errorCode: 'NO_MESSAGES', - userTitle: '暂无可生成的日报', - userMessage: normalized.userMessage, - message: normalized.userMessage, - suggestedAction: normalized.suggestedAction, - retryable: false, - sendStatus: 'unavailable', - notificationStatus: 'not_needed' - }) - } - const completed = await this.finalizeExecution( - task, - execution, - { - ...patch, - status, - currentStage: normalized.stage, - failedStage: normalized.stage, - error: legacyScheduledReportError(normalized.code, normalized.technicalMessage), - message: normalized.userMessage, - errorCode: normalized.code, - technicalMessage: normalized.technicalMessage, - userTitle: normalized.userTitle, - userMessage: normalized.userMessage, - suggestedAction: normalized.suggestedAction, - retryable: normalized.retryable - }, - notificationType - ? { - type: notificationType, - severity: normalized.severity, - title: normalized.userTitle, - message: normalized.userMessage, - suggestedAction: normalized.suggestedAction - } - : undefined - ) - return completed - } - - try { - await update({ currentStage: 'precheck' }) - - await update({ currentStage: 'data' }) - await update({ currentStage: 'ai' }) - let generated: Awaited> - try { - generated = await this.deps.generateReport({ - group: task.group, - range: task.reportRange, - messageTypes: task.messageTypes, - templateId: task.templateId, - memberNameMode: task.memberNameMode, - timeoutSeconds: task.timeoutSeconds - }) - } catch (error) { - return finishError(error, 'report') - } - if (!generated.success || !generated.pngPath) { - return finishError( - generated.error || '日报生成失败', - generated.errorStage || 'report', - 'failed', - {}, - 'failure', - generated.errorCode, - generated.errorStatus, - generated.errorType - ) - } - - await update({ currentStage: 'persist' }) - const reportContact = resolveMd5(task.group) - let contactAvatar = reportContact?.avatar - if (!contactAvatar && reportContact?.m_nsUsrName && this.deps.getContactAvatars) { - try { - const avatars = await this.deps.getContactAvatars([reportContact.m_nsUsrName]) - contactAvatar = avatars[reportContact.m_nsUsrName] - } catch (error) { - console.warn('[ScheduledReport] failed to hydrate group avatar:', error) - } - } - let savedHistory: Awaited> - try { - savedHistory = await this.deps.saveGeneratedReport({ - contactId: reportContact?.md5 || task.group, - contactName: generated.groupName || reportContact?.m_nsNickName || task.group, - contactAvatar, - source: 'scheduled', - dateRange: generated.reportMetadata?.dateRange || reportRangeLabel(task.reportRange), - reportDate: generated.reportMetadata?.reportDate, - messageCount: generated.messageCount ?? generated.reportMetadata?.messageCount ?? 0, - generatedAt: (this.deps.now?.() || new Date()).toISOString(), - htmlPath: generated.htmlPath, - pngPath: generated.pngPath, - duration: generated.duration, - modelName: generated.modelName, - tokenUsage: generated.tokenUsage, - reportSnapshot: generated.reportSnapshot, - reportMetadata: generated.reportMetadata, - templateId: task.templateId - }) - } catch (error) { - return finishError(error, 'persist') - } - if (!savedHistory.success) { - return finishError( - savedHistory.error || '日报历史保存失败', - 'persist', - 'failed', - {}, - 'failure', - 'REPORT_HISTORY_SAVE_FAILED' - ) - } - - const reportId = savedHistory.record?.id - const pngPath = savedHistory.record?.pngPath || generated.pngPath - const htmlPath = savedHistory.record?.htmlPath || generated.htmlPath - if (!pngPath) { - return finishError( - '日报历史未返回可发送的 PNG 文件', - 'persist', - 'failed', - {}, - 'failure', - 'REPORT_HISTORY_SAVE_FAILED' - ) - } - await update({ reportId, htmlPath, pngPath, currentStage: 'send' }) - - const target = this.resolveTarget(task) - if (!target) { - return finishError( - '未找到指定微信群', - 'send', - 'partial_success', - { sendStatus: 'failed', sendError: '未找到指定微信群' }, - 'partial_success', - 'WECHAT_SEND_FAILED' - ) - } - await update({ sendTarget: target, currentStage: 'send' }) - let action: WechatActionResult - try { - action = await this.deps.sendAction!({ - target, - filePath: pngPath, - executionId: execution.id, - triggerType, - taskId: task.id - }) - } catch (error) { - return finishError( - error, - 'send', - 'partial_success', - { - sendStatus: 'failed', - sendError: error instanceof Error ? error.message : String(error) - }, - 'partial_success', - 'WECHAT_SEND_FAILED' - ) - } - if (action.status !== 'sent') { - const unavailable = - action.errorCode === 'SEND_CAPABILITY_UNAVAILABLE' || - action.errorCode === 'SEND_NOT_READY' - const actionError = action.reason || action.errorCode || '微信发送失败' - return finishError( - actionError, - 'send', - unavailable ? 'waiting_to_send' : 'partial_success', - { - sendStatus: unavailable ? 'unavailable' : 'failed', - sendError: actionError - }, - unavailable ? null : 'partial_success', - unavailable ? 'WECHAT_SEND_UNAVAILABLE' : 'WECHAT_SEND_FAILED' - ) - } - return this.finalizeExecution(task, execution, { - status: 'success', - currentStage: 'send', - sendTarget: target, - sendStatus: 'success', - notificationStatus: 'not_needed', - message: '日报生成成功,微信发送成功' - }) - } catch (error) { - return finishError(error, execution.currentStage || 'report') - } - } - - private async retrySend(original: ScheduledReportExecution): Promise { - const task = this.tasks!.find((item) => item.id === original.taskId) - if (!task) return original - const retryCount = (original.retryCount || 0) + 1 - const working: ScheduledReportExecution = { - ...original, - status: 'running', - currentStage: 'send', - retryCount, - sendStatus: 'pending' - } - await this.persistExecution(working) - - const finishRetryError = async ( - rawError: unknown, - code: string, - sendStatus: ScheduledReportSendStatus, - preferredStatus: 'waiting_to_send' | 'partial_success' - ): Promise => { - const normalized = normalizeScheduledReportError( - { error: rawError, code, stage: 'send' }, - 'send' - ) - return this.finalizeExecution(task, working, { - status: preferredStatus, - currentStage: normalized.stage, - failedStage: normalized.stage, - error: legacyScheduledReportError(normalized.code, normalized.technicalMessage), - message: normalized.userMessage, - errorCode: normalized.code, - technicalMessage: normalized.technicalMessage, - userTitle: normalized.userTitle, - userMessage: normalized.userMessage, - suggestedAction: normalized.suggestedAction, - retryable: normalized.retryable, - retryCount, - sendStatus, - sendError: normalized.technicalMessage - }) - } - - if (!working.pngPath) { - return finishRetryError( - '执行记录缺少已保存的 PNG 文件', - 'REPORT_HISTORY_SAVE_FAILED', - 'failed', - 'partial_success' - ) - } - - const target = working.sendTarget || this.resolveTarget(task) - if (!target) { - return finishRetryError('未找到指定微信群', 'WECHAT_SEND_FAILED', 'failed', 'partial_success') - } - try { - const action = await this.deps.sendAction!({ - target, - filePath: working.pngPath, - executionId: working.id, - triggerType: working.triggerType || 'scheduled', - retryCount, - taskId: task.id - }) - if (action.status !== 'sent') { - const unavailable = - action.errorCode === 'SEND_CAPABILITY_UNAVAILABLE' || - action.errorCode === 'SEND_NOT_READY' - return finishRetryError( - action.reason || action.errorCode || '微信发送失败', - unavailable ? 'WECHAT_SEND_UNAVAILABLE' : 'WECHAT_SEND_FAILED', - unavailable ? 'unavailable' : 'failed', - unavailable ? 'waiting_to_send' : 'partial_success' - ) - } - } catch (error) { - return finishRetryError(error, 'WECHAT_SEND_FAILED', 'failed', 'partial_success') - } - - const previousStatus = original.status - const cleared = this.clearFailureFields(working, { - status: 'success', - currentStage: 'send', - sendTarget: target, - sendStatus: 'success', - retryCount, - message: '日报生成成功,微信发送成功', - notificationStatus: original.notificationStatus || 'not_needed' + if (!this.notificationSettings!.enabled) return + const executionId = outcome.executionId || `${rule.id}:${this.deps.now?.()?.getTime() ?? 0}` + const message = outcome.errorSummary || '定时日报执行失败。' + await this.enqueueNotification({ + ruleId: rule.id, + ruleName: rule.name, + executionId, + dedupeKey: `${executionId}:failure`, + payload: { + type: 'failure', + severity: 'error', + title: '定时日报执行失败', + message + }, + createdAt: (this.deps.now?.() || new Date()).toISOString() }) - const completed = await this.finalizeExecution(task, cleared, {}) - if (previousStatus === 'waiting_to_send' || previousStatus === 'partial_success') { - return this.notifyExecution(task, completed, { - type: 'recovery', - severity: 'info', - title: `${task.name} 已恢复`, - message: '刚才未发送的日报已经成功发送。' - }) - } - return completed } - private clearFailureFields( - execution: ScheduledReportExecution, - patch: Partial - ): ScheduledReportExecution { - const next = { ...execution, ...patch } - delete next.error - delete next.failedStage - delete next.errorCode - delete next.technicalMessage - delete next.userTitle - delete next.userMessage - delete next.suggestedAction - delete next.retryable - delete next.sendError - return next - } - - private async finalizeExecution( - task: ScheduledReportTask, - execution: ScheduledReportExecution, - patch: Partial, - notification?: ScheduledReportNotificationPayload - ): Promise { - const completed: ScheduledReportExecution = { - ...execution, - ...patch, - finishedAt: (this.deps.now?.() || new Date()).toISOString() - } - // 让执行记录在通知完成前保持进行中,避免日报已结束但通知状态尚未更新。 - const notified = notification - ? await this.notifyExecution(task, completed, notification) - : completed - await this.persistExecution(notified) - const taskIndex = this.tasks!.findIndex((item) => item.id === task.id) - if (taskIndex >= 0) { - this.tasks![taskIndex] = { - ...this.tasks![taskIndex], - lastRunAt: notified.finishedAt, - updatedAt: notified.finishedAt! - } - await this.saveTasks() - } - return { ...notified } - } - - private async persistExecution(execution: ScheduledReportExecution): Promise { - const index = this.executions!.findIndex((item) => item.id === execution.id) - if (index >= 0) this.executions![index] = { ...execution } - else this.executions!.push({ ...execution }) - await this.saveExecutions() - } - - private async notifyExecution( - task: ScheduledReportTask, - execution: ScheduledReportExecution, + private async enqueueNotification(input: { + ruleId: string + ruleName: string + executionId?: string + dedupeKey: string payload: ScheduledReportNotificationPayload - ): Promise { + createdAt: string + }): Promise { await this.load() - if ( - execution.triggerType !== 'scheduled' || - execution.status === 'skipped' || - !this.notificationSettings!.enabled - ) { - if (execution.notificationStatus !== 'not_needed') { - const updated = { ...execution, notificationStatus: 'not_needed' as const } - await this.persistExecution(updated) - return updated - } - return { ...execution } - } - let notification: ScheduledReportNotification - try { - notification = await this.enqueueNotification(task, execution, payload) - } catch (error) { - notification = { - id: `scheduled_report_notification_${randomUUID()}`, - executionId: execution.id, - taskId: task.id, - type: payload.type, - severity: payload.severity, - title: payload.title, - message: payload.message, - dedupeKey: `${execution.id}:${payload.type}`, - channel: 'agent_hub', - status: 'failed', - createdAt: (this.deps.now?.() || new Date()).toISOString(), - attempts: 0, - lastError: error instanceof Error ? error.message : String(error) - } - } - const updated: ScheduledReportExecution = { - ...execution, - currentStage: 'notify', - notificationStatus: notification.status - } - await this.persistExecution(updated) - return { ...updated } - } - - private async enqueueNotification( - task: ScheduledReportTask, - execution: ScheduledReportExecution, - payload: ScheduledReportNotificationPayload - ): Promise { - await this.load() - const dedupeKey = `${execution.id}:${payload.type}` - const existing = this.notifications!.find((item) => item.dedupeKey === dedupeKey) + const existing = this.notifications!.find((item) => item.dedupeKey === input.dedupeKey) if (existing) return { ...existing } let recipient: string | undefined try { @@ -1075,42 +559,38 @@ export class ScheduledReportService { } const notification: ScheduledReportNotification = { id: `scheduled_report_notification_${randomUUID()}`, - executionId: execution.id, - taskId: task.id, - type: payload.type, - severity: payload.severity, - title: payload.title, - message: payload.message, - dedupeKey, + executionId: input.executionId || input.dedupeKey, + // 字段名沿用旧存档(`taskId`)—— 迁移后 rule.id === 旧 task.id,语义一致。 + taskId: input.ruleId, + type: input.payload.type, + severity: input.payload.severity, + title: input.payload.title, + message: input.payload.message, + dedupeKey: input.dedupeKey, channel: 'agent_hub', ...(recipient ? { recipient } : {}), status: 'pending', - createdAt: (this.deps.now?.() || new Date()).toISOString(), + createdAt: input.createdAt, attempts: 0 } this.notifications!.unshift(notification) this.notifications = this.notifications!.slice(0, 500) await this.saveNotifications() - if (recipient) await this.tryDeliverNotification(notification, payload, task) + await this.tryDeliverNotification(notification, input.ruleName, input.payload) return { ...notification } } private async tryDeliverNotification( notification: ScheduledReportNotification, - payload?: ScheduledReportNotificationPayload, - task?: ScheduledReportTask + ruleName: string, + payload?: ScheduledReportNotificationPayload ): Promise { if (notification.status === 'sent') return await this.load() - const execution = this.executions!.find((item) => item.id === notification.executionId) - if ( - !this.notificationSettings!.enabled || - execution?.triggerType === 'manual' || - execution?.status === 'skipped' - ) { + if (!this.notificationSettings!.enabled) { notification.status = 'suppressed' notification.suppressedAt = (this.deps.now?.() || new Date()).toISOString() - notification.lastError = '定时日报微信异常通知已关闭或不适用于本次执行。' + notification.lastError = '定时日报微信异常通知已关闭。' await this.saveNotifications() return } @@ -1129,7 +609,7 @@ export class ScheduledReportService { const result = await this.deps.sendNotification({ to: recipient, text: this.notificationText( - task?.name || '定时日报', + ruleName, payload?.severity || notification.severity, payload?.title || notification.title, payload?.message || notification.message, @@ -1159,14 +639,17 @@ export class ScheduledReportService { } const pending = this.notifications!.filter((item) => item.status === 'pending') if (!pending.length) return + let rules: AutomationRule[] = [] + try { + rules = this.deps.listRules() + } catch { + rules = [] + } for (const notification of pending) { - const task = this.tasks!.find((item) => item.id === notification.taskId) - await this.tryDeliverNotification(notification, undefined, task) - const execution = this.executions!.find((item) => item.id === notification.executionId) - if (execution) { - execution.notificationStatus = notification.status - await this.persistExecution(execution) - } + const name = + rules.find((rule) => rule.id === notification.taskId)?.name || + AUTOMATION_RULE_TYPE_LABELS.scheduled_report + await this.tryDeliverNotification(notification, name) } } @@ -1175,19 +658,12 @@ export class ScheduledReportService { const pending = this.notifications!.filter((item) => item.status === 'pending') if (!pending.length) return const suppressedAt = (this.deps.now?.() || new Date()).toISOString() - const executionIds = new Set() for (const notification of pending) { notification.status = 'suppressed' notification.suppressedAt = suppressedAt notification.lastError = '定时日报微信异常通知已关闭。' - executionIds.add(notification.executionId) } - for (const execution of this.executions!) { - if (executionIds.has(execution.id) && execution.notificationStatus === 'pending') { - execution.notificationStatus = 'suppressed' - } - } - await Promise.all([this.saveNotifications(), this.saveExecutions()]) + await this.saveNotifications() } private notificationCapabilityReasonForSend( @@ -1199,7 +675,7 @@ export class ScheduledReportService { } private notificationText( - taskName: string, + ruleName: string, severity: ScheduledReportNotificationSeverity, title: string, message: string, @@ -1207,7 +683,7 @@ export class ScheduledReportService { ): string { const icon = severity === 'error' ? '❌' : severity === 'warning' ? '⚠️' : '✅' return [ - `${icon} ${taskName}`, + `${icon} ${ruleName}`, title, message, suggestedAction ? `建议:${suggestedAction}` : '' @@ -1216,63 +692,268 @@ export class ScheduledReportService { .join('\n') } - 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 + // --------------------------------------------------------------------------- + // 旧数据只读存档 + HTTP API 兼容投影 + // --------------------------------------------------------------------------- + + /** + * 旧 `tasks.json` 的**只读**快照。 + * + * 运行期调度**绝不**用它(唯一 source of truth 是 `AutomationRuleStore`)。 + * 保留它只为一件事:把历史执行记录还原成"当时那条任务叫什么"。 + */ + async listLegacyTasks(): Promise { + await this.load() + return this.legacyTasks!.map((task) => ({ ...task })) } - private normalizeInput(input: ScheduledReportCreateInput): ScheduledReportResult<{ - name: string - group: string - scheduleTime: string - reportRange: ScheduledReportRange - messageTypes: ScheduledReportMessageType[] - templateId: ScheduledReportTask['templateId'] - memberNameMode: ScheduledReportMemberNameMode - timeoutSeconds: number - 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 messageTypes: ScheduledReportMessageType[] = input.messageTypes?.length - ? input.messageTypes - : ['text'] - const templateId = input.templateId || 'v1' - const memberNameMode = input.memberNameMode || 'groupNickname' - const timeoutSeconds = Math.max( - 30, - Math.min(1800, Math.round(Number(input.timeoutSeconds) || 300)) + /** + * 历史执行记录(只读存档)。 + * + * 旧执行记录**无法无损转换**成新的步骤模型(旧的是 7 态 + stage + sendStatus, + * 新的是 3 态 + 通用步骤),所以**不迁移、不改写**,原样只读保留。 + */ + async listLegacyExecutions(ruleId?: string): Promise { + await this.load() + if (!ruleId) return this.legacyExecutions!.map((item) => ({ ...item })) + const key = String(ruleId).trim() + return this.legacyExecutions! + .filter((item) => this.matchesRuleId(item.taskId, key)) + .map((item) => ({ ...item })) + } + + /** 旧 taskId ↔ 新 ruleId 的对应(复用旧 id 时两者相同)。 */ + private matchesRuleId(taskId: string | undefined, ruleId: string): boolean { + const raw = String(taskId || '').trim() + if (!raw) return false + return raw === ruleId || `scheduled-report:${raw}` === ruleId + } + + /** + * 兼容投影:`scheduled_report` 规则 → 旧 `ScheduledReportTask` 形状。 + * + * **只读、内存内转换**,不产生第二份存储。只投影目标为「发回来源群」的规则 —— + * 这是旧 HTTP API 契约(`target.type === 'wechat_group'`)唯一能如实表达的形态。 + */ + async listTasks(): Promise { + const now = this.deps.now?.() || new Date() + return this.scheduledRules() + .filter((rule) => this.isHttpProjectable(rule)) + .map((rule) => this.ruleToLegacyTask(rule, now)) + } + + async listExecutions(taskId?: string): Promise { + const key = String(taskId || '').trim() + const legacy = await this.listLegacyExecutions(key || undefined) + // 新增的执行记录从 Automation Execution Log 投影(同一 ruleId)。 + let projected: ScheduledReportExecution[] = [] + try { + const executions = this.deps.listExecutions({ limit: 500 }) + projected = executions + .filter((item) => (key ? item.ruleId === key : true)) + .filter((item) => { + const rule = this.findRule(item.ruleId) + return rule?.ruleType === 'scheduled_report' + }) + .map((item) => this.automationExecutionToLegacy(item)) + } catch (error) { + console.warn('[ScheduledReport] 读取 Automation 执行日志失败:', error) + } + // 旧记录在前(更早),新记录追加在后,按开始时间排序。 + return [...legacy, ...projected].sort( + (left, right) => Date.parse(left.startedAt) - Date.parse(right.startedAt) ) - const target = String(input.target || group).trim() - if (!name) return { success: false, error: '日报名称不能为空' } + } + + async createTask( + input: ScheduledReportCreateInput + ): Promise> { + const group = String(input.group || '').trim() 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, - messageTypes, - templateId, - memberNameMode, - timeoutSeconds, - target, - enabled: input.enabled !== false - } + const name = String(input.name || '').trim() + if (!name) return { success: false, error: '日报名称不能为空' } + const scheduleTime = String(input.scheduleTime || '').trim() + if (!isValidScheduleTime(scheduleTime)) return { success: false, error: '执行时间必须是 HH:mm' } + + const config = normalizeScheduledReportConfig({ + schedule: { time: scheduleTime }, + report: { + sourceConversationId: group, + range: input.reportRange, + messageTypes: input.messageTypes, + templateId: input.templateId, + memberNameMode: input.memberNameMode, + timeoutSeconds: input.timeoutSeconds + }, + // HTTP 契约里 target 必须等于 group,等价于 source_chat。 + target: { type: 'source_chat' } + }) + const created = automationRuleStore.createRule({ + name, + enabled: input.enabled !== false, + ruleType: 'scheduled_report', + scheduledReport: config + }) + return { success: true, data: this.ruleToLegacyTask(created, this.deps.now?.() || new Date()) } + } + + async updateTask( + taskId: string, + input: ScheduledReportUpdateInput + ): Promise> { + const rule = this.findRule(taskId) + if (!rule) return { success: false, error: '未找到定时日报任务' } + const current = normalizeScheduledReportConfig(rule.scheduledReport) + const scheduleTime = String(input.scheduleTime ?? current.schedule.time).trim() + if (!isValidScheduleTime(scheduleTime)) return { success: false, error: '执行时间必须是 HH:mm' } + const next = normalizeScheduledReportConfig({ + ...current, + schedule: { time: scheduleTime }, + report: { + sourceConversationId: String(input.group ?? current.report.sourceConversationId).trim(), + range: input.reportRange ?? current.report.range, + messageTypes: input.messageTypes ?? current.report.messageTypes, + templateId: input.templateId ?? current.report.templateId, + memberNameMode: input.memberNameMode ?? current.report.memberNameMode, + timeoutSeconds: input.timeoutSeconds ?? current.report.timeoutSeconds + }, + target: current.target, + targetNeedsReview: false + }) + const updated = automationRuleStore.updateRule(rule.id, { + ...rule, + name: input.name !== undefined ? String(input.name).trim() : rule.name, + enabled: input.enabled !== undefined ? input.enabled !== false : rule.enabled, + scheduledReport: next + }) + if (!updated) return { success: false, error: '未找到定时日报任务' } + return { success: true, data: this.ruleToLegacyTask(updated, this.deps.now?.() || new Date()) } + } + + async deleteTask(taskId: string): Promise> { + const rule = this.findRule(taskId) + if (!rule) return { success: false, error: '未找到定时日报任务' } + const deleted = automationRuleStore.deleteRule(rule.id) + if (!deleted) return { success: false, error: '未找到定时日报任务' } + return { success: true, data: { deletedId: rule.id } } + } + + async setTaskEnabled( + taskId: string, + enabled: boolean + ): Promise> { + const rule = this.findRule(taskId) + if (!rule) return { success: false, error: '未找到定时日报任务' } + const updated = automationRuleStore.setRuleEnabled(rule.id, enabled) + if (!updated) return { success: false, error: '未找到定时日报任务' } + return { success: true, data: this.ruleToLegacyTask(updated, this.deps.now?.() || new Date()) } + } + + /** 新建一条定时日报规则(Automation UI 用;与兼容投影无关)。 */ + createScheduledReportRule(input: { + name: string + enabled?: boolean + config: ScheduledReportAutomationConfig + }): AutomationRule { + return automationRuleStore.createRule({ + name: input.name, + enabled: input.enabled !== false, + ruleType: 'scheduled_report', + scheduledReport: input.config + }) + } + + private scheduledRules(): AutomationRule[] { + try { + return this.deps.listRules().filter((rule) => rule.ruleType === 'scheduled_report') + } catch (error) { + console.warn('[ScheduledReport] 读取自动化规则失败:', error) + return [] } } + private findRule(ruleId: string): AutomationRule | undefined { + const key = String(ruleId || '').trim() + if (!key) return undefined + const rule = this.scheduledRules().find((item) => item.id === key) + return rule + } + + private isHttpProjectable(rule: AutomationRule): boolean { + const config = rule.scheduledReport + if (!config) return false + if (config.targetNeedsReview) return false + return this.scheduledTargetType(rule) === 'source_chat' + } + + private scheduledTargetType(rule: AutomationRule): ScheduledReportTargetType { + const type = rule.scheduledReport?.target?.type + if (type === 'self' || type === 'file_transfer' || type === 'contact' || type === 'source_chat') { + return type + } + return 'source_chat' + } + + private ruleToLegacyTask(rule: AutomationRule, now: Date): ScheduledReportTask { + const config = normalizeScheduledReportConfig(rule.scheduledReport) + const source = config.report.sourceConversationId + return { + id: rule.id, + name: rule.name, + group: source, + scheduleTime: config.schedule.time, + reportRange: config.report.range, + messageTypes: config.report.messageTypes, + templateId: config.report.templateId, + memberNameMode: config.report.memberNameMode, + timeoutSeconds: config.report.timeoutSeconds, + // 旧契约里 target 必须是群:source_chat 时就是来源群本身。 + target: source, + enabled: rule.enabled, + createdAt: new Date(rule.createdAt).toISOString(), + updatedAt: new Date(rule.updatedAt).toISOString(), + nextRunAt: calculateNextRunAt(config.schedule.time, now), + ...(config.lastRunAt ? { lastRunAt: config.lastRunAt } : {}), + ...(config.lastScheduledSlot ? { lastScheduledSlot: config.lastScheduledSlot } : {}) + } + } + + /** Automation 执行记录 → 旧 `ScheduledReportExecution` 形状(只读投影)。 */ + private automationExecutionToLegacy( + execution: ReturnType[number] + ): ScheduledReportExecution { + const startedAt = new Date(execution.triggerTime).toISOString() + const finishedAt = new Date(execution.triggerTime + Math.max(0, execution.durationMs)).toISOString() + const sent = execution.steps.some((step) => step.key === 'report_sent' && step.status === 'success') + const currentStage: ScheduledReportExecutionStage = execution.steps.some( + (step) => step.key === 'report_sent' && step.status !== 'pending' + ) + ? 'send' + : 'report' + return { + id: execution.executionId, + taskId: execution.ruleId, + triggerType: execution.trigger === 'manual' ? 'manual' : 'scheduled', + startedAt, + finishedAt, + status: execution.status, + currentStage, + message: execution.errorSummary || '日报执行完成', + ...(execution.status === 'failed' ? { error: execution.errorSummary } : {}), + retryCount: 0, + sendStatus: (sent ? 'success' : execution.status === 'failed' ? 'failed' : 'pending') as ScheduledReportSendStatus, + notificationStatus: 'not_needed' + } + } + + // --------------------------------------------------------------------------- + // 落盘 / 读盘 + // --------------------------------------------------------------------------- + private async load(): Promise { - if (this.tasks && this.executions && this.notifications && this.notificationSettings) return + if (this.legacyTasks && this.legacyExecutions && this.notifications && this.notificationSettings) { + return + } await fs.mkdir(this.deps.storageDir, { recursive: true }) const [tasks, executions, notifications, settings] = await Promise.all([ this.readJson(TASKS_FILE), @@ -1280,8 +961,8 @@ export class ScheduledReportService { this.readJson(NOTIFICATIONS_FILE), this.readJson>(SETTINGS_FILE) ]) - this.tasks = asArray(tasks) - this.executions = asArray(executions).map(normalizeExecution) + this.legacyTasks = asArray(tasks) + this.legacyExecutions = asArray(executions).map(normalizeExecution) this.notifications = asArray(notifications) this.notificationSettings = { enabled: settings?.enabled === true } } @@ -1297,22 +978,7 @@ export class ScheduledReportService { } } - 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' - ) - } - + /** **只写通知**:旧 tasks.json / executions.json 已冻结为只读存档。 */ private async saveNotifications(): Promise { await fs.writeFile( path.join(this.deps.storageDir, NOTIFICATIONS_FILE), @@ -1330,4 +996,32 @@ export class ScheduledReportService { } } +const asArray = (value: unknown): T[] => (Array.isArray(value) ? (value as T[]) : []) + +const executionStatuses = new Set([ + 'running', + 'success', + 'waiting_to_send', + 'partial_success', + 'failed', + 'waiting_for_recovery', + 'skipped' +]) + +/** 旧存档里的状态收敛:非法值靠「有没有 error」推断,绝不丢记录。 */ +const normalizeExecution = (value: ScheduledReportExecution): ScheduledReportExecution => { + const status = executionStatuses.has(value.status) + ? value.status + : value.error + ? 'failed' + : 'success' + const retryCount = Number(value.retryCount) + return { + ...value, + status, + triggerType: value.triggerType || 'scheduled', + retryCount: Number.isFinite(retryCount) && retryCount >= 0 ? retryCount : 0 + } +} + export const scheduledReportService = new ScheduledReportService() diff --git a/src/main/services/scheduled-report-target.ts b/src/main/services/scheduled-report-target.ts new file mode 100644 index 0000000..b41787c --- /dev/null +++ b/src/main/services/scheduled-report-target.ts @@ -0,0 +1,81 @@ +import { + SCHEDULED_REPORT_TARGET_OPTIONS, + scheduledReportTargetLabel, + type ScheduledReportAutomationConfig +} from '../../shared/automation' +import { + resolveAutomationTarget, + resolveGroupConversationId, + resolveGroupDisplayName, + type AutomationTargetContact, + type AutomationTargetResolution +} from './automation-wechat-target' + +/** + * 定时日报的**目标解析**(薄适配层)。 + * + * 判定逻辑在 `automation-wechat-target.ts`,与退群通知**共用同一份**。 + * 差异只有一处:来源会话不是"事件所在群",而是**这条规则配置的日报来源群**。 + */ + +/** 定时日报的目标文案(唯一一份)。 */ +export const SCHEDULED_REPORT_TARGET_MESSAGES = { + options: SCHEDULED_REPORT_TARGET_OPTIONS, + invalidTarget: '定时日报的发送目标无效,请重新选择', + sourceMissing: '无法确定日报来源群,本次日报未发送', + sourceRecipientName: '日报来源群', + sourceDisplayName: '日报来源群', + selfMissing: '无法确定当前登录的微信账号,本次日报未发送', + contactNotChosen: '还没有选择发送联系人,请重新选择', + contactUnavailable: '发送联系人已不存在或当前无法发送,请重新选择' +} as const + +export function resolveScheduledReportTarget(input: { + config: ScheduledReportAutomationConfig | undefined + contacts: AutomationTargetContact[] + /** 当前登录账号的 wxid;拿不到时传空串。 */ + selfWxid?: string +}): AutomationTargetResolution { + const config = input.config + if (!config) return { ok: false, error: '定时日报尚未配置发送目标' } + const sourceConversationId = String(config.report?.sourceConversationId || '').trim() + return resolveAutomationTarget( + { + targetType: config.target?.type as never, + sourceConversationId, + // 来源群的可读名从通讯录解析;解析不到时由中性层给兜底称呼。 + sourceDisplayName: resolveGroupDisplayName(sourceConversationId, input.contacts), + ...(config.target?.contactId ? { contactId: config.target.contactId } : {}), + contacts: input.contacts, + ...(input.selfWxid ? { selfWxid: input.selfWxid } : {}) + }, + SCHEDULED_REPORT_TARGET_MESSAGES + ) +} + +/** + * 日报来源群的可读显示名(执行日志的 `sourceDisplayName` 用它)。 + * + * **绝不回落到裸 id**:解析不到就说「日报来源群」。 + */ +export function scheduledReportSourceDisplayName( + config: ScheduledReportAutomationConfig | undefined, + contacts: AutomationTargetContact[] +): string { + const raw = String(config?.report?.sourceConversationId || '').trim() + if (!raw) return '日报来源群' + return resolveGroupDisplayName(raw, contacts) || '日报来源群' +} + +/** 日报来源群解析成稳定会话 id(执行前预检用;解析不到返回 undefined)。 */ +export function scheduledReportSourceConversationId( + config: ScheduledReportAutomationConfig | undefined, + contacts: AutomationTargetContact[] +): string | undefined { + return resolveGroupConversationId( + String(config?.report?.sourceConversationId || ''), + contacts + ) +} + +export { scheduledReportTargetLabel } diff --git a/src/main/services/wechat-action-gateway.ts b/src/main/services/wechat-action-gateway.ts index 29d7208..8232c4d 100644 --- a/src/main/services/wechat-action-gateway.ts +++ b/src/main/services/wechat-action-gateway.ts @@ -32,6 +32,12 @@ const AUTOMATION_PURPOSE_ALLOWLIST = new Set([ // 退群通知(由 Automation 发出)。目标可以是群 / 自己 / 文件传输助手 / 指定好友, // 所以**不绑定**任何 "只能发回原群" 的作用域锁。 'automation_leave_notification', + // 定时日报(由 Automation 的 `scheduled_report` 规则发出)。目标是四选一, + // 同样**不绑定**任何作用域锁。 + 'automation_scheduled_report', + // 定时日报的「后置词」(图片 sent 之后补发的那条文本)。与图片是两个独立 + // purpose,各自有幂等位 —— 少这一条会被 evaluateWechatActionPolicy 拦下。 + 'automation_scheduled_report_postfix', 'manual_report_image', 'manual_report_postfix' ]) diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 4dd0aa6..9cfda07 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -84,13 +84,12 @@ import type { import type { PersonalWechatSendCapability } from '../shared/personal-wechat' import type { GroupMemberStatsQuery, GroupMemberStatsResult } from '../shared/group-stats' import type { - ScheduledReportCreateInput, ScheduledReportExecution, + ScheduledReportNotification, + ScheduledReportNotificationCapability, ScheduledReportNotificationSettings, ScheduledReportNotificationSettingsResult, - ScheduledReportResult, - ScheduledReportTask, - ScheduledReportUpdateInput + ScheduledReportResult } from '../shared/scheduled-report' import type { PersonalWechatVoiceEncodingEnvironment, @@ -102,7 +101,8 @@ import type { AutomationExecution, AutomationRule, AutomationRuleDraft, - AutomationStatusSummary + AutomationStatusSummary, + ScheduledRuleRunOutcome } from '../shared/automation' import type { ActionLogEntry } from '../shared/action-log' import type { @@ -733,35 +733,23 @@ declare global { sendPersonalWechatMessage: ( request: PersonalWechatSendRequest ) => Promise - listScheduledReports: () => Promise - listScheduledReportExecutions: (taskId?: string) => Promise + /** 定时日报:「立即执行」——与 scheduler 走同一条执行链路(manual trigger)。 */ + runScheduledReportRule: ( + ruleId: string + ) => Promise<{ success: boolean; error?: string; data?: ScheduledRuleRunOutcome }> + /** 定时日报:旧执行记录**只读存档**(旧记录无法无损转换,原样展示)。 */ + listScheduledReportLegacyExecutions: ( + ruleId?: string + ) => Promise + /** 定时日报:微信异常通知开关与能力检测。 */ getScheduledReportNotificationSettings: () => Promise + getScheduledReportNotificationCapability: () => Promise setScheduledReportNotificationEnabled: ( enabled: boolean ) => 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> - retryScheduledReportSend: ( - executionId: string - ) => Promise> testScheduledReportErrorNotification: ( - taskId: string - ) => Promise> + ruleId: string + ) => Promise> getPersonalWechatVoiceDiagnostic: () => Promise getAgentHubStatus: () => Promise getAgentHubLogs: () => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index 39e4842..bd6e74a 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -52,13 +52,12 @@ import type { } from '../shared/personal-wechat' import type { PersonalWechatSendCapability } from '../shared/personal-wechat' import type { - ScheduledReportCreateInput, ScheduledReportExecution, + ScheduledReportNotification, + ScheduledReportNotificationCapability, ScheduledReportNotificationSettings, ScheduledReportNotificationSettingsResult, - ScheduledReportResult, - ScheduledReportTask, - ScheduledReportUpdateInput + ScheduledReportResult } from '../shared/scheduled-report' import type { PersonalWechatVoiceEncodingEnvironment, @@ -71,7 +70,8 @@ import type { AutomationExecution, AutomationRule, AutomationRuleDraft, - AutomationStatusSummary + AutomationStatusSummary, + ScheduledRuleRunOutcome } from '../shared/automation' import type { GroupMemberStatsQuery, GroupMemberStatsResult } from '../shared/group-stats' import type { ActionLogEntry } from '../shared/action-log' @@ -572,44 +572,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), + /** + * 定时日报(Automation 的 `scheduled_report` 规则类型)。 + * + * 规则 CRUD 复用上面的 `automation:*` 通道;这里只暴露三块专属能力: + * 立即执行、微信异常通知、旧执行记录只读存档。 + */ + runScheduledReportRule: ( + ruleId: string + ): Promise<{ success: boolean; error?: string; data?: ScheduledRuleRunOutcome }> => + ipcRenderer.invoke('automation:runScheduledReportRule', ruleId), + listScheduledReportLegacyExecutions: (ruleId?: string): Promise => + ipcRenderer.invoke('automation:listScheduledReportLegacyExecutions', ruleId), getScheduledReportNotificationSettings: (): Promise => - ipcRenderer.invoke('scheduled-report:getNotificationSettings'), + ipcRenderer.invoke('automation:getScheduledReportNotificationSettings'), + getScheduledReportNotificationCapability: (): Promise => + ipcRenderer.invoke('automation:getScheduledReportNotificationCapability'), setScheduledReportNotificationEnabled: ( enabled: boolean ): Promise => - ipcRenderer.invoke('scheduled-report:setNotificationEnabled', enabled), - 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), - retryScheduledReportSend: ( - executionId: string - ): Promise> => - ipcRenderer.invoke('scheduled-report:retrySend', executionId), + ipcRenderer.invoke('automation:setScheduledReportNotificationEnabled', enabled), testScheduledReportErrorNotification: ( - taskId: string - ): Promise> => - ipcRenderer.invoke('scheduled-report:testErrorNotification', taskId), + ruleId: string + ): Promise> => + ipcRenderer.invoke('automation:testScheduledReportErrorNotification', ruleId), getPersonalWechatVoiceDiagnostic: (): Promise => ipcRenderer.invoke('wechat-personal:getVoiceDiagnostic'), getAgentHubStatus: () => ipcRenderer.invoke('agent-hub:getStatus'), diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 8cc2e47..26946f8 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -18,7 +18,6 @@ import { ReportInfoPanel } from './components/reports/ReportInfoPanel' import { ReportSourceSidebar } from './components/reports/ReportSourceSidebar' import { ReportTaskStatusPanel } from './components/reports/ReportTaskStatusPanel' import { ReportViewer } from './components/reports/ReportViewer' -import { ScheduledReportsWorkspace } from './components/reports/ScheduledReportsWorkspace' import { ReportTemplateMarketWorkspace } from './components/reports/ReportTemplateMarketWorkspace' import { contactDisplayName } from './components/reports/types' import type { GeneratedReportRecord, ReportWorkspaceView } from './components/reports/types' @@ -42,8 +41,8 @@ import { isRelevantMessageMonitorEvent, parseWcdbMonitorEvent } from './utils/me import { enrichQuotedMessages } from './utils/quoted-messages' import type { ReportTemplateSelectionId } from '../../shared/report-templates' import { switchGeneratedReportTemplate } from './utils/report-template-switch' -import { runtimePlatform, supportsPersonalWechatSend } from './utils/runtime-environment' -import { useToast } from './components/ui' +import { runtimePlatform } from './utils/runtime-environment' +import { Button, useToast } from './components/ui' import { AppUpdatePrompt } from './features/app-update/AppUpdatePrompt' import { GroupExitMonitorWorkspace, type GroupExitMonitorOpenViewRequest } from './features/group-exit-monitor/GroupExitMonitorWorkspace' import { AutomationWorkspace, type AutomationOpenRuleRequest } from './features/automation/AutomationWorkspace' @@ -280,7 +279,7 @@ function App(): React.ReactElement { const [settingsCategory, setSettingsCategory] = useState('account-database') const [reportSourceContact, setReportSourceContact] = useState(null) const [reportWorkspaceView, setReportWorkspaceView] = useState('result') - const [reportSection, setReportSection] = useState<'today' | 'scheduled' | 'market'>('today') + const [reportSection, setReportSection] = useState<'today' | 'market'>('today') const [generatedReports, setGeneratedReports] = useState([]) const [selectedReportId, setSelectedReportId] = useState(null) const [latestGeneratedReportId, setLatestGeneratedReportId] = useState(null) @@ -1678,9 +1677,10 @@ function App(): React.ReactElement { } /** - * 「日报 → 定时日报 → 在自动化中配置」。 + * 「定时日报」已正式迁入自动化,日报页只保留「今日日报 | 社区模板市场」两个 Tab。 * - * 纯导航:直接落到「自动化 → 规则 → 定时日报」的规则列表。 + * 这里仍然留一个导航入口:用户是从日报页产生"要定时发日报"这个念头的, + * 不给路会显得功能被删了。它**不是**第三个 Tab,只是一句去处的说明。 */ const openScheduledReportAutomation = (): void => { setAutomationOpenRuleRequest({ ruleType: 'scheduled_report', requestId: Date.now() }) @@ -1977,22 +1977,6 @@ function App(): React.ReactElement { > 今日日报 - +
{reportSection === 'market' ? ( - ) : reportSection === 'scheduled' ? ( - - toast({ description: message, variant, duration: 3200 }) - } - onOpenAutomation={openScheduledReportAutomation} - /> ) : reportWorkspaceView === 'result' ? (
setExitMonitorOpenViewRequest(null)} onOpenLeaveNotificationAutomation={openLeaveNotificationAutomation} /> ) @@ -2148,6 +2135,7 @@ function App(): React.ReactElement { onOpenExitMonitorGroups={openExitMonitorGroups} openRuleRequest={automationOpenRuleRequest} onOpenModelSettings={openModelSettings} + onOpenAgentHub={openAgentHub} /> ) case 'agent-hub': diff --git a/src/renderer/src/components/reports/ScheduledReportsWorkspace.tsx b/src/renderer/src/components/reports/ScheduledReportsWorkspace.tsx deleted file mode 100644 index a8b1632..0000000 --- a/src/renderer/src/components/reports/ScheduledReportsWorkspace.tsx +++ /dev/null @@ -1,1242 +0,0 @@ -import * as React from 'react' -import type { Contact } from '../../../../shared/types' -import type { PersonalWechatSendCapability } from '../../../../shared/personal-wechat' -import type { - ScheduledReportCreateInput, - ScheduledReportExecution, - ScheduledReportMessageType, - ScheduledReportMemberNameMode, - ScheduledReportNotificationCapabilityReason, - ScheduledReportRange, - ScheduledReportTask -} from '../../../../shared/scheduled-report' -import type { SelectableReportTemplateId } from '../../../../shared/report-templates' -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - Button, - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - Input, - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, - Skeleton, - Switch, - Checkbox, - SegmentedControl, - SegmentedControlItem -} from '../ui' -import { contactDisplayName } from './types' -import { - SUMMARY_DATE_OPTIONS, - SUMMARY_TYPE_OPTIONS, - getSummaryDateRange, - type SummaryDateRange -} from '../../utils/group-report' -import { REPORT_TEMPLATES, DEFAULT_REPORT_TEMPLATE } from '../../../../shared/report-templates' -import { isTruthyDebugFlag } from '../../../../shared/debug-flags' - -type ReportDialogMode = 'create' | 'edit' - -interface ScheduledReportsWorkspaceProps { - contacts: Contact[] - platformSupported?: boolean - onOpenWechatSettings: () => void - onOpenAgentHub: () => void - onOpenModelSettings?: () => void - onNotice: (message: string, variant?: 'default' | 'success' | 'warning' | 'destructive') => void - /** 纯导航:跳到「自动化 → 规则 → 定时日报」;不改动本页任何数据。 */ - onOpenAutomation?: () => void -} - -const rangeLabel = (range: ScheduledReportRange): string => - range === 'recent24h' - ? '最近24小时' - : range === '7days' - ? '近 7 天' - : range === 'today' - ? '今日' - : '昨日' - -const isScheduledReportRange = (value: unknown): value is ScheduledReportRange => - value === 'today' || value === 'yesterday' || value === '7days' || value === 'recent24h' - -const allMessageTypes: ScheduledReportMessageType[] = SUMMARY_TYPE_OPTIONS.map( - (option) => option.value -) - -const reportRangeForUi = (range: ScheduledReportRange): SummaryDateRange => - range === '7days' || range === 'yesterday' ? range : 'today' - -const messageTypeCounts = ( - messages: Array<{ type: string }> -): Record => { - const counts = Object.fromEntries(allMessageTypes.map((type) => [type, 0])) as Record< - ScheduledReportMessageType, - number - > - for (const message of messages) { - const option = SUMMARY_TYPE_OPTIONS.find((item) => item.messageTypes.includes(message.type)) - if (option) counts[option.value] += 1 - } - return counts -} - -const formatDateTime = (value?: string): string => { - if (!value) return '尚未执行' - const date = new Date(value) - if (Number.isNaN(date.getTime())) return '时间未知' - const now = new Date() - const dayLabel = - date.toDateString() === now.toDateString() ? '今日' : date.toLocaleDateString('zh-CN') - return `${dayLabel} ${date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}` -} - -const capabilityCopy = (capability: PersonalWechatSendCapability | null): string => { - if (!capability) return '正在检查微信发送能力…' - switch (capability.status) { - case 'ready': - return '✓ 微信发送能力已就绪' - case 'unsupported': - return '微信消息发送目前仅支持 macOS 和 Windows' - case 'needs_binding': - case 'unconfigured': - return '请先绑定个人微信' - case 'initializing': - return '微信发送能力正在初始化' - default: - return '微信发送能力异常' - } -} - -const capabilityTone = (capability: PersonalWechatSendCapability | null): string => { - if (!capability) return 'border-border-subtle bg-muted/30 text-muted-foreground' - if (capability.status === 'ready') return 'border-border-subtle bg-muted/20 text-foreground' - return 'border-border-subtle bg-muted/20 text-muted-foreground' -} - -const readableError = (error?: string): string => { - const value = String(error || '') - return value || '操作失败,请稍后重试。' -} - -const notificationFailureCopy = ( - reason?: ScheduledReportNotificationCapabilityReason, - error?: string -): string => { - if (reason === 'agent_hub_offline') return '需要先连接 Agent Hub 微信机器人,才能接收异常通知。' - if (reason === 'connector_offline') return 'Agent Hub 微信连接器当前未在线,请先恢复连接。' - if (reason === 'recipient_not_bound') { - return 'Agent Hub 已连接,但还不知道异常通知应该发送给谁。请先在微信中给 TraceMemo 机器人发送一条消息,完成通知接收者绑定。' - } - if (reason === 'settings_persist_failed') return '异常通知开关保存失败,请稍后重试。' - return error ? `异常通知测试发送失败:${error}` : '异常通知测试发送失败,请检查 Agent Hub 连接。' -} - -const executionLabel = (execution: ScheduledReportExecution): string => { - if (execution.status === 'running') return '生成中 · 待发送' - if (execution.status === 'success') return '已生成并发送' - if (execution.status === 'waiting_to_send') return '日报已生成,但未发送' - if (execution.status === 'partial_success') return '日报已生成,微信发送失败' - if (execution.status === 'waiting_for_recovery') return '等待恢复' - if (execution.status === 'skipped') return execution.userTitle || '暂无可生成的日报' - return execution.userTitle || '日报生成失败' -} - -const executionDescription = (execution: ScheduledReportExecution): string => { - if (execution.status === 'success') { - return execution.sendTarget ? `已发送到:${execution.sendTarget}` : '日报已生成并发送' - } - return execution.userMessage || execution.message || '本次定时日报执行失败。' -} - -function GroupAvatar({ contact }: { contact: Contact }): React.ReactElement { - const name = contactDisplayName(contact) - return ( - - {contact.avatar ? ( - {name} - ) : ( - name.slice(0, 1) - )} - - ) -} - -function ScheduledReportDialog({ - open, - mode, - task, - contacts, - busy, - onOpenModelSettings, - onOpenChange, - onSubmit -}: { - open: boolean - mode: ReportDialogMode - task: ScheduledReportTask | null - contacts: Contact[] - busy: boolean - onOpenModelSettings?: () => void - onOpenChange: (open: boolean) => void - onSubmit: (input: ScheduledReportCreateInput, taskId?: string) => Promise -}): React.ReactElement { - const groups = React.useMemo( - () => contacts.filter((contact) => contact.type === 'group'), - [contacts] - ) - const [keyword, setKeyword] = React.useState('') - const [selectedId, setSelectedId] = React.useState('') - const [name, setName] = React.useState('') - const [scheduleTime, setScheduleTime] = React.useState('09:00') - const [reportRange, setReportRange] = React.useState('yesterday') - const [selectedMessageTypes, setSelectedMessageTypes] = - React.useState(allMessageTypes) - const [templateId, setTemplateId] = React.useState('v1') - const [memberNameMode, setMemberNameMode] = - React.useState('groupNickname') - const [timeoutSeconds, setTimeoutSeconds] = React.useState(300) - const [rangeMessages, setRangeMessages] = React.useState>([]) - const [rangeLoading, setRangeLoading] = React.useState(false) - const [memberCount, setMemberCount] = React.useState(null) - const [memberCountAvailable, setMemberCountAvailable] = React.useState(true) - const selected = groups.find( - (contact) => contact.md5 === selectedId || contact.m_nsUsrName === selectedId - ) - - React.useEffect(() => { - if (!open) return - setKeyword('') - setSelectedId(task?.target || task?.group || '') - setName(task?.name || '') - setScheduleTime(task?.scheduleTime || '09:00') - const persistedRange = task?.reportRange - setReportRange(isScheduledReportRange(persistedRange) ? persistedRange : 'yesterday') - setSelectedMessageTypes(task?.messageTypes?.length ? task.messageTypes : allMessageTypes) - setTemplateId(task?.templateId || 'v1') - setMemberNameMode(task?.memberNameMode || 'groupNickname') - setTimeoutSeconds(task?.timeoutSeconds || 300) - setRangeMessages([]) - setMemberCount(null) - setMemberCountAvailable(true) - }, [open, task]) - - React.useEffect(() => { - if (open && !selectedId && groups[0]) { - setSelectedId(groups[0].md5) - } - }, [groups, open, selectedId]) - - React.useEffect(() => { - if (!open || !selected) return - setRangeLoading(true) - const loadRange = async (): Promise => { - try { - const range = - reportRange === 'recent24h' ? null : getSummaryDateRange(reportRangeForUi(reportRange)) - const now = Math.floor(Date.now() / 1000) - const messages = await window.api.getMessages( - selected.md5, - range?.startTime ?? now - 86400, - range?.endTime ?? now - ) - setRangeMessages(messages) - } catch { - setRangeMessages([]) - } finally { - setRangeLoading(false) - } - } - void loadRange() - }, [open, reportRange, selected]) - - React.useEffect(() => { - if (!open || !selected) return - const getGroupSnapshot = window.api.getGroupSnapshot - if (typeof getGroupSnapshot !== 'function') { - setMemberCountAvailable(false) - return - } - let cancelled = false - void getGroupSnapshot(selected.md5) - .then((snapshot) => { - if (cancelled) return - setMemberCount(snapshot?.memberCount ?? snapshot?.members?.length ?? null) - }) - .catch(() => { - if (!cancelled) setMemberCountAvailable(false) - }) - return () => { - cancelled = true - } - }, [open, selected]) - - const filteredGroups = React.useMemo(() => { - const lower = keyword.trim().toLowerCase() - if (!lower) return groups - return groups.filter((contact) => - `${contactDisplayName(contact)} ${contact.m_nsUsrName}`.toLowerCase().includes(lower) - ) - }, [groups, keyword]) - const submit = async (): Promise => { - if (!selected || !name.trim()) return - await onSubmit( - { - name: name.trim(), - group: selected.md5, - target: selected.m_nsUsrName, - scheduleTime, - reportRange, - messageTypes: selectedMessageTypes, - templateId, - memberNameMode, - timeoutSeconds - }, - task?.id - ) - } - - return ( - - - - {mode === 'create' ? '新建定时日报' : '编辑定时日报'} - 每天自动生成群聊日报,并发送到指定微信群。 - -
- -
- 选择群聊 - setKeyword(event.target.value)} - placeholder="搜索群聊" - /> -
- {filteredGroups.length ? ( - filteredGroups.map((contact) => { - const selectedRow = selected?.md5 === contact.md5 - return ( - - ) - }) - ) : ( -

- 没有匹配的群聊 -

- )} -
-
-
-
- 总结范围 - - {rangeLoading ? '正在计算' : `${rangeMessages.length} 条消息`} - -
- setReportRange(value as ScheduledReportRange)} - > - {SUMMARY_DATE_OPTIONS.map((option) => ( - - {option.label} - - ))} - - 最近24小时 - - - 自定义 - 即将支持 - - -
-
-
- 纳入的消息类型 - 至少选择一种 -
-
- {SUMMARY_TYPE_OPTIONS.map((option) => ( - - ))} -
-
-
- 日报内容 -

- 与普通日报相同,固定生成今日话题、重要消息、问题与解答、实用资源、精彩对话、活跃成员、活跃时间分布、关键词和内容密度。 -

-
-
-
-
- 模型配置 -

- 使用当前日报默认的文字总结和图片理解模型。 -

-
- {onOpenModelSettings && ( - - )} -
-
-
- - -
-
- - -
-
- 发送到 - - {selected ? contactDisplayName(selected) : '请选择微信群'} - -
-

- 微信数据库和聊天记录默认从本机读取。所选内容将发送至你配置的模型服务进行处理,TraceMemo - 本身不额外保存或转发内容。 -

-
- - - - -
-
- ) -} - -export function ScheduledReportsWorkspace({ - contacts, - platformSupported = true, - onOpenWechatSettings, - onOpenAgentHub, - onOpenModelSettings, - onNotice, - onOpenAutomation -}: ScheduledReportsWorkspaceProps): React.ReactElement { - const [capability, setCapability] = React.useState(null) - const [capabilityError, setCapabilityError] = React.useState(false) - const [notificationEnabled, setNotificationEnabled] = React.useState(false) - const [notificationBusy, setNotificationBusy] = React.useState(false) - const [notificationSettingsError, setNotificationSettingsError] = React.useState(false) - const [notificationFailure, setNotificationFailure] = React.useState<{ - reason?: ScheduledReportNotificationCapabilityReason - error?: string - } | null>(null) - const [tasks, setTasks] = React.useState([]) - const [executions, setExecutions] = React.useState([]) - const [loading, setLoading] = React.useState(true) - const [listError, setListError] = React.useState(false) - const [dialogOpen, setDialogOpen] = React.useState(false) - const [dialogMode, setDialogMode] = React.useState('create') - const [editingTask, setEditingTask] = React.useState(null) - const [saving, setSaving] = React.useState(false) - const [deletingTask, setDeletingTask] = React.useState(null) - const [retryingExecution, setRetryingExecution] = React.useState( - null - ) - const [busyTaskId, setBusyTaskId] = React.useState(null) - - const load = React.useCallback(async (): Promise => { - setLoading(true) - setListError(false) - if (!platformSupported) { - setCapability({ - supported: false, - ready: false, - status: 'unsupported', - capabilities: { text: false, image: false, voice: false }, - senderStatus: {} as PersonalWechatSendCapability['senderStatus'], - message: '定时日报目前仅支持 macOS 和 Windows' - }) - setTasks([]) - setExecutions([]) - try { - const settings = await window.api.getScheduledReportNotificationSettings() - setNotificationEnabled(settings.enabled) - setNotificationSettingsError(false) - } catch { - setNotificationSettingsError(true) - } - setLoading(false) - return - } - const [taskResult, executionResult, capabilityResult, notificationSettingsResult] = - await Promise.allSettled([ - window.api.listScheduledReports(), - window.api.listScheduledReportExecutions(), - window.api.getPersonalWechatSendCapability(), - window.api.getScheduledReportNotificationSettings() - ]) - if (taskResult.status === 'fulfilled') setTasks(taskResult.value) - else setListError(true) - if (executionResult.status === 'fulfilled') setExecutions(executionResult.value) - if (capabilityResult.status === 'fulfilled') { - setCapability(capabilityResult.value) - setCapabilityError(false) - } else { - setCapabilityError(true) - } - if (notificationSettingsResult.status === 'fulfilled') { - setNotificationEnabled(notificationSettingsResult.value.enabled) - setNotificationSettingsError(false) - } else { - setNotificationSettingsError(true) - } - setLoading(false) - }, [platformSupported]) - - React.useEffect(() => { - void load() - }, [load]) - - const refreshTasks = React.useCallback(async (): Promise => { - const [nextTasks, nextExecutions] = await Promise.all([ - window.api.listScheduledReports(), - window.api.listScheduledReportExecutions() - ]) - setTasks(nextTasks) - setExecutions(nextExecutions) - }, []) - - React.useEffect(() => { - const timer = window.setInterval(() => { - if (dialogOpen) return - void refreshTasks().catch(() => undefined) - void window.api - .getPersonalWechatSendCapability() - .then((nextCapability) => { - setCapability(nextCapability) - setCapabilityError(false) - }) - .catch(() => setCapabilityError(true)) - void window.api - .getScheduledReportNotificationSettings() - .then((settings) => { - setNotificationEnabled(settings.enabled) - setNotificationSettingsError(false) - }) - .catch(() => setNotificationSettingsError(true)) - }, 15_000) - return () => window.clearInterval(timer) - }, [dialogOpen, refreshTasks]) - - const setNotification = async (enabled: boolean): Promise => { - setNotificationBusy(true) - try { - const result = await window.api.setScheduledReportNotificationEnabled(enabled) - setNotificationEnabled(result.data.enabled) - if (result.success) { - setNotificationFailure(null) - onNotice(enabled ? '微信异常通知已开启' : '微信异常通知已关闭', 'success') - } else { - setNotificationFailure({ reason: result.reason, error: result.error }) - onNotice(notificationFailureCopy(result.reason, result.error), 'warning') - } - } catch (error) { - setNotificationEnabled(false) - const message = readableError(error instanceof Error ? error.message : String(error)) - setNotificationFailure({ reason: 'send_failed', error: message }) - onNotice(message, 'warning') - } finally { - setNotificationBusy(false) - } - } - - const submit = async (input: ScheduledReportCreateInput, taskId?: string): Promise => { - setSaving(true) - try { - const result = taskId - ? await window.api.updateScheduledReport(taskId, input) - : await window.api.createScheduledReport(input) - if (!result.success) { - onNotice(readableError(result.error), 'destructive') - return - } - setDialogOpen(false) - await refreshTasks() - onNotice(taskId ? '定时日报已更新' : '定时日报已创建', 'success') - } catch (error) { - onNotice(readableError(error instanceof Error ? error.message : String(error)), 'destructive') - } finally { - setSaving(false) - } - } - - const setEnabled = async (task: ScheduledReportTask, enabled: boolean): Promise => { - setBusyTaskId(task.id) - try { - const result = await window.api.setScheduledReportEnabled(task.id, enabled) - if (!result.success) onNotice(readableError(result.error), 'destructive') - else await refreshTasks() - } catch (error) { - onNotice(readableError(error instanceof Error ? error.message : String(error)), 'destructive') - } finally { - setBusyTaskId(null) - } - } - - const runNow = async (task: ScheduledReportTask): Promise => { - setBusyTaskId(task.id) - try { - const result = await window.api.runScheduledReportNow(task.id) - await refreshTasks() - const execution = result.data - if (execution?.status === 'waiting_to_send') { - onNotice('日报已生成,但未发送', 'warning') - } else if (execution?.status === 'partial_success') { - onNotice('日报已生成,微信发送失败', 'warning') - } else if (execution?.status === 'success') { - onNotice('日报已生成并发送', 'success') - } else if (execution?.status === 'skipped') { - onNotice('本次没有可生成的日报', 'default') - } else if (!result.success) { - onNotice( - execution?.userMessage || readableError(result.error || execution?.error), - 'destructive' - ) - } - } catch (error) { - onNotice(readableError(error instanceof Error ? error.message : String(error)), 'destructive') - } finally { - setBusyTaskId(null) - } - } - - const retrySend = async (execution: ScheduledReportExecution): Promise => { - const task = tasks.find((item) => item.id === execution.taskId) - const busyId = task?.id || execution.id - setBusyTaskId(busyId) - try { - const result = await window.api.retryScheduledReportSend(execution.id) - await refreshTasks() - if (result.data?.status === 'success') onNotice('日报已重新发送', 'success') - else if (result.data?.status === 'waiting_to_send') - onNotice('微信发送能力仍未恢复', 'warning') - else onNotice(result.data?.userMessage || readableError(result.error), 'warning') - } catch (error) { - onNotice(readableError(error instanceof Error ? error.message : String(error)), 'destructive') - } finally { - setBusyTaskId(null) - } - } - - const confirmRetrySend = async (): Promise => { - if (!retryingExecution || busyTaskId) return - const execution = retryingExecution - setRetryingExecution(null) - await retrySend(execution) - } - - const testErrorNotification = async (task: ScheduledReportTask): Promise => { - setBusyTaskId(task.id) - try { - const result = await window.api.testScheduledReportErrorNotification(task.id) - await refreshTasks() - if (result.data?.notificationStatus === 'sent') { - onNotice('测试错误信息已发送到 Agent Hub 微信通知接收者', 'success') - } else { - onNotice(readableError(result.error), 'warning') - } - } catch (error) { - onNotice(readableError(error instanceof Error ? error.message : String(error)), 'destructive') - } finally { - setBusyTaskId(null) - } - } - - const openReport = async (execution: ScheduledReportExecution): Promise => { - if (!execution.pngPath) { - onNotice('该执行记录没有可查看的日报文件', 'destructive') - return - } - const result = await window.api.revealGroupReport(execution.pngPath) - if (!result.success) onNotice(result.error || '日报文件打开失败', 'destructive') - } - - const confirmDelete = async (): Promise => { - if (!deletingTask) return - setBusyTaskId(deletingTask.id) - try { - const result = await window.api.deleteScheduledReport(deletingTask.id) - if (!result.success) onNotice(readableError(result.error), 'destructive') - else { - setDeletingTask(null) - await refreshTasks() - onNotice('定时日报已删除', 'success') - } - } catch (error) { - onNotice(readableError(error instanceof Error ? error.message : String(error)), 'destructive') - } finally { - setBusyTaskId(null) - } - } - - const openCreate = (): void => { - setDialogMode('create') - setEditingTask(null) - setDialogOpen(true) - } - - const groupsById = React.useMemo( - () => new Map(contacts.map((contact) => [contact.md5, contact])), - [contacts] - ) - const sortedExecutions = React.useMemo( - () => - [...executions] - .sort((left, right) => Date.parse(right.startedAt) - Date.parse(left.startedAt)) - .slice(0, 8), - [executions] - ) - const showDebugNotificationButton = isTruthyDebugFlag(import.meta.env.VITE_SCHEDULED_REPORT_DEBUG) - return ( -
-
-
-

定时日报

-

每天自动生成群聊日报,并发送到指定微信群。

- {onOpenAutomation ? ( - - ) : null} -
-
-
- 微信异常通知 - void setNotification(value)} - /> - {notificationEnabled && ✓} -
- -
-
- {notificationEnabled && ( -

- 定时日报出现异常时,通过 Agent Hub 微信机器人通知你。 -

- )} - {notificationFailure && ( -
- - {notificationFailureCopy(notificationFailure.reason, notificationFailure.error)} - -
- - -
-
- )} -
- 微信发送能力 - - - {capabilityError ? '微信发送能力加载失败' : capabilityCopy(capability)} - - {(capability?.status === 'needs_binding' || - capability?.status === 'unconfigured' || - capability?.status === 'initializing' || - capability?.status === 'error' || - capabilityError) && ( - - )} -
- {listError && ( -
- 加载定时日报失败,请重试。 - -
- )} - {loading ? ( -
- {[0, 1, 2].map((item) => ( - - ))} -
- ) : tasks.length ? ( -
-
-
- 任务名称 - 执行时间 - 日报范围 - 发送目标 - 状态 - 下次执行 - 操作 -
- {tasks.map((task) => { - const group = groupsById.get(task.group) - const label = group ? contactDisplayName(group) : task.target || task.group - const running = busyTaskId === task.id - return ( -
-
- {group && } -
-

{task.name}

-

{label}

-
-
- 每天 {task.scheduleTime} - - {rangeLabel(task.reportRange)} - - {label} -
- void setEnabled(task, value)} - /> - - {task.enabled ? '已启用' : '已暂停'} - -
- - {task.enabled ? formatDateTime(task.nextRunAt) : '已暂停'} - -
- - {showDebugNotificationButton && ( - - )} - - -
-
- ) - })} -
-
- ) : ( -
-
- ◷ -
-

还没有定时日报

-

创建一个任务,让 TraceMemo 每天自动生成群聊日报并发送到微信。

- -
- )} -
-
-
-

执行记录

-

最近几次定时日报执行结果

-
-
- {sortedExecutions.length ? ( -
- {sortedExecutions.map((execution) => { - const task = tasks.find((item) => item.id === execution.taskId) - const success = execution.status === 'success' - const skipped = execution.status === 'skipped' - const sendPending = - execution.status === 'waiting_to_send' || execution.status === 'partial_success' - const busy = busyTaskId === (task?.id || execution.id) - return ( -
- - {success - ? '✓' - : skipped - ? '–' - : execution.status === 'running' - ? '…' - : sendPending - ? '!' - : '×'} - -
-

{task?.name || '定时日报'}

-

{executionLabel(execution)}

-

- {executionDescription(execution)} -

- {execution.suggestedAction && execution.status !== 'success' && ( -

- 建议:{execution.suggestedAction} -

- )} -
- {sendPending && ( - - )} - {execution.status === 'failed' && ( - - )} - {execution.pngPath && ( - - )} - {execution.status !== 'success' && - execution.errorCode?.startsWith('WECHAT_') && ( - - )} - {execution.technicalMessage && ( -
- 查看详情 -

- {execution.technicalMessage} -

-
- )} -
-
- -
- ) - })} -
- ) : ( -

- 暂无执行记录 -

- )} -
- - !open && setDeletingTask(null)} - > - - - 删除定时日报? - 删除后不会再自动生成和发送该日报。 - - - 取消 - { - event.preventDefault() - void confirmDelete() - }} - disabled={Boolean(busyTaskId)} - className="bg-destructive text-destructive-foreground hover:bg-destructive/90" - > - 删除 - - - - - !open && setRetryingExecution(null)} - > - - - 将重新发送这份日报? - -
- - 目标: - {retryingExecution?.sendTarget || - tasks.find((item) => item.id === retryingExecution?.taskId)?.target || - tasks.find((item) => item.id === retryingExecution?.taskId)?.group || - '未指定'} - - - 日报: - {tasks.find((item) => item.id === retryingExecution?.taskId)?.name || '定时日报'} - - 日期:{formatDateTime(retryingExecution?.startedAt)} -
-
-
- - 取消 - { - event.preventDefault() - void confirmRetrySend() - }} - disabled={Boolean(busyTaskId)} - > - {busyTaskId ? '发送中…' : '确认重新发送'} - - -
-
-
- ) -} diff --git a/src/renderer/src/features/automation/AutomationRuleTypeTabs.tsx b/src/renderer/src/features/automation/AutomationRuleTypeTabs.tsx index a671741..045c85d 100644 --- a/src/renderer/src/features/automation/AutomationRuleTypeTabs.tsx +++ b/src/renderer/src/features/automation/AutomationRuleTypeTabs.tsx @@ -5,11 +5,10 @@ import { AUTOMATION_RULE_TYPE_LABELS, type AutomationRuleType } from '../../../. /** * Tab 上能出现的规则类型。 * - * `scheduled_report` **只存在于这一层**:真实 `AutomationRule.ruleType` - * 仍然只有 `daily_report` / `leave_notification` - * —— 不往真实 schema 里塞一个还不存在的类型。 + * `scheduled_report` 是真实的 `AutomationRuleType`;这个别名只为让调用点的 + * 语义更清楚 —— 它等价于 `AutomationRuleType`。 */ -export type AutomationRuleTabType = AutomationRuleType | 'scheduled_report' +export type AutomationRuleTabType = AutomationRuleType /** * Tab 顺序 = 触发方式的演进:消息触发 → 时间触发 → 系统事件触发。 @@ -20,11 +19,7 @@ const RULE_TABS: AutomationRuleTabType[] = [ 'leave_notification' ] -const TAB_LABELS: Record = { - daily_report: AUTOMATION_RULE_TYPE_LABELS.daily_report, - scheduled_report: '定时日报', - leave_notification: AUTOMATION_RULE_TYPE_LABELS.leave_notification -} +const TAB_LABELS: Record = AUTOMATION_RULE_TYPE_LABELS /** * AutomationRuleTypeTabs —— 编辑器上方的**规则类型切换**。 diff --git a/src/renderer/src/features/automation/AutomationWorkspace.tsx b/src/renderer/src/features/automation/AutomationWorkspace.tsx index 61aa3d9..bd4fc40 100644 --- a/src/renderer/src/features/automation/AutomationWorkspace.tsx +++ b/src/renderer/src/features/automation/AutomationWorkspace.tsx @@ -2,6 +2,8 @@ import * as React from 'react' import { BUILTIN_DAILY_REPORT_RULE_ID, BUILTIN_LEAVE_NOTIFICATION_RULE_ID, + calculateNextRunAt, + normalizeScheduledReportConfig, type AutomationExecution, type AutomationRule, type AutomationRuleDraft, @@ -24,15 +26,15 @@ import { } from './AutomationRuleTypeTabs' import { ScheduledReportEditor } from './ScheduledReportEditor' import { ScheduledReportRuleList } from './ScheduledReportRuleList' -import type { ScheduledReportTask } from '../../../../shared/scheduled-report' +import { ScheduledReportNotificationPanel } from './ScheduledReportNotificationPanel' import { + createDraftFromRule, createEmptyScheduledReportDraft, - createDraftFromTask, + draftToAutomationRuleDraft, formatNextRunAt, - loadScheduledReportTasks, resolveGroupDisplayName, type ScheduledReportDraft -} from './model/scheduled-report-preview' +} from './model/scheduled-report-model' import { LeaveNotificationEditor, type LeaveNotificationSaveInput @@ -68,6 +70,8 @@ export interface AutomationWorkspaceProps { onOpenExitMonitorGroups?: () => void /** 「更改模型」:复用现有的模型设置入口(定时日报的模型配置)。 */ onOpenModelSettings?: () => void + /** 「微信异常通知」依赖 Agent Hub:未就绪时给一个可操作的去处。 */ + onOpenAgentHub?: () => void /** 深链请求(来自退群监控页的「退群通知自动化」入口)。 */ openRuleRequest?: AutomationOpenRuleRequest | null } @@ -84,6 +88,7 @@ export function AutomationWorkspace({ onOpenSendSettings, onOpenExitMonitorGroups, onOpenModelSettings, + onOpenAgentHub, openRuleRequest }: AutomationWorkspaceProps): React.ReactElement { const { toast } = useToast() @@ -96,6 +101,8 @@ export function AutomationWorkspace({ const [executions, setExecutions] = React.useState([]) const [sendableContacts, setSendableContacts] = React.useState([]) const [monitoredGroupCount, setMonitoredGroupCount] = React.useState(0) + /** 已监控群聊清单(含显示名)—— 退群通知「通知群聊」二次勾选的候选项。 */ + const [monitoredGroups, setMonitoredGroups] = React.useState([]) const [loading, setLoading] = React.useState(true) const [clearing, setClearing] = React.useState(false) const [saving, setSaving] = React.useState(false) @@ -105,22 +112,31 @@ export function AutomationWorkspace({ const [pendingDelete, setPendingDelete] = React.useState(null) const reload = React.useCallback(async (): Promise => { - const [nextStatus, nextRules, nextGroups, nextExecutions, nextContacts, nextMonitored] = - await Promise.all([ - automationApi.getStatus(), - automationApi.listRules(), - automationApi.listGroups(), - automationApi.listExecutions(100), - // 「指定好友」候选与已监控群聊数量:都来自既有能力,不在自动化里另存一份。 - automationApi.listSendableContacts(), - automationApi.getMonitoredGroupCount() - ]) + const [ + nextStatus, + nextRules, + nextGroups, + nextExecutions, + nextContacts, + nextMonitored, + nextMonitoredGroups + ] = await Promise.all([ + automationApi.getStatus(), + automationApi.listRules(), + automationApi.listGroups(), + automationApi.listExecutions(100), + // 「指定好友」候选、已监控群聊数量与清单:都来自既有能力,不在自动化里另存一份。 + automationApi.listSendableContacts(), + automationApi.getMonitoredGroupCount(), + automationApi.getMonitoredGroups() + ]) setStatus(nextStatus) setRules(nextRules) setGroups(nextGroups) setExecutions(nextExecutions) setSendableContacts(nextContacts) setMonitoredGroupCount(nextMonitored) + setMonitoredGroups(nextMonitoredGroups) }, []) React.useEffect(() => { @@ -195,15 +211,19 @@ export function AutomationWorkspace({ }, [rules]) /* - * ---------- 定时日报(只读)---------- + * ---------- 定时日报(真实规则,真写入)---------- * - * 数据是**只读**拿到的真实任务;所有写操作(保存 / 立即执行 / 启停 / 删除) - * 一律不落盘、不调 scheduler、不生成日报。详见 `model/scheduled-report-preview.ts`。 + * `scheduled_report` 是真实的 `AutomationRuleType`,所以这一层 + * **不做任何数据副本**:列表 = `rules.filter(ruleType==='scheduled_report')`, + * 保存 / 启停 / 删除 / 立即执行全部经 `automation:*` IPC 落到 + * `AutomationRuleStore` 与 `AutomationService`。 */ - const [scheduledTasks, setScheduledTasks] = React.useState([]) - const [scheduledLoading, setScheduledLoading] = React.useState(true) - /** `null` = 看列表;非空 = 在编辑器里(`taskId` 为 `null` 表示新建)。 */ - const [scheduledEditor, setScheduledEditor] = React.useState<{ taskId: string | null } | null>( + const scheduledRules = React.useMemo( + () => rules.filter((rule) => rule.ruleType === 'scheduled_report'), + [rules] + ) + /** `null` = 看列表;非空 = 在编辑器里(`ruleId` 为 `null` 表示新建)。 */ + const [scheduledEditor, setScheduledEditor] = React.useState<{ ruleId: string | null } | null>( null ) const [scheduledDraft, setScheduledDraft] = React.useState(null) @@ -218,47 +238,35 @@ export function AutomationWorkspace({ return dailyReportTarget?.name ?? '新建自动化' }, [editor, leaveNotificationRule, dailyReportTarget, scheduledDraft]) - React.useEffect(() => { - let disposed = false - setScheduledLoading(true) - void loadScheduledReportTasks() - .then((tasks) => { - if (!disposed) setScheduledTasks(tasks) - }) - .finally(() => { - if (!disposed) setScheduledLoading(false) - }) - return () => { - disposed = true - } - }, []) - - /** 首页汇总卡:数量 + 最近一次执行时间(加载中先不显示,避免闪一个"0 条")。 */ + /** + * 首页汇总卡:数量 / 运行中 / 最近一次「下次执行」。 + * 数据直接来自真实规则 —— 加载中不显示(避免闪一个假的"0 条")。 + */ const scheduledSummary = React.useMemo(() => { - if (scheduledLoading) return null - const running = scheduledTasks.filter((task) => task.enabled) - const nextRunAt = running - .map((task) => task.nextRunAt) - .filter((value) => Boolean(value)) - .sort()[0] + if (loading) return null + const now = new Date() + const running = scheduledRules.filter((rule) => rule.enabled) + const nextRuns = running + .map((rule) => normalizeScheduledReportConfig(rule.scheduledReport)) + .filter((config) => !config.targetNeedsReview) + .map((config) => calculateNextRunAt(config.schedule.time, now)) + .sort() return { - total: scheduledTasks.length, + total: scheduledRules.length, running: running.length, - nextRunLabel: nextRunAt ? formatNextRunAt(nextRunAt) : '—' + nextRunLabel: nextRuns.length ? formatNextRunAt(nextRuns[0]) : '—' } - }, [scheduledTasks, scheduledLoading]) + }, [scheduledRules, loading]) - /** 群标识 → 显示名(列表与预览共用,保证任何地方都不出现裸 id)。 */ + /** 群标识 → 显示名(列表与编辑器共用,保证任何地方都不出现裸 id)。 */ const resolveScheduledGroupDisplay = React.useCallback( (raw: string): string => resolveGroupDisplayName(raw, groups), [groups] ) - const PREVIEW_NOTICE = 'UI 预览模式,配置暂未保存' - - const openScheduledEditor = (task: ScheduledReportTask | null): void => { - setScheduledDraft(task ? createDraftFromTask(task) : createEmptyScheduledReportDraft()) - setScheduledEditor({ taskId: task?.id ?? null }) + const openScheduledEditor = (rule: AutomationRule | null): void => { + setScheduledDraft(rule ? createDraftFromRule(rule) : createEmptyScheduledReportDraft()) + setScheduledEditor({ ruleId: rule?.id ?? null }) } const closeScheduledEditor = (): void => { @@ -266,28 +274,77 @@ export function AutomationWorkspace({ setScheduledDraft(null) } - /** 保存:只提示,绝不 create/update。 */ - const handleScheduledSave = (): void => { - toast({ description: PREVIEW_NOTICE, duration: 3200 }) - closeScheduledEditor() - } - - /** 立即执行:不碰 scheduler / 生成 / 发送。 */ - const handleScheduledRunNow = (_task: ScheduledReportTask): void => { - toast({ description: 'UI 预览模式,不会执行真实日报任务', duration: 3200 }) - } - - /** 启停:只改本地列表,不写 store。 */ - const handleScheduledToggle = (task: ScheduledReportTask, enabled: boolean): void => { - setScheduledTasks((current) => - current.map((item) => (item.id === task.id ? { ...item, enabled } : item)) + /** 保存:**真的**创建 / 更新一条 `scheduled_report` 规则。 */ + const handleScheduledSave = async (draft: ScheduledReportDraft): Promise => { + if (!scheduledEditor) return + const target = scheduledEditor.ruleId + ? (scheduledRules.find((rule) => rule.id === scheduledEditor.ruleId) ?? null) + : null + // 编辑态但目标不存在 ⇒ 报错,绝不静默新建(与 @我日报同一条红线)。 + if (scheduledEditor.ruleId && !target) { + toast({ + description: '要编辑的定时日报已不存在,请返回列表重新打开', + variant: 'destructive', + duration: 3600 + }) + return + } + setSaving(true) + const payload = draftToAutomationRuleDraft(draft) + const saved = + scheduledEditor.ruleId && target + ? await automationApi.updateRule(target.id, payload) + : await automationApi.createRule(payload) + setSaving(false) + if (!saved) { + toast({ description: '保存失败,请稍后重试', variant: 'destructive', duration: 3200 }) + return + } + setRules((current) => + current.some((item) => item.id === saved.id) + ? current.map((item) => (item.id === saved.id ? saved : item)) + : [...current, saved] ) - toast({ description: PREVIEW_NOTICE, duration: 3200 }) + closeScheduledEditor() + toast({ + description: scheduledEditor.ruleId ? `已保存「${saved.name}」` : `已创建「${saved.name}」`, + duration: 2800 + }) + void automationApi.getStatus().then(setStatus) } - /** 删除:直接提示,避免用户以为任务真被删了。 */ - const handleScheduledDelete = (_task: ScheduledReportTask): void => { - toast({ description: 'UI 预览模式,不会删除现有任务', duration: 3200 }) + /** 立即执行:走与 scheduler 完全相同的执行链路(manual trigger)。 */ + const handleScheduledRunNow = async (rule: AutomationRule): Promise => { + setBusyRuleId(rule.id) + const result = await automationApi.runScheduledReportRule(rule.id) + setBusyRuleId(null) + toast({ + description: result.success + ? `「${rule.name}」已执行完成` + : result.error || '定时日报执行失败,请查看执行日志', + variant: result.success ? undefined : 'destructive', + duration: 3600 + }) + // 执行结果落在 Automation Execution Log,切到日志 tab 就能看到。 + void reload() + } + + /** 启停:真写 store。 */ + const handleScheduledToggle = async (rule: AutomationRule, enabled: boolean): Promise => { + setBusyRuleId(rule.id) + const updated = await automationApi.setRuleEnabled(rule.id, enabled) + setBusyRuleId(null) + if (!updated) { + toast({ description: '切换失败,规则状态未改变', variant: 'destructive', duration: 3200 }) + return + } + setRules((current) => current.map((item) => (item.id === updated.id ? updated : item))) + void automationApi.getStatus().then(setStatus) + } + + /** 删除:走统一的确认弹层(与其它规则类型一致)。 */ + const handleScheduledDelete = (rule: AutomationRule): void => { + setPendingDelete(rule) } const handleToggle = async (rule: AutomationRule, enabled: boolean): Promise => { @@ -563,27 +620,45 @@ export function AutomationWorkspace({ ) : editor.ruleType === 'scheduled_report' ? ( scheduledEditor && scheduledDraft ? ( void handleScheduledSave(draft)} {...(onOpenModelSettings ? { onOpenModelSettings } : {})} + {...(scheduledEditor.ruleId + ? (() => { + const rule = scheduledRules.find( + (item) => item.id === scheduledEditor.ruleId + ) + const legacy = rule?.scheduledReport?.legacyTarget + return legacy ? { legacyTarget: legacy } : {} + })() + : {})} + notificationSlot={ + item.id === scheduledEditor.ruleId) ?? null} + {...(onOpenAgentHub ? { onOpenAgentHub } : {})} + /> + } /> ) : ( setEditor(null)} onCreate={() => openScheduledEditor(null)} onEdit={openScheduledEditor} - onRunNow={handleScheduledRunNow} - onToggle={handleScheduledToggle} + onRunNow={(rule) => void handleScheduledRunNow(rule)} + onToggle={(rule, enabled) => void handleScheduledToggle(rule, enabled)} onDelete={handleScheduledDelete} /> ) @@ -592,11 +667,12 @@ export function AutomationWorkspace({ rule={leaveNotificationRule} contacts={sendableContacts} monitoredCount={monitoredGroupCount} + monitoredGroups={monitoredGroups} saving={saving} {...(capabilityReady ? {} : { - // §「规则仍然可以保存」:只提示,不阻断编辑。 + // 只提示,不阻断编辑。 sendCapabilityWarning: '当前发送能力未就绪:退群事件仍会被记录,但通知发送会失败。' })} diff --git a/src/renderer/src/features/automation/LeaveNotificationEditor.tsx b/src/renderer/src/features/automation/LeaveNotificationEditor.tsx index 8251b7f..51376bc 100644 --- a/src/renderer/src/features/automation/LeaveNotificationEditor.tsx +++ b/src/renderer/src/features/automation/LeaveNotificationEditor.tsx @@ -1,14 +1,17 @@ import * as React from 'react' -import { Button, RadioGroup, RadioGroupItem, Switch } from '../../components/ui' +import { Button, Input, RadioGroup, RadioGroupItem, Switch } from '../../components/ui' import { LEAVE_NOTIFICATION_TARGET_OPTIONS, - createDefaultLeaveNotificationRule, + describeLeaveNotificationNotifyScope, + normalizeLeaveNotificationConfig, type AutomationRule, type LeaveNotificationConfig, + type LeaveNotificationNotifyScope, type LeaveNotificationTargetType } from '../../../../shared/automation' import { insertGroupNamePlaceholder } from '../../../../shared/group-exit-monitor' import { LeaveNotificationPreview } from './LeaveNotificationPreview' +import type { AutomationGroupOption } from './model/api' import { LeaveNotificationTargetPicker, type LeaveNotificationContactOption @@ -21,9 +24,12 @@ import { LeaveNotificationTemplateEditor } from './LeaveNotificationTemplateEdit * 这是**真实配置页**:保存会落盘成一条 singleton 规则(`builtin-leave-notification`), * 由 `AutomationService.handleGroupExit` 在退群事件到来时执行。 * - * 职责边界在 UI 上一眼可见: - * 退群监控 = 监测哪些群有人退出(**范围完全由它管**);自动化 = 检测到之后发到哪。 - * 所以这里**没有**「触发范围」二次筛选,规则级启停也**只有**顶部一处。 + * 职责边界在 UI 上一眼可见,是**两层**而不是一层: + * 退群监控 = 监测哪些群有人退出(范围由它管); + * 本规则 = ① 这批群里**哪些要通知**(二次勾选);② 通知**发到哪**。 + * + * ① 就是旧版「退群监控 → 通知群聊」的那份逐群勾选。它曾在自动化迁移里被压成单值目标而丢失, + * 导致"规则一启用就全量通知";`notifyScope` 把它恢复回来,所以这里**有**勾选控件。 */ export interface LeaveNotificationSaveInput { @@ -38,6 +44,8 @@ export interface LeaveNotificationEditorProps { contacts: LeaveNotificationContactOption[] /** 真实已监控群聊数量(来自退群监控,自动化不维护副本)。 */ monitoredCount: number + /** 真实已监控群聊清单 —— 二次勾选的候选项,与 `monitoredCount` 同一次取数。 */ + monitoredGroups: AutomationGroupOption[] saving: boolean /** 发送能力不完整时的一句话提示;为 undefined 表示能力正常。 */ sendCapabilityWarning?: string @@ -47,20 +55,22 @@ export interface LeaveNotificationEditorProps { onSave: (input: LeaveNotificationSaveInput) => void } +/** + * 草稿初值。 + * + * 一律过一遍 `normalizeLeaveNotificationConfig`:它本来就负责"补齐缺失字段 + + * 清掉互相矛盾的配置",所以契约新增字段(例如 `notifyScope`)时这里不会漏。 + * 手拼字面量就会漏。 + */ function initialConfig(rule: AutomationRule): LeaveNotificationConfig { - return ( - rule.leaveNotification ?? - createDefaultLeaveNotificationRule(Date.now()).leaveNotification ?? { - target: { type: 'file_transfer' }, - template: '' - } - ) + return normalizeLeaveNotificationConfig(rule.leaveNotification) } export function LeaveNotificationEditor({ rule, contacts, monitoredCount, + monitoredGroups, saving, sendCapabilityWarning, onOpenMonitoredGroups, @@ -69,11 +79,13 @@ export function LeaveNotificationEditor({ }: LeaveNotificationEditorProps): React.ReactElement { const [enabled, setEnabled] = React.useState(rule.enabled) const [config, setConfig] = React.useState(() => initialConfig(rule)) + const [groupFilter, setGroupFilter] = React.useState('') // 切换编辑对象 / 保存后回填时重建草稿,避免把上一份改动带过来。 React.useEffect(() => { setEnabled(rule.enabled) setConfig(initialConfig(rule)) + setGroupFilter('') }, [rule]) const targetType = config.target.type @@ -96,6 +108,21 @@ export function LeaveNotificationEditor({ const missingGroupName = targetType !== 'source_chat' && !config.template.includes('{groupName}') + /** 通知范围(二次勾选)。`config.notifyScope` 是唯一权威,这里只是取短名。 */ + const notifyScope: LeaveNotificationNotifyScope = + config.notifyScope === 'selected' ? 'selected' : 'all' + + const selectedNotifyIds = React.useMemo( + () => new Set(config.notifyRoomIds), + [config.notifyRoomIds] + ) + + const visibleMonitoredGroups = React.useMemo(() => { + const keyword = groupFilter.trim().toLowerCase() + if (!keyword) return monitoredGroups + return monitoredGroups.filter((group) => group.name.toLowerCase().includes(keyword)) + }, [monitoredGroups, groupFilter]) + const selectTarget = (next: LeaveNotificationTargetType): void => { setConfig((current) => ({ ...current, @@ -105,8 +132,40 @@ export function LeaveNotificationEditor({ })) } + const selectNotifyScope = (next: LeaveNotificationNotifyScope): void => { + setConfig((current) => ({ ...current, notifyScope: next })) + } + + const toggleNotifyRoom = (roomId: string): void => { + setConfig((current) => { + const chosen = new Set(current.notifyRoomIds) + if (chosen.has(roomId)) chosen.delete(roomId) + else chosen.add(roomId) + // 按候选项顺序落盘,勾选顺序不影响存下来的结果。 + const ordered = monitoredGroups + .map((group) => group.id) + .filter((id) => chosen.has(id)) + // 候选项还没加载出来时保留原样,避免把用户的勾选静默清空。 + return { + ...current, + notifyRoomIds: monitoredGroups.length ? ordered : current.notifyRoomIds + } + }) + } + const handleSave = (): void => { - onSave({ enabled, config: { ...config, targetNeedsReview: undefined } }) + onSave({ + enabled, + config: { + ...config, + // 勾选集收敛到「当前已监控」这一集合内:否则监控范围缩小之后, + // 会留下一批界面上看不见、且永远不可能命中的幽灵勾选。 + notifyRoomIds: monitoredGroups.length + ? monitoredGroups.map((group) => group.id).filter((id) => selectedNotifyIds.has(id)) + : config.notifyRoomIds, + targetNeedsReview: undefined + } + }) } return ( @@ -172,9 +231,8 @@ export function LeaveNotificationEditor({
{/* - 只读摘要 + 唯一入口。 - 「哪些群被监控」完全由退群监控决定,本规则不做二次筛选 —— - 所以这里不给任何勾选控件,只把当前范围如实说出来。 + 第一层:只读的监控范围摘要 + 唯一入口。 + 「哪些群被监控」完全由退群监控决定,本规则不重复维护一份。 */}
@@ -191,6 +249,100 @@ export function LeaveNotificationEditor({ 管理监控群聊 →
+ + {/* + 第二层:在上面这批已监控群聊里**二次勾选**哪些要通知。 + 这就是旧版「退群监控 → 通知群聊」的那份逐群勾选 —— + 没有它,规则一启用就等于给全部已监控群聊发通知。 + */} +
+
+ 通知范围 + + selectNotifyScope(value as LeaveNotificationNotifyScope) + } + aria-label="通知范围" + className="automation-leave-scope-options" + > +
+ +
+ + 任一被监控的群有人退出都发通知。 +
+
+
+ +
+ + 只对下面勾选的群发通知。 +
+
+
+ + 当前覆盖:{describeLeaveNotificationNotifyScope(config, monitoredCount)} + +
+
+ + {notifyScope === 'selected' ? ( + monitoredGroups.length ? ( +
+
+ 勾选要通知的群聊 + + {selectedNotifyIds.size ? `已选 ${selectedNotifyIds.size} 个` : '尚未勾选'} + +
+ setGroupFilter(event.target.value)} + placeholder="搜索群聊" + aria-label="搜索群聊" + /> +
+ {visibleMonitoredGroups.length === 0 ? ( +

没有匹配「{groupFilter}」的群聊

+ ) : ( + visibleMonitoredGroups.map((group) => { + const checked = selectedNotifyIds.has(group.id) + return ( + + ) + }) + )} +
+ {selectedNotifyIds.size === 0 ? ( +

+ + 一个群都没勾选,这条规则不会发送任何通知。 +

+ ) : null} +
+ ) : ( +

+ + 还没有设置监控范围。请先到「退群监控 → 管理群聊」选择要监控的群, + 再回来勾选通知范围。 +

+ ) + ) : null}
diff --git a/src/renderer/src/features/automation/LeaveNotificationPreview.tsx b/src/renderer/src/features/automation/LeaveNotificationPreview.tsx index 38b40a2..50e4a50 100644 --- a/src/renderer/src/features/automation/LeaveNotificationPreview.tsx +++ b/src/renderer/src/features/automation/LeaveNotificationPreview.tsx @@ -1,5 +1,6 @@ import * as React from 'react' import { + describeLeaveNotificationNotifyScope, describeMonitoredScope, leaveNotificationTargetLabel, type LeaveNotificationConfig @@ -99,6 +100,10 @@ export function LeaveNotificationPreview({
监控来源
{describeMonitoredScope(monitoredCount)}
+
+
通知范围
+
{describeLeaveNotificationNotifyScope(config, monitoredCount)}
+
通知目标
{targetSummary}
diff --git a/src/renderer/src/features/automation/RuleListPanel.tsx b/src/renderer/src/features/automation/RuleListPanel.tsx index c0ec88e..88dd16b 100644 --- a/src/renderer/src/features/automation/RuleListPanel.tsx +++ b/src/renderer/src/features/automation/RuleListPanel.tsx @@ -2,8 +2,8 @@ import * as React from 'react' import { AUTOMATION_RULE_TEMPLATES, BUILTIN_LEAVE_NOTIFICATION_RULE_ID, + describeLeaveNotificationNotifyScope, describeLeaveNotificationTarget, - describeMonitoredScope, describeRuleActions, describeRuleTrigger, type AutomationRule @@ -192,8 +192,13 @@ export function RuleListPanel({
{describeRuleTrigger(leaveRule)}
-
范围
-
{describeMonitoredScope(leaveNotification?.monitoredCount ?? 0)}
+
通知范围
+
+ {describeLeaveNotificationNotifyScope( + leaveRule.leaveNotification, + leaveNotification?.monitoredCount ?? 0 + )} +
动作
diff --git a/src/renderer/src/features/automation/ScheduledReportEditor.tsx b/src/renderer/src/features/automation/ScheduledReportEditor.tsx index 481b9d4..3a6cd27 100644 --- a/src/renderer/src/features/automation/ScheduledReportEditor.tsx +++ b/src/renderer/src/features/automation/ScheduledReportEditor.tsx @@ -14,7 +14,9 @@ import { } from '../../components/ui' import { MessageTypeSelector } from '../../components/reports/MessageTypeSelector' import { + DEFAULT_REPORT_IMAGE_POSTFIX_TEXT, SCHEDULED_REPORT_MEMBER_NAME_OPTIONS, + SCHEDULED_REPORT_POSTFIX_MAX_LENGTH, SCHEDULED_REPORT_RANGE_OPTIONS, SCHEDULED_REPORT_TARGET_OPTIONS, SCHEDULED_REPORT_TEMPLATE_SELECT_OPTIONS, @@ -24,9 +26,8 @@ import { resolveGroupDisplayName, type ScheduledReportDraft, type ScheduledReportTargetType -} from './model/scheduled-report-preview' +} from './model/scheduled-report-model' import { LeaveNotificationTargetPicker } from './LeaveNotificationTargetPicker' -import { ScheduledReportPreview } from './ScheduledReportPreview' /** * ScheduledReportEditor —— 「自动化 → 规则 → 定时日报」的编辑器。 @@ -36,7 +37,7 @@ import { ScheduledReportPreview } from './ScheduledReportPreview' * Section 2 承载完整的日报配置(纳入的消息类型 / 日报内容 / 模型配置 / * 日报模板 / 成员名称 / 生成超时),字段全部来自真实模型 —— 不发明,也不删减。 * - * 草稿只存在 renderer local state,保存不落盘(由父层提示)。 + * 草稿仍然是 renderer local state,但**保存是真的落盘**(父层调 `automation:*`)。 */ export interface ScheduledReportEditorProps { @@ -52,6 +53,15 @@ export interface ScheduledReportEditorProps { onSave: (draft: ScheduledReportDraft) => void /** 复用现有的模型配置入口。 */ onOpenModelSettings?: () => void + /** + * 迁移遗留的旧目标原文(只作展示)。 + * + * 有值时说明这条规则是迁移过来的、当时的目标无法自动对应 —— + * 界面必须让用户**看见原来的目标是什么**再重选。 + */ + legacyTarget?: string + /** 微信异常通知的入口(底部一块,与退群通知同一套开关组件)。 */ + notificationSlot?: React.ReactNode } export function ScheduledReportEditor({ @@ -62,7 +72,9 @@ export function ScheduledReportEditor({ onBack, onCancel, onSave, - onOpenModelSettings + onOpenModelSettings, + legacyTarget, + notificationSlot }: ScheduledReportEditorProps): React.ReactElement { const [draft, setDraft] = React.useState(initialDraft) const [groupFilter, setGroupFilter] = React.useState('') @@ -101,23 +113,20 @@ export function ScheduledReportEditor({ } /* - * 候选群 = 自动化现有的群列表 **并上** 当前任务已在用的来源群。 + * 候选群 = 自动化现有的群列表 **并上** 当前规则已在用的来源群。 * - * 任务里存的群标识与群列表是两个数据源,不保证一致;不并的话, - * 编辑已有任务会出现"当前来源群在列表里找不到、于是没有任何一项被选中"。 + * ⚠️ `value` 一律是**稳定会话 id**(`xxx@chatroom`),不是群名 —— + * 群名会改,把群名当 id 存下来的规则改一次名就发错群。 + * 迁移过来的老规则里可能仍是群名 / md5,这时作为"额外候选项"补进列表, + * 界面上用可读名展示,**绝不把裸标识印给用户**。 */ const groupOptions = React.useMemo(() => { - const known = new Set(groups.map((group) => group.name)) - // 去重:不去重会生成两条同 id 的候选项,React 重复 key 会让渲染错乱。 + const known = new Set(groups.map((group) => group.id)) const extras = Array.from( - new Set( - [initialDraft.group, initialDraft.target].filter( - (value) => Boolean(value) && !known.has(value) - ) - ) - ).map((value) => ({ id: `current:${value}`, name: value })) + new Set([initialDraft.group].filter((value) => Boolean(value) && !known.has(value))) + ).map((value) => ({ id: value, name: resolveGroupDisplayName(value, groups) })) return [...groups, ...extras] - }, [groups, initialDraft.group, initialDraft.target]) + }, [groups, initialDraft.group]) const visibleGroups = React.useMemo(() => { const needle = groupFilter.trim().toLowerCase() @@ -128,14 +137,24 @@ export function ScheduledReportEditor({ ) }, [groupOptions, groupFilter]) - const selectedContactName = - sendableContacts.find((contact) => contact.id === draft.targetContactId)?.name ?? '' + /** + * 保存前置校验。 + * + * 不允许存一条跑不起来的规则:没有名称 / 没有来源群 / 选了「指定好友」 + * 却没选人,这三种在运行期必然失败,与其让用户过几天在日志里发现,不如现在说清。 + */ + const saveBlocker = !draft.name.trim() + ? '请填写任务名称' + : !draft.group + ? '请选择日报来源群' + : draft.targetType === 'contact' && !draft.targetContactId + ? '请选择要发送的指定好友' + : '' const selectTargetType = (next: ScheduledReportTargetType): void => { - // 「发送到日报来源群」的目标跟随来源群;其余目标清空,避免看起来已经选好了。 + // 切换目标类型时清空已选联系人,避免看起来"已经选好了"却发不出去。 patch({ targetType: next, - target: next === 'source_chat' ? draft.group : '', targetContactId: next === 'contact' ? draft.targetContactId : '' }) } @@ -163,12 +182,29 @@ export function ScheduledReportEditor({ -
+ {saveBlocker ? ( +

+ {saveBlocker} +

+ ) : null} + + {legacyTarget ? ( +

+ 这条规则是升级前创建的,当时的发送目标({legacyTarget})无法自动对应到新的选项。 + 请重新选择发送目标后再保存。 +

+ ) : null} +
@@ -244,12 +280,7 @@ export function ScheduledReportEditor({ 生成哪个群的日报。 - patch({ - group: value, - target: draft.targetType === 'source_chat' ? value : draft.target - }) - } + onValueChange={(value) => patch({ group: value })} aria-label="日报来源" className="automation-leave-contact-picker" > @@ -265,7 +296,7 @@ export function ScheduledReportEditor({ ) : ( visibleGroups.map((group) => (
- +
)) @@ -421,6 +452,20 @@ export function ScheduledReportEditor({
) : null} + {/* 后置词:从「手动发送日报图片」那个输入框原样搬运过来,只是改成按规则存。 */} +
+ 发送后置词 + patch({ postfixText: event.target.value })} + /> + 图片确认发送成功后,会发送一条文本消息;留空则只发图片。 +
+

微信数据库和聊天记录默认从本机读取。所选内容将发送至你配置的模型服务进行处理, TraceMemo 本身不额外保存或转发内容。 @@ -428,11 +473,9 @@ export function ScheduledReportEditor({

- +
) diff --git a/src/renderer/src/features/automation/ScheduledReportNotificationPanel.tsx b/src/renderer/src/features/automation/ScheduledReportNotificationPanel.tsx new file mode 100644 index 0000000..005bf3d --- /dev/null +++ b/src/renderer/src/features/automation/ScheduledReportNotificationPanel.tsx @@ -0,0 +1,150 @@ +import * as React from 'react' + +import { Button, Switch, useToast } from '../../components/ui' +import type { + ScheduledReportNotificationCapability, + ScheduledReportNotificationSettings +} from '../../../../shared/scheduled-report' +import type { AutomationRule } from '../../../../shared/automation' +import { automationApi } from './model/api' + +/** + * ScheduledReportNotificationPanel —— **微信异常通知**的入口。 + * + * 这是「定时日报」功能的一项**全局能力**(不是某条规则的配置): + * 定时任务在生成 / 发送失败时,经 Agent Hub 给用户推一条微信。 + * + * 迁移前它挂在旧「日报 → 定时日报」页面上;那个页面退役后,入口挪到这里 —— + * 能力本身一行没改,绝不因为删页面而静默丢掉。 + */ + +export interface ScheduledReportNotificationPanelProps { + /** 当前正在编辑的规则(用于「发送测试错误通知」;新建时可传 null)。 */ + rule: AutomationRule | null + /** 异常通知依赖 Agent Hub:未就绪时给一个可操作的去处。 */ + onOpenAgentHub?: () => void +} + +export function ScheduledReportNotificationPanel({ + rule, + onOpenAgentHub +}: ScheduledReportNotificationPanelProps): React.ReactElement { + const { toast } = useToast() + const [settings, setSettings] = React.useState({ + enabled: false + }) + const [capability, setCapability] = React.useState({ + ready: false + }) + const [loading, setLoading] = React.useState(true) + const [saving, setSaving] = React.useState(false) + const [testing, setTesting] = React.useState(false) + + const load = React.useCallback(async (): Promise => { + const [nextSettings, nextCapability] = await Promise.all([ + automationApi.getScheduledReportNotificationSettings(), + automationApi.getScheduledReportNotificationCapability() + ]) + setSettings(nextSettings) + setCapability(nextCapability) + }, []) + + React.useEffect(() => { + let disposed = false + setLoading(true) + void load().finally(() => { + if (!disposed) setLoading(false) + }) + return () => { + disposed = true + } + }, [load]) + + const handleToggle = async (enabled: boolean): Promise => { + setSaving(true) + const result = await automationApi.setScheduledReportNotificationEnabled(enabled) + setSaving(false) + if (!result.success) { + // 开启失败时 main 侧会带出真实原因(Agent Hub 未连接 / 未绑定接收者 / 测试发送失败)。 + toast({ + description: result.error || '异常通知设置失败', + variant: 'destructive', + duration: 4000 + }) + await load() + return + } + setSettings(result.data) + toast({ + description: result.data.enabled ? '已开启微信异常通知' : '已关闭微信异常通知', + duration: 2800 + }) + } + + const handleTest = async (): Promise => { + if (!rule) { + toast({ description: '请先保存这条定时日报,再发送测试通知', variant: 'destructive', duration: 3200 }) + return + } + setTesting(true) + const result = await automationApi.testScheduledReportErrorNotification(rule.id) + setTesting(false) + toast({ + description: result.success ? '测试通知已发送,请查看微信' : result.error || '测试通知发送失败', + variant: result.success ? undefined : 'destructive', + duration: 4000 + }) + } + + const capabilityHint = capability.ready + ? capability.recipient + ? `通知将发送给:${capability.recipient}` + : '通知接收者已就绪' + : capability.error || '异常通知能力尚未就绪。' + + return ( +
+
+

微信异常通知

+
+

+ 定时日报生成或发送失败时,通过 Agent Hub 给你发一条微信。关闭后不会再推送。 +

+ +
+
+ 开启异常通知 + {capabilityHint} + {!capability.ready && onOpenAgentHub ? ( + + ) : null} +
+ void handleToggle(checked)} + aria-label="开启微信异常通知" + /> +
+ + {settings.enabled ? ( +
+
+ 发送测试错误通知 + 验证 Agent Hub 推送链路是否通畅。 +
+ +
+ ) : null} +
+ ) +} diff --git a/src/renderer/src/features/automation/ScheduledReportPreview.tsx b/src/renderer/src/features/automation/ScheduledReportPreview.tsx deleted file mode 100644 index 93c56f2..0000000 --- a/src/renderer/src/features/automation/ScheduledReportPreview.tsx +++ /dev/null @@ -1,153 +0,0 @@ -import * as React from 'react' - -import { SUMMARY_TYPE_OPTIONS } from '../../utils/group-report' -import { - formatScheduledSlot, - scheduledReportRangeLabel, - scheduledReportTemplateLabel, - type ScheduledReportDraft, - type ScheduledReportTargetType -} from './model/scheduled-report-preview' - -/** - * ScheduledReportPreview —— 右侧「效果预览」。 - * - * 与左侧表单逐项联动,**绝不执行任何真实动作**: - * 不调 scheduler、不生成日报、不发送、不碰 store。 - * - * 定时日报用「流程」表达比微信气泡准确:一次执行是 - * 定时触发 → 生成日报 → 模型 → 模板 → 发送 的链条,而不是一段对话。 - */ - -export interface ScheduledReportPreviewProps { - draft: ScheduledReportDraft - /** 来源群的**显示名**(草稿里存的可能是内部标识,展示前已在调用方解析)。 */ - groupLabel: string - /** 指定好友的显示名。 */ - contactName: string -} - -export function ScheduledReportPreview({ - draft, - groupLabel, - contactName -}: ScheduledReportPreviewProps): React.ReactElement { - const groupText = groupLabel.trim() || '未选择群聊' - - /** 发送目标的显示文案(四种类型各取真实语义,不回落成内部 id)。 */ - const targetText = React.useMemo((): string => { - const labels: Record = { - source_chat: groupText, - self: '我', - file_transfer: '文件传输助手', - contact: contactName.trim() || '未选择好友' - } - return labels[draft.targetType] - }, [draft.targetType, groupText, contactName]) - - /** - * 纳入的消息类型只做**摘要**:左侧才是详细配置, - * 右侧逐条列出来会把预览拉得很长。 - */ - const messageTypesSummary = React.useMemo((): string => { - if (draft.messageTypes.length === 0) return '未选择' - if (draft.messageTypes.length === SUMMARY_TYPE_OPTIONS.length) return '全部类型' - return draft.messageTypes - .map((value) => SUMMARY_TYPE_OPTIONS.find((option) => option.value === value)?.label ?? value) - .join('、') - }, [draft.messageTypes]) - - return ( -
-
-

效果预览

- 仅预览,不会真实执行 -
- -
    -
  1. - {draft.scheduleTime} - 定时任务触发 - 每天 {draft.scheduleTime} -
  2. - - - -
  3. - 生成日报 - 来源:{groupText} - 范围:{scheduledReportRangeLabel(draft.reportRange)} - 纳入消息:{messageTypesSummary} -
  4. - - - -
  5. - 模型 - 默认日报模型 -
  6. - - - -
  7. - 模板 - {scheduledReportTemplateLabel(draft.templateId)} -
  8. - - - -
  9. - 发送到 - {targetText} -
  10. -
- - {/* 日报图片的形状示意(不是真实截图,也不触发生成)。 */} -
- ) -} diff --git a/src/renderer/src/features/automation/ScheduledReportRuleList.tsx b/src/renderer/src/features/automation/ScheduledReportRuleList.tsx index 3f61fc4..3a9ce82 100644 --- a/src/renderer/src/features/automation/ScheduledReportRuleList.tsx +++ b/src/renderer/src/features/automation/ScheduledReportRuleList.tsx @@ -1,12 +1,17 @@ import * as React from 'react' import { Button, Spinner, Switch } from '../../components/ui' -import type { ScheduledReportTask } from '../../../../shared/scheduled-report' +import { + calculateNextRunAt, + normalizeScheduledReportConfig, + type AutomationRule +} from '../../../../shared/automation' import { formatNextRunAt, scheduledReportRangeLabel, + scheduledReportTargetDisplayName, scheduledReportTemplateLabel -} from './model/scheduled-report-preview' +} from './model/scheduled-report-model' /** * ScheduledReportRuleList —— 「自动化 → 规则 → 定时日报」的**规则列表**。 @@ -14,48 +19,59 @@ import { * 为什么这里是列表而不是直接进编辑器:定时日报是**多条**规则 * (与 singleton 的「退群通知」不同),所以必须先选一条,或新建一条。 * - * 所有写操作(立即执行 / 启停 / 删除)都不落盘,只提示。 - * 数据是**只读**拿到的真实任务(见 `model/scheduled-report-preview.ts`)。 + * 数据是 `AutomationRuleStore` 里的真实 `scheduled_report` 规则; + * 立即执行 / 启停 / 删除全部**真写入**(由父层调 `automation:*` IPC)。 + * + * `onBack` 是这一层**必须**有的逃生口:进了「定时日报」这一类型后,只有 + * 列表与编辑器两种形态,而列表本身没有「取消」——如果这里不给退出入口, + * 用户唯一的出路就是切到别的规则类型 tab,再从那边的「取消」绕回来。 */ export interface ScheduledReportRuleListProps { - tasks: ScheduledReportTask[] + rules: AutomationRule[] loading: boolean - /** 正在"操作中"的任务 id(仅用于视觉反馈,不产生真实副作用)。 */ - busyTaskId: string | null - /** - * 群标识 → 显示名。 - * - * 任务里存的可能是群名、room id 或 hash;列表**绝不能**把内部标识露给用户。 - */ + /** 正在"操作中"的规则 id(视觉反馈,防止重复点击)。 */ + busyRuleId: string | null + /** 群标识 → 显示名(**绝不**把内部标识露给用户)。 */ resolveGroupDisplay: (raw: string) => string + /** 退出本类型、回到规则面板(三个类型卡片那一层)。 */ + onBack?: () => void onCreate: () => void - onEdit: (task: ScheduledReportTask) => void - onRunNow: (task: ScheduledReportTask) => void - onToggle: (task: ScheduledReportTask, enabled: boolean) => void - onDelete: (task: ScheduledReportTask) => void + onEdit: (rule: AutomationRule) => void + onRunNow: (rule: AutomationRule) => void + onToggle: (rule: AutomationRule, enabled: boolean) => void + onDelete: (rule: AutomationRule) => void } export function ScheduledReportRuleList({ - tasks, + rules, loading, - busyTaskId, + busyRuleId, resolveGroupDisplay, + onBack, onCreate, onEdit, onRunNow, onToggle, onDelete }: ScheduledReportRuleListProps): React.ReactElement { - const runningCount = tasks.filter((task) => task.enabled).length + const runningCount = rules.filter((rule) => rule.enabled).length + const now = new Date() return (
+ {/* 没有 onBack 就不渲染死按钮:按钮点不动比没有按钮更糟。 */} + {onBack ? ( + + ) : null} +

定时日报

- {tasks.length > 0 ? `${tasks.length} 条规则` : '尚未配置'} + {rules.length > 0 ? `${rules.length} 条规则` : '尚未配置'}
@@ -65,9 +81,9 @@ export function ScheduledReportRuleList({
- {tasks.length > 0 ? ( + {rules.length > 0 ? ( - 运行中 {runningCount} / {tasks.length} + 运行中 {runningCount} / {rules.length} ) : null}
@@ -77,83 +93,106 @@ export function ScheduledReportRuleList({ 正在读取定时日报…
- ) : tasks.length === 0 ? ( + ) : rules.length === 0 ? (

还没有定时日报

创建一条自动化,让 TraceMemo 在指定时间生成并发送日报。

) : ( - tasks.map((task) => ( -
-
-
-

{task.name}

- {/* 状态只出现两处:这个 badge + 右侧 toggle,不做第三重表达。 */} - - {task.enabled ? '运行中' : '已暂停'} - + rules.map((rule) => { + const config = normalizeScheduledReportConfig(rule.scheduledReport) + const needsReview = config.targetNeedsReview === true + const busy = busyRuleId === rule.id + return ( +
+
+
+

{rule.name}

+ {/* 状态只出现两处:这个 badge + 右侧 toggle,不做第三重表达。 */} + + {rule.enabled ? '运行中' : '已暂停'} + +
+ {needsReview ? ( + // 迁移过来但目标无法无损映射:如实提示,绝不冒充"配置正常"。 +

+ 这条规则的发送目标需要重新选择(迁移自旧版本,原目标无法自动对应)。 + {config.legacyTarget ? ( + 原来的目标:{config.legacyTarget} + ) : null} +

+ ) : null} +
+
+
触发
+
每天 {config.schedule.time}
+
+
+
日报
+
{scheduledReportRangeLabel(config.report.range)}
+
+
+
来源
+
{resolveGroupDisplay(config.report.sourceConversationId) || '未设置'}
+
+
+
模板
+
{scheduledReportTemplateLabel(config.report.templateId)}
+
+
+
发送到
+
{scheduledReportTargetDisplayName(config, resolveGroupDisplay)}
+
+
+
下次执行
+
+ {rule.enabled && !needsReview + ? formatNextRunAt(calculateNextRunAt(config.schedule.time, now)) + : '—'} +
+
+
-
-
-
触发
-
每天 {task.scheduleTime}
-
-
-
日报
-
{scheduledReportRangeLabel(task.reportRange)}
-
-
-
来源
-
{resolveGroupDisplay(task.group) || '未设置'}
-
-
-
模板
-
{scheduledReportTemplateLabel(task.templateId)}
-
-
-
发送到
- {/* 旧任务的 target 恒等于来源群;这时用语义文案比重复群名清楚。 */} -
- {!task.target || task.target === task.group - ? '日报来源群' - : resolveGroupDisplay(task.target)} -
-
-
-
下次执行
-
{task.enabled ? formatNextRunAt(task.nextRunAt) : '—'}
-
-
-
-
- onToggle(task, checked)} - aria-label={`${task.name} 启停`} - /> - - - -
-
- )) +
+ onToggle(rule, checked)} + aria-label={`${rule.name} 启停`} + /> + + + +
+ + ) + }) )} + + {/* 消息驱动的规则不在这里维护;显式说明,避免用户以为它消失了。 */} +

+ 消息驱动的「@我生成日报」规则在上一个类型里维护;这里只列出时间驱动的定时日报。 +

) diff --git a/src/renderer/src/features/automation/model/api.ts b/src/renderer/src/features/automation/model/api.ts index d8c5137..d6b131f 100644 --- a/src/renderer/src/features/automation/model/api.ts +++ b/src/renderer/src/features/automation/model/api.ts @@ -2,8 +2,17 @@ import type { AutomationExecution, AutomationRule, AutomationRuleDraft, - AutomationStatusSummary + AutomationStatusSummary, + ScheduledRuleRunOutcome } from '../../../../../shared/automation' +import type { + ScheduledReportExecution, + ScheduledReportNotification, + ScheduledReportNotificationCapability, + ScheduledReportNotificationSettings, + ScheduledReportNotificationSettingsResult, + ScheduledReportResult +} from '../../../../../shared/scheduled-report' import type { GroupExitMonitorState } from '../../../../../shared/group-exit-monitor' /** @@ -110,7 +119,7 @@ export const automationApi = { /** * 真实已监控群聊数量。 * - * 数据源是**退群监控**,自动化不维护副本(§「Automation 不做二次 group filter」)。 + * 数据源是**退群监控**,自动化不维护副本(不做二次群过滤)。 * 取数与退群监控页保持同一表达式,否则两个页面会对同一个数字给出不同答案。 */ async getMonitoredGroupCount(): Promise { @@ -118,5 +127,73 @@ export const automationApi = { if (!state) return 0 if (state.monitorSelectionConfigured) return (state.monitoredRoomIds || []).length return Number(state.monitoredGroupCount) || 0 - } + }, + + /** + * 真实已监控群聊清单(含显示名),供退群通知做**二次勾选**。 + * + * 与 `getMonitoredGroupCount` **同一处取数**:清单和数字必须来自同一个快照, + * 否则会出现"卡片说 5 个群、勾选列表只有 3 个"这种自相矛盾的界面。 + * + * 名字从群列表里取(`listAutomationGroups`)。取不到的 roomId **保留原样**而不是丢掉 —— + * 那个群仍然被监控着,丢掉它等于悄悄缩小用户的范围。 + * + * 未显式设置监控范围时返回空数组:此时没有"被监控的群"这个集合, + * 二次筛选无从谈起,由调用方退化成"全部"并如实说明。 + */ + async getMonitoredGroups(): Promise { + const [state, groups] = await Promise.all([ + invoke('getGroupExitMonitorState', null), + invoke('listAutomationGroups', []) + ]) + if (!state?.monitorSelectionConfigured) return [] + const nameById = new Map(groups.map((group) => [group.id, group.name])) + return (state.monitoredRoomIds || []) + .map((roomId) => ({ id: roomId, name: nameById.get(roomId) || roomId })) + .sort((left, right) => left.name.localeCompare(right.name, 'zh-CN')) + }, + + /** 定时日报「立即执行」:与 scheduler 走同一条执行链路(manual trigger)。 */ + runScheduledReportRule: ( + ruleId: string + ): Promise<{ success: boolean; error?: string; data?: ScheduledRuleRunOutcome }> => + invoke<{ success: boolean; error?: string; data?: ScheduledRuleRunOutcome }>( + 'runScheduledReportRule', + { success: false, error: '定时日报执行接口尚未就绪' }, + ruleId + ), + + /** + * 旧执行记录**只读存档**。 + * + * 旧记录是 7 态 + stage 模型,新执行日志是 3 态 + 通用步骤 —— 两者无法无损互转, + * 所以历史记录原样保留、只读展示,不迁进新日志。 + */ + listScheduledReportLegacyExecutions: (ruleId?: string): Promise => + invoke('listScheduledReportLegacyExecutions', [], ruleId), + + /** 微信异常通知(随定时日报功能保留的全局能力)。 */ + getScheduledReportNotificationSettings: (): Promise => + invoke('getScheduledReportNotificationSettings', { enabled: false }), + + getScheduledReportNotificationCapability: (): Promise => + invoke('getScheduledReportNotificationCapability', { ready: false }), + + setScheduledReportNotificationEnabled: ( + enabled: boolean + ): Promise => + invoke( + 'setScheduledReportNotificationEnabled', + { success: false, data: { enabled: false }, error: '异常通知设置接口尚未就绪' }, + enabled + ), + + testScheduledReportErrorNotification: ( + ruleId: string + ): Promise> => + invoke>( + 'testScheduledReportErrorNotification', + { success: false, error: '调试接口尚未就绪' }, + ruleId + ) } diff --git a/src/renderer/src/features/automation/model/scheduled-report-model.ts b/src/renderer/src/features/automation/model/scheduled-report-model.ts new file mode 100644 index 0000000..8996075 --- /dev/null +++ b/src/renderer/src/features/automation/model/scheduled-report-model.ts @@ -0,0 +1,304 @@ +import { + DEFAULT_REPORT_TEMPLATE, + REPORT_TEMPLATES, + getReportTemplate, + type SelectableReportTemplateId +} from '../../../../../shared/report-templates' +import type { + ScheduledReportMemberNameMode, + ScheduledReportMessageType, + ScheduledReportRange +} from '../../../../../shared/scheduled-report' +import { + DEFAULT_REPORT_IMAGE_POSTFIX_TEXT +} from '../../../../../shared/personal-wechat' +import { + AUTOMATION_REPLY_DELAY_DEFAULT_SECONDS, + SCHEDULED_REPORT_DEFAULT_MEMBER_NAME_MODE, + SCHEDULED_REPORT_DEFAULT_RANGE, + SCHEDULED_REPORT_DEFAULT_TEMPLATE_ID, + SCHEDULED_REPORT_DEFAULT_TIMEOUT_SECONDS, + SCHEDULED_REPORT_DEFAULT_TARGET_TYPE, + SCHEDULED_REPORT_MEMBER_NAME_OPTIONS, + SCHEDULED_REPORT_POSTFIX_MAX_LENGTH, + SCHEDULED_REPORT_RANGE_LABELS, + SCHEDULED_REPORT_TARGET_OPTIONS, + normalizeScheduledReportConfig, + scheduledReportTargetLabel, + type AutomationRule, + type AutomationRuleDraft, + type ScheduledReportAutomationConfig, + type ScheduledReportTargetType +} from '../../../../../shared/automation' +import { type SummaryMessageType } from '../../../utils/group-report' + +/** + * 定时日报编辑器的**真实模型**(唯一一份)。 + * + * 与迁移前的 `scheduled-report-preview.ts` 的根本区别: + * 那时这一层只读旧 `ScheduledReportTask`、保存不落盘;现在 + * `scheduled_report` 已经是真实的 `AutomationRuleType`, + * 编辑器产出的草稿会经 `automation:*` IPC **真写入** `AutomationRuleStore`。 + * + * 所以这里只剩两类东西: + * 1. 草稿 ↔ `ScheduledReportAutomationConfig` 的双向映射; + * 2. 纯展示格式化函数(时间 / 范围 / 模板名)。 + * + * **不再有** `loadScheduledReportTasks()` 这类"只读预览数据层"。 + */ + +/** + * 编辑器草稿。 + * + * 字段与 `ScheduledReportAutomationConfig` 一一对应(外加 `name` / `enabled`), + * 唯一多出来的是 `targetContactId`:真实模型里它是 `target.contactId`, + * 摊平只是为了表单好用。 + */ +export interface ScheduledReportDraft { + name: string + enabled: boolean + /** 每天的执行时间 `HH:mm`。 */ + scheduleTime: string + /** 日报来源群:生成**哪个群**的日报(稳定会话 id)。 */ + group: string + reportRange: ScheduledReportRange + messageTypes: SummaryMessageType[] + memberNameMode: ScheduledReportMemberNameMode + timeoutSeconds: number + templateId: SelectableReportTemplateId + targetType: ScheduledReportTargetType + targetContactId: string + /** + * 日报图片发送成功后补发的那句话(「后置词」)。 + * + * 与手动发送日报图片里的后置词同一件事,但这里**按规则存**。 + * 空字符串 = 只发图片。 + */ + postfixText: string +} + +/** + * 新建规则时「纳入的消息类型」的默认值:**只勾选「文本」**。 + * + * 以前这里是全选(对齐迁移前的旧「定时日报」页面)。改成只选文本是刻意的产品口径: + * 日报默认只吃文本,想要图片 / 语音等再手动勾 —— 与日报页(今日日报)的默认值保持一致。 + * + * 存量规则**不受影响**:`createDraftFromRule` 读的是规则自己存的 `messageTypes`, + * 只有「新建」才会用到这个默认值。 + */ +export const SCHEDULED_REPORT_DEFAULT_MESSAGE_TYPES: SummaryMessageType[] = ['text'] + +/** 日报范围选项(文案来自共享层,避免同一份配置出现两种叫法)。 */ +export const SCHEDULED_REPORT_RANGE_OPTIONS: ReadonlyArray<{ + value: ScheduledReportRange + label: string +}> = (['today', 'yesterday', '7days', 'recent24h'] as ScheduledReportRange[]).map((value) => ({ + value, + label: SCHEDULED_REPORT_RANGE_LABELS[value] +})) + +/** 成员名称选项(共享层唯一来源)。 */ +export { SCHEDULED_REPORT_MEMBER_NAME_OPTIONS, SCHEDULED_REPORT_TARGET_OPTIONS } + +/** + * 后置词输入框的约束与占位文案。 + * + * 两个都从共享层转出,**刻意不让编辑器直接 import shared** —— + * 这样「手动发送」与「定时日报」两个入口的 200 字上限和占位句只有一份定义。 + */ +export { SCHEDULED_REPORT_POSTFIX_MAX_LENGTH, DEFAULT_REPORT_IMAGE_POSTFIX_TEXT } + +/** 发送目标四选一(共享层唯一来源)。 */ +export type { ScheduledReportTargetType } + +/** 模板选项(默认模板 + 社区模板)。 */ +export const SCHEDULED_REPORT_TEMPLATE_SELECT_OPTIONS: ReadonlyArray<{ + value: SelectableReportTemplateId + label: string +}> = [ + { value: DEFAULT_REPORT_TEMPLATE.id, label: DEFAULT_REPORT_TEMPLATE.name }, + ...REPORT_TEMPLATES.map((template) => ({ + value: template.id, + label: `${template.label} · ${template.name}` + })) +] + +/** 与旧页面同语义的范围文案。 */ +export function scheduledReportRangeLabel(range: ScheduledReportRange): string { + return SCHEDULED_REPORT_RANGE_LABELS[range] ?? SCHEDULED_REPORT_RANGE_LABELS.today +} + +/** 模板的展示名(列表 / 编辑器 / 预览必须是同一个称呼)。 */ +export function scheduledReportTemplateLabel(templateId?: string): string { + return getReportTemplate(templateId).name +} + +/** 发送目标的展示名(列表用;不再需要"target 是否等于 group"的猜测)。 */ +export function scheduledReportTargetDisplayName( + config: ScheduledReportAutomationConfig | undefined, + resolveGroupDisplay: (raw: string) => string +): string { + const target = config?.target + if (!target) return '' + if (target.type === 'source_chat') { + const group = resolveGroupDisplay(config.report.sourceConversationId) + return group ? `${scheduledReportTargetLabel('source_chat')}(${group})` : scheduledReportTargetLabel('source_chat') + } + if (target.type === 'contact') return scheduledReportTargetLabel('contact') + return scheduledReportTargetLabel(target.type) +} + +/** 新建草稿:目标默认「自己」,避免任何形式的"没选就发到群里"。 */ +export function createEmptyScheduledReportDraft(): ScheduledReportDraft { + return { + name: '', + enabled: true, + scheduleTime: '18:30', + group: '', + reportRange: SCHEDULED_REPORT_DEFAULT_RANGE, + messageTypes: [...SCHEDULED_REPORT_DEFAULT_MESSAGE_TYPES], + memberNameMode: SCHEDULED_REPORT_DEFAULT_MEMBER_NAME_MODE, + timeoutSeconds: SCHEDULED_REPORT_DEFAULT_TIMEOUT_SECONDS, + templateId: SCHEDULED_REPORT_DEFAULT_TEMPLATE_ID, + targetType: SCHEDULED_REPORT_DEFAULT_TARGET_TYPE, + targetContactId: '', + // 刻意留空:新建规则默认**不**补发任何文本,用户明确要才填。 + postfixText: '' + } +} + +/** 真实规则 → 编辑器草稿。 */ +export function createDraftFromRule(rule: AutomationRule): ScheduledReportDraft { + const config = normalizeScheduledReportConfig(rule.scheduledReport) + return { + name: rule.name, + enabled: rule.enabled, + scheduleTime: config.schedule.time, + group: config.report.sourceConversationId, + reportRange: config.report.range, + messageTypes: config.report.messageTypes.length + ? (config.report.messageTypes as SummaryMessageType[]) + : [...SCHEDULED_REPORT_DEFAULT_MESSAGE_TYPES], + memberNameMode: config.report.memberNameMode, + timeoutSeconds: config.report.timeoutSeconds, + templateId: config.report.templateId, + targetType: config.target.type, + targetContactId: config.target.contactId ?? '', + postfixText: config.postfixText ?? '' + } +} + +/** + * 草稿 → `AutomationRuleDraft`(真正落盘的那一份)。 + * + * 定时日报由**时间驱动**,不参与消息匹配;`trigger` / `scope` / `conditions` / + * `actions` / `cooldownSeconds` 这几个字段保留只是为了 schema 完整 + * (`matchAutomationRule` 对 `scheduled_report` 是硬分派,永不读它们)。 + * + * 显式清掉 `targetNeedsReview`:用户手动保存一次即代表"目标已确认"。 + * 旧的 `legacyTarget` / `legacySourceGroup` 也在这里丢弃 —— + * 它们只服务于"让用户看清原来配的是什么",保存之后就不再需要。 + */ +export function draftToAutomationRuleDraft(draft: ScheduledReportDraft): AutomationRuleDraft { + return { + name: draft.name.trim(), + enabled: draft.enabled, + ruleType: 'scheduled_report', + trigger: 'message', + scope: 'group', + conditions: { + requireMentionMe: false, + keyword: '', + keywordMatchMode: 'contains', + conversationIds: [], + ignoreSelf: true + }, + actions: [], + // 不套用消息型规则的 cooldown:定时规则有自己的 ruleId inFlight 锁。 + cooldownSeconds: 0, + replyDelaySeconds: AUTOMATION_REPLY_DELAY_DEFAULT_SECONDS, + scheduledReport: normalizeScheduledReportConfig({ + schedule: { time: draft.scheduleTime }, + report: { + sourceConversationId: draft.group, + range: draft.reportRange, + messageTypes: draft.messageTypes as ScheduledReportMessageType[], + templateId: draft.templateId, + memberNameMode: draft.memberNameMode, + timeoutSeconds: draft.timeoutSeconds + }, + target: { + type: draft.targetType, + ...(draft.targetType === 'contact' ? { contactId: draft.targetContactId } : {}) + }, + postfixText: draft.postfixText + }) + } +} + +/** + * 时间展示:`今日 18:30` / `明日 18:30`。 + * + * 只服务编辑器里的"预计下次执行"(草稿还没落盘,没有真实 `nextRunAt` 可读)。 + */ +export function formatScheduledSlot(scheduleTime: string, now = new Date()): string { + const [hour, minute] = parseScheduleTime(scheduleTime) + const slot = new Date(now) + slot.setSeconds(0, 0) + slot.setHours(hour, minute) + if (slot.getTime() <= now.getTime()) slot.setDate(slot.getDate() + 1) + const isToday = slot.toDateString() === now.toDateString() + return `${isToday ? '今日' : '明日'} ${pad2(hour)}:${pad2(minute)}` +} + +/** 把真实的 `nextRunAt`(ISO)格式化成旧页面同款文案。 */ +export function formatNextRunAt(value?: string): string { + if (!value) return '尚未执行' + const date = new Date(value) + if (Number.isNaN(date.getTime())) return '时间未知' + const now = new Date() + const dayLabel = + date.toDateString() === now.toDateString() ? '今日' : date.toLocaleDateString('zh-CN') + return `${dayLabel} ${date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}` +} + +export function parseScheduleTime(value: string): [number, number] { + const matched = /^(\d{1,2}):(\d{2})$/.exec(String(value ?? '').trim()) + if (!matched) return [18, 30] + const hour = Math.min(23, Math.max(0, Number(matched[1]))) + const minute = Math.min(59, Math.max(0, Number(matched[2]))) + return [hour, minute] +} + +export function composeScheduleTime(hour: number, minute: number): string { + return `${pad2(Math.min(23, Math.max(0, hour)))}:${pad2(Math.min(59, Math.max(0, minute)))}` +} + +/** + * 把规则里存的群标识解析成**能给用户看**的名字。 + * + * 迁移过来的老数据可能仍是会话 md5 / 群名 / roomId 三种形态之一; + * 直接渲染就会把内部标识暴露给用户。能对上群名就用群名; + * 明显是内部标识又对不上时显示「未知群聊」,**绝不显示裸 id**。 + */ +export function resolveGroupDisplayName( + raw: string, + groups: Array<{ id: string; name: string }> +): string { + const value = String(raw ?? '').trim() + if (!value) return '' + const matched = groups.find((group) => group.id === value) ?? groups.find((g) => g.name === value) + if (matched) return matched.name + return looksLikeInternalIdentifier(value) ? '未知群聊' : value +} + +/** 内部标识形态:room id / 16 进制 hash / 纯数字串。 */ +function looksLikeInternalIdentifier(value: string): boolean { + return ( + value.includes('@chatroom') || /^[0-9a-f]{16,}$/i.test(value) || /^\d{6,}$/.test(value) + ) +} + +function pad2(value: number): string { + return String(value).padStart(2, '0') +} diff --git a/src/renderer/src/features/automation/model/scheduled-report-preview.ts b/src/renderer/src/features/automation/model/scheduled-report-preview.ts deleted file mode 100644 index 33b3f4f..0000000 --- a/src/renderer/src/features/automation/model/scheduled-report-preview.ts +++ /dev/null @@ -1,291 +0,0 @@ -import { - DEFAULT_REPORT_TEMPLATE, - REPORT_TEMPLATES, - SELECTABLE_REPORT_TEMPLATES, - getReportTemplate, - type SelectableReportTemplateId -} from '../../../../../shared/report-templates' -import type { - ScheduledReportMemberNameMode, - ScheduledReportRange, - ScheduledReportTask -} from '../../../../../shared/scheduled-report' -import { SUMMARY_TYPE_OPTIONS, type SummaryMessageType } from '../../../utils/group-report' - -/** - * 定时日报编辑态的模型与只读数据层。 - * - * 对外唯一的接触面是 `window.api.listScheduledReports`(**只读**): - * 不写、不执行、不动 scheduler / store / schema。 - * - * 所有字段都来自 `shared/scheduled-report.ts` 的真实模型,**不发明后端不存在的选项**: - * - `schedule.type` 只有 `'daily'` ⇒ 界面只给"每天 + 时间"; - * - `target` 是单个群(`type: 'wechat_group'`)⇒ 界面只给群聊目标; - * - `templateId` 真实存在 ⇒ 界面可以给模板选择。 - */ - -/** - * 编辑器的草稿:字段与**旧「定时日报」真实配置**一一对应, - * 没有任何"想象出来的"配置,也没有丢掉任何一项已有能力。 - */ -export interface ScheduledReportDraft { - name: string - /** 每天的执行时间 `HH:mm`,对应真实的 `schedule: { type: 'daily', time }`。 */ - scheduleTime: string - reportRange: ScheduledReportRange - /** 日报来源群:生成**哪个群**的日报。 */ - group: string - /** 纳入的消息类型(真实 `messageTypes`)。 */ - messageTypes: SummaryMessageType[] - /** 成员名称显示方式(真实 `memberNameMode`)。 */ - memberNameMode: ScheduledReportMemberNameMode - /** 日报生成超时(真实 `timeoutSeconds`,旧页面 30–1800 秒)。 */ - timeoutSeconds: number - templateId: SelectableReportTemplateId - /** - * 发送目标的**类型**。 - * - * 这是本层的概念:真实的 `ScheduledReportTask.target` 只是一个字符串 - * (目前只表达"某个微信群")。四选一里的「自己 / 文件传输助手 / 指定好友」 - * 在真实模型里还没有对应的类型化表达 —— 真实迁移时应当照退群通知的做法, - * 给 target 一个带类型的模型与解析器。 - */ - targetType: ScheduledReportTargetType - /** `targetType === 'source_chat'` 时的目标群(= 来源群,旧页面就是这个语义)。 */ - target: string - /** `targetType === 'contact'` 时的联系人。 */ - targetContactId: string - enabled: boolean -} - -/** 全部消息类型(旧页面默认全选)。 */ -export const ALL_SCHEDULED_REPORT_MESSAGE_TYPES: SummaryMessageType[] = SUMMARY_TYPE_OPTIONS.map( - (option) => option.value -) - -/** 成员名称选项(与旧页面一致)。 */ -export const SCHEDULED_REPORT_MEMBER_NAME_OPTIONS: ReadonlyArray<{ - value: ScheduledReportMemberNameMode - label: string -}> = [ - { value: 'groupNickname', label: '群昵称' }, - { value: 'wechatNickname', label: '微信昵称' }, - { value: 'remark', label: '通讯录备注' } -] - -/** - * 发送目标四选一。 - * - * 第一项与退群通知不同:定时日报没有"当前消息所在群"这个概念, - * 所以是「日报来源群」。刻意**不提供"指定其他群聊"** —— - * 生成 A 群日报却发到 B 群是很容易让人误解的配置。 - */ -export type ScheduledReportTargetType = 'source_chat' | 'self' | 'file_transfer' | 'contact' - -export const SCHEDULED_REPORT_TARGET_OPTIONS: ReadonlyArray<{ - type: ScheduledReportTargetType - label: string - description: string -}> = [ - { - type: 'file_transfer', - label: '文件传输助手', - description: '将生成的日报发送到文件传输助手。' - }, - { - type: 'source_chat', - label: '发送到日报来源群', - description: '把生成的日报发送回用于生成日报的那个群。' - }, - { - type: 'self', - label: '发给自己', - description: '将生成的日报发送到当前登录微信账号自己的会话。' - }, - { - type: 'contact', - label: '指定好友', - description: '将生成的日报发送给一个指定联系人。' - } -] - -/** 新建时的默认发送目标:文件传输助手(不会误打扰群聊)。 */ -export const DEFAULT_SCHEDULED_REPORT_TARGET_TYPE: ScheduledReportTargetType = 'file_transfer' - -/** 模板选项:与旧页面同一套定义(默认模板 + REPORT_TEMPLATES)。 */ -export const SCHEDULED_REPORT_TEMPLATE_SELECT_OPTIONS: ReadonlyArray<{ - value: SelectableReportTemplateId - label: string -}> = [ - { value: DEFAULT_REPORT_TEMPLATE.id, label: DEFAULT_REPORT_TEMPLATE.name }, - ...REPORT_TEMPLATES.map((template) => ({ - value: template.id, - label: `${template.label} · ${template.name}` - })) -] - -/** 日报范围选项。文案与旧「日报 → 定时日报」页面保持一致。 */ -export const SCHEDULED_REPORT_RANGE_OPTIONS: ReadonlyArray<{ - value: ScheduledReportRange - label: string -}> = [ - { value: 'today', label: '今日' }, - { value: 'yesterday', label: '昨日' }, - { value: '7days', label: '近 7 天' }, - { value: 'recent24h', label: '最近24小时' } -] - -/** 可选日报模板(真实定义来自 `report-templates.ts`)。 */ -export const SCHEDULED_REPORT_TEMPLATE_OPTIONS = SELECTABLE_REPORT_TEMPLATES.map((template) => ({ - value: template.id, - label: template.label -})) - -export const DEFAULT_SCHEDULED_REPORT_TEMPLATE_ID: SelectableReportTemplateId = 'v1' - -/** 与旧页面同语义的范围文案(`近 7 天` 的空格等细节刻意保持一致)。 */ -export function scheduledReportRangeLabel(range: ScheduledReportRange): string { - return ( - SCHEDULED_REPORT_RANGE_OPTIONS.find((option) => option.value === range)?.label ?? - SCHEDULED_REPORT_RANGE_OPTIONS[0].label - ) -} - -/** - * 时间展示:`今日 18:30` / `明日 18:30`。 - * - * 这里只服务**本地草稿预览**(用户刚改完时间就要看到结果), - * 所以用"今日/明日"表达相对关系;真实任务的 `nextRunAt` 展示沿用旧页面语义。 - */ -export function formatScheduledSlot(scheduleTime: string, now = new Date()): string { - const [hour, minute] = parseScheduleTime(scheduleTime) - const slot = new Date(now) - slot.setSeconds(0, 0) - slot.setHours(hour, minute) - if (slot.getTime() <= now.getTime()) slot.setDate(slot.getDate() + 1) - const isToday = slot.toDateString() === now.toDateString() - return `${isToday ? '今日' : '明日'} ${pad2(hour)}:${pad2(minute)}` -} - -/** 把真实的 `nextRunAt` 格式化成旧页面同款文案。 */ -export function formatNextRunAt(value?: string): string { - if (!value) return '尚未执行' - const date = new Date(value) - if (Number.isNaN(date.getTime())) return '时间未知' - const now = new Date() - const dayLabel = - date.toDateString() === now.toDateString() ? '今日' : date.toLocaleDateString('zh-CN') - return `${dayLabel} ${date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}` -} - -export function parseScheduleTime(value: string): [number, number] { - const matched = /^(\d{1,2}):(\d{2})$/.exec(String(value ?? '').trim()) - if (!matched) return [18, 30] - const hour = Math.min(23, Math.max(0, Number(matched[1]))) - const minute = Math.min(59, Math.max(0, Number(matched[2]))) - return [hour, minute] -} - -export function composeScheduleTime(hour: number, minute: number): string { - return `${pad2(Math.min(23, Math.max(0, hour)))}:${pad2(Math.min(59, Math.max(0, minute)))}` -} - -export function createEmptyScheduledReportDraft(): ScheduledReportDraft { - return { - name: '', - scheduleTime: '18:30', - reportRange: 'today', - group: '', - // 与旧页面一致:默认纳入全部消息类型。 - messageTypes: [...ALL_SCHEDULED_REPORT_MESSAGE_TYPES], - memberNameMode: 'groupNickname', - timeoutSeconds: 300, - templateId: DEFAULT_SCHEDULED_REPORT_TEMPLATE_ID, - // 默认发到文件传输助手:不会误打扰任何群聊。 - targetType: DEFAULT_SCHEDULED_REPORT_TARGET_TYPE, - target: '', - targetContactId: '', - enabled: true - } -} - -/** 真实任务 → 编辑器草稿(只用于**预览**,保存不落盘)。 */ -export function createDraftFromTask(task: ScheduledReportTask): ScheduledReportDraft { - return { - name: task.name, - scheduleTime: task.scheduleTime, - reportRange: task.reportRange, - group: task.group, - messageTypes: task.messageTypes?.length - ? (task.messageTypes as SummaryMessageType[]) - : [...ALL_SCHEDULED_REPORT_MESSAGE_TYPES], - memberNameMode: task.memberNameMode ?? 'groupNickname', - timeoutSeconds: task.timeoutSeconds ?? 300, - templateId: task.templateId ?? DEFAULT_SCHEDULED_REPORT_TEMPLATE_ID, - // 旧页面里「发送到」恒等于来源群(只读展示),所以历史任务一律按"发回来源群"回显。 - targetType: 'source_chat', - target: task.target, - targetContactId: '', - enabled: task.enabled - } -} - -/** - * 把任务里存的群标识解析成**能给用户看**的名字。 - * - * `ScheduledReportTask.group/target` 的真实类型是 "human-readable group name or room id", - * 实际数据里两种都可能出现(纯数字 room id 与 16 进制 hash)。 - * 直接渲染就会把内部标识暴露给用户,所以这里做一次显示名解析: - * 能对上群名就用群名;明显是内部标识又对不上时显示「未知群聊」,**绝不显示裸 id**。 - */ -export function resolveGroupDisplayName( - raw: string, - groups: Array<{ id: string; name: string }> -): string { - const value = String(raw ?? '').trim() - if (!value) return '' - const matched = groups.find((group) => group.id === value) ?? groups.find((g) => g.name === value) - if (matched) return matched.name - return looksLikeInternalIdentifier(value) ? '未知群聊' : value -} - -/** 内部标识形态:room id / 16 进制 hash / 纯数字串。 */ -function looksLikeInternalIdentifier(value: string): boolean { - return ( - value.includes('@chatroom') || /^[0-9a-f]{16,}$/i.test(value) || /^\d{6,}$/.test(value) - ) -} - -/** - * 模板的**展示名**。 - * - * 用 `name`("经典日报")而不是 `label`("默认模板"): - * 同一个模板在列表、预览、选择器里必须是同一个称呼, - * 否则同一份配置会出现两种叫法。 - */ -export function scheduledReportTemplateLabel(templateId?: string): string { - return getReportTemplate(templateId).name -} - -/** - * 只读加载现有定时日报任务。 - * - * **唯一的读取入口**,也是本文件与真实系统唯一的接触面: - * - 不调用 create / update / delete / execute / toggle; - * - 接口不存在或失败时返回空数组,让界面走空态,而不是伪造数据。 - */ -export async function loadScheduledReportTasks(): Promise { - const target = (globalThis as { api?: Record }).api - const loader = target?.listScheduledReports - if (typeof loader !== 'function') return [] - try { - const tasks = await (loader as () => Promise).call(target) - return Array.isArray(tasks) ? (tasks as ScheduledReportTask[]) : [] - } catch { - return [] - } -} - -function pad2(value: number): string { - return String(value).padStart(2, '0') -} diff --git a/src/renderer/src/features/group-exit-monitor/GroupExitMonitorWorkspace.tsx b/src/renderer/src/features/group-exit-monitor/GroupExitMonitorWorkspace.tsx index a4818af..773cc93 100644 --- a/src/renderer/src/features/group-exit-monitor/GroupExitMonitorWorkspace.tsx +++ b/src/renderer/src/features/group-exit-monitor/GroupExitMonitorWorkspace.tsx @@ -39,6 +39,13 @@ interface GroupExitMonitorWorkspaceProps { onOpenSendSettings?: () => void /** 深链请求:直接落到「退群监控 → 管理群聊」。 */ openViewRequest?: GroupExitMonitorOpenViewRequest | null + /** + * 深链请求已被消费(父层据此把它清掉)。 + * + * **不给这个回调,深链就会变成常驻状态**:本组件在切换一级菜单时会被卸载, + * 而请求还挂在父层,于是下次正常点「退群监控」也会被重放,直接落到「管理群聊」。 + */ + onOpenViewRequestHandled?: () => void /** 「退群通知自动化 →」:跳到自动化里对应的规则类型(纯导航)。 */ onOpenLeaveNotificationAutomation?: () => void } @@ -307,6 +314,7 @@ export function GroupExitMonitorWorkspace({ contacts = [], onOpenSendSettings, openViewRequest, + onOpenViewRequestHandled, onOpenLeaveNotificationAutomation }: GroupExitMonitorWorkspaceProps): React.ReactElement { const [state, setState] = React.useState(EMPTY_STATE) @@ -383,11 +391,19 @@ export function GroupExitMonitorWorkspace({ } }, []) - // 「自动化 → 退群通知 → 管理监控群聊」深链过来时,直接落到管理群聊页。 + /** + * 「自动化 → 退群通知 → 管理监控群聊」深链过来时,直接落到管理群聊页。 + * + * 请求是**一次性的**:落位之后立刻回报父层把它清掉。以前只 `setView` 不回报, + * 请求就一直留在父层;而 `renderCurrentWorkspace()` 是 switch,切一级菜单会卸载 + * 本组件 —— 于是下次正常点「退群监控」时,这条陈旧的 `{view:'manage'}` 被重放, + * 用户看到的就是"点退群监控却进了管理群聊"。 + */ React.useEffect(() => { if (!openViewRequest) return setView(openViewRequest.view) - }, [openViewRequest]) + onOpenViewRequestHandled?.() + }, [openViewRequest, onOpenViewRequestHandled]) /** * 「复制退群信息」用的模板。 @@ -631,7 +647,7 @@ export function GroupExitMonitorWorkspace({ {/* 发送能力不完整时给一个可操作的去处。 - §「规则仍然可以保存」:这里只提示,不阻断任何编辑。 + 这里只提示,不阻断任何编辑。 */} {sendCapability && !(sendCapability.ready && sendCapability.capabilities?.text) && diff --git a/src/renderer/src/styles/automation.scss b/src/renderer/src/styles/automation.scss index f50be12..16089bc 100644 --- a/src/renderer/src/styles/automation.scss +++ b/src/renderer/src/styles/automation.scss @@ -1243,7 +1243,8 @@ margin-top: 10px; } -// 只读的「已监控群聊」摘要:范围归退群监控管,这里不重复维护一份选择状态。 +// 只读的「已监控群聊」摘要:监控范围归退群监控管,这里不重复维护一份选择状态。 +// (本规则只管**二次勾选**:下面 .automation-leave-scope 那一层。) .automation-leave-groups-card { display: flex; min-width: 0; @@ -1277,6 +1278,26 @@ } } +// 「通知范围」二次勾选:全部已监控群聊 / 仅选中的群聊。 +// 复用 .automation-leave-radio 那一套单行样式(radio + 标题/说明两行), +// 所以这里只补"整块纵向排布 + 选项间距 + 覆盖摘要"。 +.automation-leave-scope { + display: flex; + min-width: 0; + flex-direction: column; + gap: 6px; +} + +.automation-leave-scope-options { + gap: 2px; +} + +.automation-leave-scope-summary { + color: var(--wxex-text-muted); + font-size: 12px; + line-height: 17px; +} + // 「通知发送到哪里」四选一。 .automation-leave-targets { gap: 2px; diff --git a/src/renderer/src/styles/reports-core.scss b/src/renderer/src/styles/reports-core.scss index 742e945..d0c82fd 100644 --- a/src/renderer/src/styles/reports-core.scss +++ b/src/renderer/src/styles/reports-core.scss @@ -72,6 +72,33 @@ flex: 1 1 auto; } +/* + * 「定时日报已并入自动化」的指引条。 + * + * ⚠️ 这两个类以前只出现在 JSX 里,样式表里**从来没有**对应规则 —— + * 所以它一直是一条没样式的裸文字 + 一个没样式的裸按钮。 + * 现在补上:与 tab 条同宽的柔软信息条,按钮交给 UI 组件库。 + */ +.report-workspace-hint { + display: flex; + flex: 0 0 auto; + min-width: 0; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 8px 12px; + margin: 0; + padding: 10px 24px; + border-bottom: 1px solid var(--wxex-border); + background: var(--wxex-bg-elevated); + color: var(--wxex-text-secondary); + font: 400 12px/18px var(--wxex-font); + + > span { + min-width: 0; + } +} + .report-workspace-body > .report-page, .report-workspace-body > .report-center-page { height: 100%; diff --git a/src/shared/automation-matcher.ts b/src/shared/automation-matcher.ts index d6bbeef..e728cb6 100644 --- a/src/shared/automation-matcher.ts +++ b/src/shared/automation-matcher.ts @@ -55,10 +55,10 @@ export function matchAutomationRule( ): AutomationMatchResult { // **规则类型闸门放在最前面。** // - // 「退群通知」的空关键词 + 不要求 @我,会让它命中**每一条群消息** —— + // 「退群通知」与「定时日报」的空关键词 + 不要求 @我,会让它们命中**每一条群消息** —— // 那是最坏的一类 bug:用户什么都没配,规则却开始乱跑。 // 类型分派必须在这里一处解决,预览与真实执行才会一致。 - if (rule.ruleType === 'leave_notification') { + if (rule.ruleType !== 'daily_report') { return { matched: false, reason: 'rule_type' } } diff --git a/src/shared/automation.ts b/src/shared/automation.ts index 6474be0..bb21966 100644 --- a/src/shared/automation.ts +++ b/src/shared/automation.ts @@ -6,6 +6,15 @@ */ import { normalizeGroupExitNotificationTemplate } from './group-exit-monitor' +import { + isSelectableReportTemplateId, + type SelectableReportTemplateId +} from './report-templates' +import type { + ScheduledReportMemberNameMode, + ScheduledReportMessageType, + ScheduledReportRange +} from './scheduled-report' /** 触发事件类型。第一版只支持「收到消息」。 */ export type AutomationTriggerType = 'message' @@ -26,16 +35,18 @@ export type AutomationMessageScope = 'group' | 'direct' | 'all' * * 决定**这条规则由什么驱动、有哪些字段有意义**: * - `daily_report`:消息驱动,条件 + 动作链(@我生成日报); + * - `scheduled_report`:时间驱动,日程 + 日报配置 + 发送目标(定时日报); * - `leave_notification`:退群事件驱动,通知目标 + 模板(退群通知)。 * - * ⚠️ 这是一条**硬分派**:`matchAutomationRule` 会直接拒绝非 `daily_report` 的规则, - * 否则一条 keyword 为空的退群通知会命中**每一条群消息**。 + * ⚠️ 这是一条**硬分派**:`matchAutomationRule` 只放行 `daily_report`, + * 否则一条 keyword 为空的定时/退群规则会命中**每一条群消息**。 */ -export type AutomationRuleType = 'daily_report' | 'leave_notification' +export type AutomationRuleType = 'daily_report' | 'scheduled_report' | 'leave_notification' /** 规则类型的界面标签(Tab 与首页卡片共用一处文案)。 */ export const AUTOMATION_RULE_TYPE_LABELS: Record = { daily_report: '@我生成日报', + scheduled_report: '定时日报', leave_notification: '退群通知' } @@ -52,16 +63,350 @@ export interface LeaveNotificationTarget { contactId?: string } +/** + * 退群通知的**通知范围** —— 对「退群监控 → 管理群聊」选中集合的二次筛选。 + * + * 旧版这里是 `notificationRoomIds`(per-group 多值):管理群聊选 N 个群, + * 通知群聊再从这 N 个里勾子集。迁移到自动化时被压成单值目标,二次筛选能力随之丢失, + * 于是"规则一启用就全量通知"。本字段把它恢复回来 —— 语义与旧版逐字对齐: + * + * - `all` = 全部已监控群聊。**旧规则缺字段时的归一化结果**,保持既有行为不变。 + * - `selected` = 仅 `notifyRoomIds` 里的群。 + */ +export type LeaveNotificationNotifyScope = 'all' | 'selected' + export interface LeaveNotificationConfig { target: LeaveNotificationTarget template: string /** - * 迁移时旧配置**无法无损映射**的标记(旧「通知群聊」是 per-group 多值, - * 新目标是单值)。为 true 时:规则不执行发送,UI 提示用户重选目标。 + * 通知范围:哪些**已监控**群聊值得发通知。 + * + * 与 `target` **正交**,两者回答的是不同问题: + * - `target` → 通知**发到哪里**(当前群聊 / 自己 / 文件助手 / 指定好友) + * - `notifyScope` → **哪些群的退群**才值得发 + */ + notifyScope: LeaveNotificationNotifyScope + /** + * `notifyScope === 'selected'` 时生效的已监控群聊子集(roomId,即 `xxx@chatroom`)。 + * + * 选中的 id 在本字段里**记住**,所以「全部 ⇄ 仅选中」来回切换不会丢掉用户的勾选; + * 但它只在 `notifyScope === 'selected'` 时被读取 —— `notifyScope` 是唯一权威。 + * + * 范围外的 id 不具备危害:未被监控的群不会产生退群事件,自然永不命中。 + */ + notifyRoomIds: string[] + /** + * 迁移时旧配置**无法无损映射**的标记。 + * + * ⚠️ 退群通知的迁移**已经不再产生**它(子集现在可无损表达,见 + * `leave-notification-migration.ts`)。保留只为两件事: + * 1. 存量安装里**已经落盘**的 `targetNeedsReview: true` 仍要拦发送,等用户重选; + * 2. 定时日报迁移确实还需要它(`scheduled_report` 的跨群目标是新产品不支持的形态)。 + * + * 为 true 时:规则不执行发送,UI 提示用户重选目标。用户手动保存一次即清除。 + */ + targetNeedsReview?: boolean +} + +/** + * 定时日报发到哪里。刻意只有这四种,**不提供「指定另一个群聊」** —— + * 生成 A 群日报却发到 B 群是最容易让人误解的配置。 + */ +export type ScheduledReportTargetType = 'source_chat' | 'self' | 'file_transfer' | 'contact' + +export interface ScheduledReportTarget { + type: ScheduledReportTargetType + /** `contact` 时必填:**稳定 id(wxid / username)**,不是昵称。 */ + contactId?: string +} + +/** + * 定时日报的规则配置。 + * + * 字段一一对应历史「定时日报任务」的既有能力, + * 没有任何"按 UI 猜出来的"配置,也没有丢掉任何一项已有能力。 + */ +export interface ScheduledReportAutomationConfig { + schedule: { + /** `HH:mm`,**每天一次**。当前只支持每日。 */ + time: string + } + report: { + /** + * 日报来源群的**稳定会话 id**(`xxx@chatroom`)。 + * + * ⚠️ 迁移过来的老数据可能仍是旧形态(会话 md5 / 群名)—— 那是历史事实, + * 不在这里"猜着改";运行期解析失败会如实报错(不静默换群)。 + */ + sourceConversationId: string + range: ScheduledReportRange + messageTypes: ScheduledReportMessageType[] + templateId: SelectableReportTemplateId + memberNameMode: ScheduledReportMemberNameMode + timeoutSeconds: number + } + target: ScheduledReportTarget + /** + * 日报图片**发送成功之后**再补发的一句话(「后置词」)。 + * + * 与「手动发送日报图片」里的后置词是同一件事,但这里是**按规则存**: + * 定时日报按群发,A 群和 B 群想跟的话未必一样;统一塞进全局设置反而更别扭。 + * + * 空字符串 = 只发图片。存量规则迁移过来就是空(`normalizeScheduledReportConfig` + * 不再凭空补默认值)—— 「上线后突然多出一条文本消息」是绝不能接受的静默行为变化。 + */ + postfixText?: string + /** + * 迁移时旧目标**无法无损映射**的标记(例如"生成 A 群日报 → 发 B 群", + * 或旧目标解析不到)。为 true 时:规则不执行、不创建记录,UI 提示重选目标。 * * 只有迁移会把它置为 true,用户手动保存一次即清除。 */ targetNeedsReview?: boolean + /** + * 迁移保留的**旧目标原文**。仅供用户在编辑器里看清"原来配的是哪个", + * **永远不会**被当作发送目标使用。 + */ + legacyTarget?: string + /** 迁移保留的**旧来源群原文**(同理,只作展示)。 */ + legacySourceGroup?: string + /** + * 最近一次执行的完成时间(ISO)。**调度游标,不是用户配置**: + * 由 scheduler 写,`updateRule` 会保留它,草稿改不动它。 + */ + lastRunAt?: string + /** + * 已消费的每日槽位(ISO)。**幂等标记,不是用户配置**。 + * 用于避免同一天重复补跑、以及重启后重复执行同一槽位。 + */ + lastScheduledSlot?: string +} + +/** 定时日报的四种发送目标(展示层单一来源)。 */ +export const SCHEDULED_REPORT_TARGET_OPTIONS: ReadonlyArray<{ + type: ScheduledReportTargetType + label: string + description: string +}> = [ + { + type: 'source_chat', + label: '发送到日报来源群', + description: '把生成的日报发送回用于生成日报的那个群。' + }, + { + type: 'file_transfer', + label: '文件传输助手', + description: '将生成的日报发送到文件传输助手。' + }, + { + type: 'self', + label: '发给自己', + description: '将生成的日报发送到当前登录微信账号自己的会话。' + }, + { + type: 'contact', + label: '指定好友', + description: '将生成的日报发送给一个指定联系人。' + } +] + +/** + * 新建定时日报的默认目标:文件传输助手。 + * + * 与退群通知不同(那边默认「当前群聊」): + * 定时日报新建时**绝不能默认发回群里** —— 用户还没确认过就自动往群里发日报, + * 是最容易造成打扰的一类默认值。 + */ +export const SCHEDULED_REPORT_DEFAULT_TARGET_TYPE: ScheduledReportTargetType = 'file_transfer' + +/** 目标类型 → 界面短标签。 */ +export function scheduledReportTargetLabel(type: ScheduledReportTargetType): string { + return SCHEDULED_REPORT_TARGET_OPTIONS.find((option) => option.type === type)?.label ?? '' +} + +/** 日报范围的界面文案(与旧「定时日报」页面逐字一致)。 */ +export const SCHEDULED_REPORT_RANGE_LABELS: Record = { + today: '今日', + yesterday: '昨日', + '7days': '近 7 天', + recent24h: '最近24小时' +} + +export function scheduledReportRangeLabel(range: ScheduledReportRange): string { + return SCHEDULED_REPORT_RANGE_LABELS[range] ?? SCHEDULED_REPORT_RANGE_LABELS.today +} + +/** 成员名称选项(与旧页面一致)。 */ +export const SCHEDULED_REPORT_MEMBER_NAME_OPTIONS: ReadonlyArray<{ + value: ScheduledReportMemberNameMode + label: string +}> = [ + { value: 'groupNickname', label: '群昵称' }, + { value: 'wechatNickname', label: '微信昵称' }, + { value: 'remark', label: '通讯录备注' } +] + +export const SCHEDULED_REPORT_MEMBER_NAME_LABELS: Record = { + groupNickname: '群昵称', + wechatNickname: '微信昵称', + remark: '通讯录备注' +} + +export const SCHEDULED_REPORT_DEFAULT_TIME = '18:30' +export const SCHEDULED_REPORT_DEFAULT_RANGE: ScheduledReportRange = 'today' +export const SCHEDULED_REPORT_DEFAULT_TEMPLATE_ID: SelectableReportTemplateId = 'v1' +export const SCHEDULED_REPORT_DEFAULT_MEMBER_NAME_MODE: ScheduledReportMemberNameMode = + 'groupNickname' +export const SCHEDULED_REPORT_DEFAULT_TIMEOUT_SECONDS = 300 +export const SCHEDULED_REPORT_MIN_TIMEOUT_SECONDS = 30 +export const SCHEDULED_REPORT_MAX_TIMEOUT_SECONDS = 1800 + +/** + * 后置词长度上限。 + * + * 与手动发送日报图片那个输入框(`ReportImagePostfixInput` 的 `maxLength`) + * **必须一致**:同一句话在两个入口能存下不同长度,是最没道理的一种不一致。 + */ +export const SCHEDULED_REPORT_POSTFIX_MAX_LENGTH = 200 + +/** 全部消息类型(新建时的默认:与旧页面一致,全选)。 */ +export const SCHEDULED_REPORT_ALL_MESSAGE_TYPES: ScheduledReportMessageType[] = [ + 'text', + 'image', + 'sticker', + 'video', + 'voice', + 'share', + 'system' +] + +const SCHEDULED_REPORT_RANGES: ScheduledReportRange[] = ['today', 'yesterday', '7days', 'recent24h'] + +/** `HH:mm` 校验。 */ +export function isValidScheduleTime(value: unknown): boolean { + return /^(?:[01]\d|2[0-3]):[0-5]\d$/.test(String(value ?? '').trim()) +} + +/** 任意输入 → 合法 `HH:mm`;非法回落到默认时间。 */ +export function normalizeScheduleTime(value: unknown): string { + return isValidScheduleTime(value) ? String(value).trim() : SCHEDULED_REPORT_DEFAULT_TIME +} + +/** + * 归一化「后置词」。 + * + * 去首尾空白 + 截断到上限。**空就是空** —— 绝不回落到 + * `DEFAULT_REPORT_IMAGE_POSTFIX_TEXT`,那会让每一条存量规则在上线后 + * 突然多发出一句文本,属于静默的行为变化。 + */ +export function normalizeScheduledReportPostfixText(input: unknown): string { + return String(input ?? '') + .trim() + .slice(0, SCHEDULED_REPORT_POSTFIX_MAX_LENGTH) +} + +/** + * 下一次执行时刻(本地时间)。 + * + * **source of truth 是 `schedule.time`**:这个函数是纯派生, + * `nextRunAt` 不再作为可写配置持久化。 + */ +export function calculateNextRunAt(scheduleTime: string, from: Date = new Date()): string { + if (!isValidScheduleTime(scheduleTime)) throw new Error('执行时间必须是 HH:mm') + const [hour, minute] = String(scheduleTime).trim().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() +} + +/** 收敛定时日报配置:未知值一律回落到安全默认,绝不落一条跑不起来的规则。 */ +export function normalizeScheduledReportConfig( + input: unknown +): ScheduledReportAutomationConfig { + const raw = (input ?? {}) as Partial + const scheduleRaw = (raw.schedule ?? {}) as Partial + const reportRaw = (raw.report ?? {}) as Partial + const targetRaw = (raw.target ?? {}) as Partial + + const allowedTargets: ScheduledReportTargetType[] = [ + 'source_chat', + 'self', + 'file_transfer', + 'contact' + ] + const targetType: ScheduledReportTargetType = allowedTargets.includes( + targetRaw.type as ScheduledReportTargetType + ) + ? (targetRaw.type as ScheduledReportTargetType) + : SCHEDULED_REPORT_DEFAULT_TARGET_TYPE + const contactId = String(targetRaw.contactId ?? '').trim() + // `contact` 但没有 id → 回落默认目标(与退群通知同口径):宁可发文件助手, + // 也不能存一条必然失败的规则。 + const effectiveTargetType: ScheduledReportTargetType = + targetType === 'contact' && !contactId ? SCHEDULED_REPORT_DEFAULT_TARGET_TYPE : targetType + + const range: ScheduledReportRange = SCHEDULED_REPORT_RANGES.includes( + reportRaw.range as ScheduledReportRange + ) + ? (reportRaw.range as ScheduledReportRange) + : SCHEDULED_REPORT_DEFAULT_RANGE + + const messageTypes = Array.isArray(reportRaw.messageTypes) + ? (reportRaw.messageTypes + .map((type) => String(type)) + .filter((type) => + SCHEDULED_REPORT_ALL_MESSAGE_TYPES.includes(type as ScheduledReportMessageType) + ) as ScheduledReportMessageType[]) + : [] + const effectiveMessageTypes: ScheduledReportMessageType[] = messageTypes.length + ? messageTypes + : ['text'] + + const memberNameMode: ScheduledReportMemberNameMode = + reportRaw.memberNameMode === 'wechatNickname' || reportRaw.memberNameMode === 'remark' + ? reportRaw.memberNameMode + : SCHEDULED_REPORT_DEFAULT_MEMBER_NAME_MODE + + const rawTimeout = Number(reportRaw.timeoutSeconds) + const timeoutSeconds = Number.isFinite(rawTimeout) + ? Math.max( + SCHEDULED_REPORT_MIN_TIMEOUT_SECONDS, + Math.min(SCHEDULED_REPORT_MAX_TIMEOUT_SECONDS, Math.round(rawTimeout)) + ) + : SCHEDULED_REPORT_DEFAULT_TIMEOUT_SECONDS + + const legacyTarget = String(raw.legacyTarget ?? '').trim() + const legacySourceGroup = String(raw.legacySourceGroup ?? '').trim() + const lastRunAt = String(raw.lastRunAt ?? '').trim() + const lastScheduledSlot = String(raw.lastScheduledSlot ?? '').trim() + const postfixText = normalizeScheduledReportPostfixText(raw.postfixText) + + return { + schedule: { time: normalizeScheduleTime(scheduleRaw.time) }, + report: { + sourceConversationId: String(reportRaw.sourceConversationId ?? '').trim(), + range, + messageTypes: effectiveMessageTypes, + // 必须**校验**而不是只兜空值:旧 tasks.json 里可能存着本版本已经没有的模板 id, + // 原样透传会得到一条"配置看着在、运行期生成不出来"的规则。 + templateId: isSelectableReportTemplateId(reportRaw.templateId) + ? reportRaw.templateId + : SCHEDULED_REPORT_DEFAULT_TEMPLATE_ID, + memberNameMode, + timeoutSeconds + }, + target: { + type: effectiveTargetType, + ...(effectiveTargetType === 'contact' ? { contactId } : {}) + }, + ...(postfixText ? { postfixText } : {}), + ...(raw.targetNeedsReview === true ? { targetNeedsReview: true } : {}), + ...(legacyTarget ? { legacyTarget } : {}), + ...(legacySourceGroup ? { legacySourceGroup } : {}), + ...(lastRunAt ? { lastRunAt } : {}), + ...(lastScheduledSlot ? { lastScheduledSlot } : {}) + } } export interface AutomationConditions { @@ -110,6 +455,8 @@ export interface AutomationRule { replyDelaySeconds: number /** `ruleType === 'leave_notification'` 时必填。 */ leaveNotification?: LeaveNotificationConfig + /** `ruleType === 'scheduled_report'` 时必填。 */ + scheduledReport?: ScheduledReportAutomationConfig createdAt: number updatedAt: number } @@ -120,12 +467,18 @@ export type AutomationStepStatus = 'pending' | 'running' | 'success' | 'failed' /** * 执行步骤键。 * - * 分两族,**不共用**: + * 分三族,**不共用**: * - 消息型(`daily_report`):received / matched / reply / report / send + * - 定时型(`scheduled_report`):schedule_triggered / report_generating / report_generated / + * send_resolved / report_sent * - 退群型(`leave_notification`):exit_received / exit_matched / exit_target / exit_send * - * 不共用的理由:给退群通知渲染一个「回复确认 已跳过」的步骤是纯噪声, + * 不共用的理由:给定时日报渲染一个「回复确认 已跳过」的步骤是纯噪声, * 用户会以为规则配错了。 + * + * ⚠️ 定时型的步骤键**刻意拆开「生成中 / 已生成」与「确定目标 / 已发送」**: + * 「生成成功但发送失败」要求用户能一眼看出**日报确实生成了**,只是没发出去。 + * 合成一步就没法表达这件事。 */ export type AutomationStepKey = | 'received' @@ -133,6 +486,11 @@ export type AutomationStepKey = | 'reply' | 'report' | 'send' + | 'schedule_triggered' + | 'report_generating' + | 'report_generated' + | 'send_resolved' + | 'report_sent' | 'exit_received' | 'exit_matched' | 'exit_target' @@ -188,11 +546,33 @@ export interface AutomationStep { */ export type AutomationExecutionStatus = 'running' | 'success' | 'failed' +/** + * 一次执行是**被什么触发**的。 + * + * - `message`:群里出现符合条件的消息(@我生成日报); + * - `exit`:检测到群成员退出(退群通知); + * - `schedule`:到了设定时间(定时日报); + * - `manual`:用户在列表里点了「立即执行」(定时日报)。 + * + * 区分 `schedule` 与 `manual` 是硬要求:否则看日志没人知道"为什么 17:03 跑了一次"。 + * 旧记录没有这个字段(读盘时保持 `undefined`,不猜测、不编造)。 + */ +export type AutomationExecutionTrigger = 'message' | 'exit' | 'schedule' | 'manual' + +export const AUTOMATION_EXECUTION_TRIGGER_LABELS: Record = { + message: '收到消息', + exit: '检测到成员退出', + schedule: '定时任务', + manual: '手动立即执行' +} + export interface AutomationExecution { executionId: string ruleId: string ruleName: string triggerTime: number + /** 触发方式。旧记录缺省(`undefined`)—— 读盘不猜测。 */ + trigger?: AutomationExecutionTrigger /** * 来源的**显示名**(群名 / 联系人昵称)。 * @@ -206,6 +586,31 @@ export interface AutomationExecution { errorSummary?: string } +/** + * 定时日报一次执行的结果(scheduler 与「立即执行」共用)。 + * + * 放在 shared 层是因为它要跨三层:`AutomationService` 产出 → IPC 回执 → 渲染层提示。 + */ +export interface ScheduledRuleRunOutcome { + /** 是否真的跑了一次。false 时 `reason` 说明为什么没跑。 */ + executed: boolean + /** 本次执行的 id(定时触发时为**确定性** id,同一槽位重启后不变)。 */ + executionId?: string + status?: AutomationExecutionStatus + /** 日报是否已生成并落库(`false` 且 `status==='failed'` 表示"根本没生成出来")。 */ + reportGenerated?: boolean + /** 底层生成错误的机器可读码(如 `NO_MESSAGES`)。 */ + errorCode?: string + /** 失败时用户可读的一句话摘要。 */ + errorSummary?: string + /** + * 未执行的原因(机器可读): + * `missing_rule` / `rule_not_found` / `disabled` / `target_needs_review` / + * `in_flight` / `store_error` / `database_not_ready`。 + */ + reason?: string +} + /** * 发送用途标识(对应 `WechatActionPurpose`)。 * @@ -225,7 +630,21 @@ export const AUTOMATION_SEND_PURPOSE = { * 的作用域锁绑死在「只能发回事件所在群」,而现在的目标可以是自己 / 文件传输助手 / * 指定好友。用新 purpose 才能真正解除那个锁,同时让旧 purpose 变成无生产者的历史值。 */ - leaveNotification: 'automation_leave_notification' + leaveNotification: 'automation_leave_notification', + /** + * 定时日报的图片发送。 + * + * 刻意**不复用**历史 purpose `scheduled_report`:那一个由已退役的旧定时日报链路发出, + * 保留它只为了让历史审计记录仍可读。新链路用新 purpose,便于在审计里区分两代实现。 + */ + scheduledReport: 'automation_scheduled_report', + /** + * 定时日报的**后置词**发送(图片 sent 之后补发的那条文本)。 + * + * 与图片用**不同的 purpose**:两条消息是两次独立发送,各自要有自己的幂等位 —— + * 共用一个 key 会让「图片先发成功、后置词那次被短路」变成常态。 + */ + scheduledReportPostfix: 'automation_scheduled_report_postfix' } as const /** 审计日志里的来源标识(`WechatActionOrigin`)。 */ @@ -286,6 +705,8 @@ export interface AutomationRuleDraft { replyDelaySeconds: number /** `ruleType === 'leave_notification'` 时的通知配置。 */ leaveNotification?: LeaveNotificationConfig + /** `ruleType === 'scheduled_report'` 时的日报配置。 */ + scheduledReport?: ScheduledReportAutomationConfig } /** 顶部状态条数据(全部来自 main 侧真实能力,UI 不做平台判断)。 */ @@ -310,27 +731,20 @@ export interface AutomationStatusSummary { /** * 规则列表里的**模板入口**。 * - * `available: false` 表示该能力本轮**尚未接通** —— UI 必须显示为「未启用 / 即将支持」, - * 不允许伪装成可运行。这是防止设计稿倒逼假数据的硬约束。 + * `available: false` 表示该能力**尚未接通** —— UI 必须显示为「未启用 / 即将支持」, + * 不得伪装成可运行;否则设计稿会倒逼出假数据。 */ export interface AutomationRuleTemplate { id: string name: string description: string - /** 本轮是否已接通的真实能力。 */ + /** 该能力是否已经真正接通。 */ available: boolean /** 未接通时的说明文案。 */ unavailableReason?: string } export const AUTOMATION_RULE_TEMPLATES: AutomationRuleTemplate[] = [ - { - id: 'scheduled-report', - name: '定时发送日报', - description: '按固定时间自动生成并发送群日报', - available: false, - unavailableReason: '' - }, { id: 'keyword-reply', name: '关键词自动回复', @@ -360,6 +774,11 @@ export const AUTOMATION_STEP_LABELS: Record = { reply: '回复确认', report: '生成日报', send: '发送日报图片', + schedule_triggered: '定时任务触发', + report_generating: '生成日报', + report_generated: '日报已生成', + send_resolved: '确定发送目标', + report_sent: '发送日报', exit_received: '检测到成员退出', exit_matched: '规则匹配', exit_target: '确定通知目标', @@ -450,13 +869,41 @@ export const LEAVE_NOTIFICATION_TARGET_OPTIONS: ReadonlyArray<{ */ export const LEAVE_NOTIFICATION_DEFAULT_TARGET_TYPE: LeaveNotificationTargetType = 'source_chat' +/** + * 通知范围的默认值:全部已监控群聊。 + * + * 选 `all` 而不是 `selected`:新装用户还没做过二次勾选,此时"不通知任何群" + * 会让规则看起来启用了却永远静默 —— 与旧版 `source_chat` 的既有行为也不一致。 + */ +export const LEAVE_NOTIFICATION_DEFAULT_NOTIFY_SCOPE: LeaveNotificationNotifyScope = 'all' + +/** + * 归一化「通知群聊」子集。 + * + * 去重 + 去空白 + 丢非字符串。**不**在这里与监控范围求交集 —— + * shared 读不到退群监控的状态,硬做只会得到第二份"监控范围"的真相。 + */ +export function normalizeLeaveNotificationRoomIds(input: unknown): string[] { + if (!Array.isArray(input)) return [] + const result: string[] = [] + const seen = new Set() + for (const value of input) { + if (typeof value !== 'string') continue + const roomId = value.trim() + if (!roomId || seen.has(roomId)) continue + seen.add(roomId) + result.push(roomId) + } + return result +} + /** 目标类型 → 界面短标签。 */ export function leaveNotificationTargetLabel(type: LeaveNotificationTargetType): string { return LEAVE_NOTIFICATION_TARGET_OPTIONS.find((option) => option.type === type)?.label ?? '' } /** - * 「范围」摘要:退群通知覆盖多少个已监控群聊。 + * 「范围」摘要:退群监控覆盖多少个已监控群聊。 * * 数字**必须**来自退群监控(`GroupExitMonitorState`),自动化不维护副本。 */ @@ -464,6 +911,40 @@ export function describeMonitoredScope(monitoredCount: number): string { return `${Math.max(0, Math.floor(Number(monitoredCount) || 0))} 个已监控群聊` } +/** + * 「通知范围」摘要:二次筛选之后的实际覆盖面。 + * + * 与 `describeMonitoredScope` 的区别就是这一层筛选: + * 前者说"监控了几个群",这里说"其中几个会发通知"。 + */ +export function describeLeaveNotificationNotifyScope( + config: LeaveNotificationConfig | undefined, + monitoredCount: number +): string { + const monitored = Math.max(0, Math.floor(Number(monitoredCount) || 0)) + if (config?.notifyScope !== 'selected') return `${monitored} 个已监控群聊(全部)` + const selected = normalizeLeaveNotificationRoomIds(config.notifyRoomIds).length + // 一个都没勾 = 明确"不通知任何群",如实说,不退回"全部"。 + if (!selected) return '未选中任何群聊' + return `${selected} / ${monitored} 个已监控群聊` +} + +/** + * 判定某个群是否在通知范围内。 + * + * main(执行闸门)与 UI(预览/摘要)共用这一处,避免"界面说会发、实际不发"。 + */ +export function leaveNotificationCoversRoom( + config: LeaveNotificationConfig | undefined, + roomId: string +): boolean { + if (!config) return false + if (config.notifyScope !== 'selected') return true + const target = String(roomId || '').trim() + if (!target) return false + return normalizeLeaveNotificationRoomIds(config.notifyRoomIds).includes(target) +} + /** * 目标的一句话描述(首页卡片摘要用)。 * @@ -480,9 +961,24 @@ export function describeLeaveNotificationTarget( return leaveNotificationTargetLabel(target.type) } +/** 目标的一句话描述(首页卡片摘要用)。 */ +export function describeScheduledReportTarget( + config: ScheduledReportAutomationConfig | undefined, + contactDisplayName?: string +): string { + const target = config?.target + if (!target) return '未配置' + if (target.type === 'contact') return contactDisplayName?.trim() || '未选择好友' + return scheduledReportTargetLabel(target.type) +} + /** 触发条件的一句话摘要(UI 展示用,不含任何内部 id)。 */ export function describeRuleTrigger(rule: AutomationRule): string { if (rule.ruleType === 'leave_notification') return '检测到群成员退出' + if (rule.ruleType === 'scheduled_report') { + const time = rule.scheduledReport?.schedule?.time + return time ? `每天 ${time}` : '按设定时间' + } const parts: string[] = [] if (rule.conditions.requireMentionMe) parts.push('@我') if (rule.conditions.keyword.trim()) { @@ -494,6 +990,11 @@ export function describeRuleTrigger(rule: AutomationRule): string { /** 动作链的一句话摘要。 */ export function describeRuleActions(rule: AutomationRule): string { if (rule.ruleType === 'leave_notification') return '发送退群通知' + if (rule.ruleType === 'scheduled_report') { + return `生成并发送${scheduledReportRangeLabel( + rule.scheduledReport?.report?.range ?? SCHEDULED_REPORT_DEFAULT_RANGE + )}日报` + } const labels: Record = { replyText: '回复确认', generateReport: '生成日报', @@ -550,6 +1051,8 @@ export function createDefaultLeaveNotificationRule( target?: LeaveNotificationTarget enabled?: boolean targetNeedsReview?: boolean + notifyScope?: LeaveNotificationNotifyScope + notifyRoomIds?: string[] } = {} ): AutomationRule { return { @@ -576,6 +1079,9 @@ export function createDefaultLeaveNotificationRule( leaveNotification: { target: options.target ?? { type: LEAVE_NOTIFICATION_DEFAULT_TARGET_TYPE }, template: normalizeLeaveNotificationTemplate(options.template), + // 新装 / 无历史配置 = 全部已监控群聊都通知(与旧版"每个群都勾上"等价)。 + notifyScope: options.notifyScope ?? LEAVE_NOTIFICATION_DEFAULT_NOTIFY_SCOPE, + notifyRoomIds: normalizeLeaveNotificationRoomIds(options.notifyRoomIds), ...(options.targetNeedsReview ? { targetNeedsReview: true } : {}) }, createdAt: now, @@ -593,6 +1099,59 @@ export function normalizeLeaveNotificationTemplate(value: unknown): string { return normalizeGroupExitNotificationTemplate(value) } +/** 定时日报规则的默认展示名。 */ +export const SCHEDULED_REPORT_RULE_NAME = '定时日报' + +/** 新建定时日报时的默认名字(与旧页面「<群名> · 每日日报」同风格)。 */ +export function suggestedScheduledReportName(groupDisplayName?: string): string { + const name = String(groupDisplayName ?? '').trim() + return name ? `${name} · 每日日报` : SCHEDULED_REPORT_RULE_NAME +} + +/** + * 构造一条「定时日报」规则。 + * + * ⚠️ 这**不是** singleton —— 定时日报是 0..N 条, + * 所以这里没有固定 id,id 一律由 store 生成 / 由迁移复用旧 taskId。 + */ +export function createDefaultScheduledReportRule( + now: number, + options: { + id?: string + name?: string + enabled?: boolean + config?: ScheduledReportAutomationConfig + } = {} +): AutomationRule { + const baseConfig = normalizeScheduledReportConfig( + options.config ?? { schedule: { time: SCHEDULED_REPORT_DEFAULT_TIME } } + ) + return { + id: options.id ?? '', + name: options.name?.trim() || SCHEDULED_REPORT_RULE_NAME, + enabled: options.enabled !== false, + ruleType: 'scheduled_report', + // 由时间驱动,不参与消息匹配。这两个字段保留只是为了 schema 完整。 + trigger: 'message', + scope: 'group', + conditions: { + requireMentionMe: false, + keyword: '', + keywordMatchMode: 'contains', + conversationIds: [], + ignoreSelf: true + }, + // 定时日报没有动作链,动作由 scheduledReport 表达。 + actions: [], + // 不套用消息型规则的 cooldown:定时规则有自己的 ruleId inFlight 锁。 + cooldownSeconds: 0, + replyDelaySeconds: AUTOMATION_REPLY_DELAY_DEFAULT_SECONDS, + scheduledReport: baseConfig, + createdAt: now, + updatedAt: now + } +} + /** 归一化用户提交的草稿:把未知值收敛成合法值,避免脏数据落盘。 */ export function normalizeRuleDraft( input: unknown, @@ -601,7 +1160,11 @@ export function normalizeRuleDraft( const raw = (input ?? {}) as Partial const conditions = (raw.conditions ?? {}) as Partial const ruleType: AutomationRuleType = - raw.ruleType === 'leave_notification' ? 'leave_notification' : 'daily_report' + raw.ruleType === 'leave_notification' + ? 'leave_notification' + : raw.ruleType === 'scheduled_report' + ? 'scheduled_report' + : 'daily_report' const keywordMatchMode: KeywordMatchMode = conditions.keywordMatchMode === 'exact' || conditions.keywordMatchMode === 'prefix' ? conditions.keywordMatchMode @@ -642,8 +1205,13 @@ export function normalizeRuleDraft( cooldownSeconds: Number.isFinite(cooldown) ? Math.max(0, Math.floor(cooldown)) : 60, replyDelaySeconds: normalizeReplyDelaySeconds(raw.replyDelaySeconds) } - if (ruleType !== 'leave_notification') return base - return { ...base, leaveNotification: normalizeLeaveNotificationConfig(raw.leaveNotification) } + if (ruleType === 'leave_notification') { + return { ...base, leaveNotification: normalizeLeaveNotificationConfig(raw.leaveNotification) } + } + if (ruleType === 'scheduled_report') { + return { ...base, scheduledReport: normalizeScheduledReportConfig(raw.scheduledReport) } + } + return base } /** @@ -652,6 +1220,9 @@ export function normalizeRuleDraft( * 目标是**必填**的:缺失一律回落到默认目标(文件传输助手), * 而不是留一个"没有目标"的规则 —— 那种规则在运行时只能失败。 * `contact` 缺 contactId 时同样回落,避免存下一条永远发不出去的规则。 + * + * `notifyScope` 缺失 → 回落 `all`(**不是** `selected`):已经落盘的规则 + * 都是在"没有二次筛选"的版本下保存的,默认成 `selected` 会让它们静默停发。 */ export function normalizeLeaveNotificationConfig(input: unknown): LeaveNotificationConfig { const raw = (input ?? {}) as Partial @@ -672,6 +1243,8 @@ export function normalizeLeaveNotificationConfig(input: unknown): LeaveNotificat ...(effectiveType === 'contact' ? { contactId } : {}) }, template: normalizeLeaveNotificationTemplate(raw.template), + notifyScope: raw.notifyScope === 'selected' ? 'selected' : LEAVE_NOTIFICATION_DEFAULT_NOTIFY_SCOPE, + notifyRoomIds: normalizeLeaveNotificationRoomIds(raw.notifyRoomIds), // 用户手动保存一次即视为已确认目标,清除迁移提示。 ...(raw.targetNeedsReview === true ? { targetNeedsReview: true } : {}) } diff --git a/tests/component/group-exit-monitor-workspace.test.tsx b/tests/component/group-exit-monitor-workspace.test.tsx index 115e90c..6c499a9 100644 --- a/tests/component/group-exit-monitor-workspace.test.tsx +++ b/tests/component/group-exit-monitor-workspace.test.tsx @@ -1,8 +1,11 @@ import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import type { ReactElement } from 'react' +import * as React from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { GroupExitMonitorWorkspace } from '../../src/renderer/src/features/group-exit-monitor/GroupExitMonitorWorkspace' +import { + GroupExitMonitorWorkspace, + type GroupExitMonitorOpenViewRequest +} from '../../src/renderer/src/features/group-exit-monitor/GroupExitMonitorWorkspace' import type { GroupExitMonitorState } from '../../src/shared/group-exit-monitor' import { formatGroupExitMonitorTime } from '../../src/shared/group-exit-monitor' import type { Contact } from '../../src/shared/types' @@ -69,7 +72,7 @@ const groups: Contact[] = [ ] describe('GroupExitMonitorWorkspace', () => { - const renderWorkspace = (element: ReactElement): void => { + const renderWorkspace = (element: React.ReactElement): void => { render({element}) } @@ -387,4 +390,63 @@ describe('GroupExitMonitorWorkspace', () => { expect(screen.getByRole('checkbox', { name: '监控研发群' })).not.toBeChecked() expect(screen.getByRole('checkbox', { name: '监控设计群' })).not.toBeChecked() }) + + /* + * 深链请求必须是**一次性**的。 + * + * 真实拓扑里请求挂在 App,而本组件随一级菜单切换被卸载/重挂。 + * 修复前 effect 只 `setView` 不回报父层,请求永远不清 —— 于是重挂时 + * 那条陈旧的 `{view:'manage'}` 被重放,用户看到的现象就是 + * "点一级菜单退群监控,却跳到了管理群聊"。 + */ + it('深链落到「管理群聊」,并立刻回报父层消费掉请求', async () => { + const onHandled = vi.fn() + renderWorkspace( + + ) + + expect(await screen.findByRole('heading', { name: '管理群聊' })).toBeVisible() + await waitFor(() => expect(onHandled).toHaveBeenCalledTimes(1)) + }) + + it('请求被消费后重新挂载,回到「退群事件」而不是「管理群聊」', async () => { + function Harness(): React.ReactElement { + const [request, setRequest] = React.useState({ + view: 'manage', + requestId: 1 + }) + const [mounted, setMounted] = React.useState(true) + return ( + <> + + {mounted ? ( + setRequest(null)} + /> + ) : null} + + ) + } + + const user = userEvent.setup() + renderWorkspace() + + // 第一次:深链确实生效。 + expect(await screen.findByRole('heading', { name: '管理群聊' })).toBeVisible() + + // 离开再回来(等价于点别的菜单再点「退群监控」)。 + await user.click(screen.getByRole('button', { name: '切换页面' })) + await user.click(screen.getByRole('button', { name: '切换页面' })) + + expect(await screen.findByText('小艾退出了研发群')).toBeVisible() + expect(screen.queryByRole('heading', { name: '管理群聊' })).toBeNull() + }) }) diff --git a/tests/component/leave-notification-ui.test.tsx b/tests/component/leave-notification-ui.test.tsx index f13149d..8c9c59a 100644 --- a/tests/component/leave-notification-ui.test.tsx +++ b/tests/component/leave-notification-ui.test.tsx @@ -58,7 +58,11 @@ interface ApiSpies { } function installApi( - overrides: { leaveRule?: AutomationRule } = {} + overrides: { + leaveRule?: AutomationRule + /** `listAutomationGroups` 的返回:把 roomId 映射成可读群名。 */ + automationGroups?: Array<{ id: string; name: string }> + } = {} ): { spies: ApiSpies; rules: AutomationRule[] } { const leaveRule = overrides.leaveRule ?? createDefaultLeaveNotificationRule(Date.now()) const rules = [createDefaultDailyReportRule(Date.now()), leaveRule] @@ -92,7 +96,7 @@ function installApi( } }), listAutomationRules: spies.listAutomationRules, - listAutomationGroups: vi.fn().mockResolvedValue([]), + listAutomationGroups: vi.fn().mockResolvedValue(overrides.automationGroups ?? []), listAutomationExecutions: vi.fn().mockResolvedValue([]), listSendableContacts: spies.listSendableContacts, getGroupExitMonitorState: vi.fn().mockResolvedValue(MONITOR_STATE), @@ -128,6 +132,7 @@ async function leaveNotificationCard(): Promise { /** 从自动化首页那张退群通知卡片的「编辑」进编辑器。 */ async function openLeaveNotificationEditor(overrides: { leaveRule?: AutomationRule + automationGroups?: Array<{ id: string; name: string }> } = {}): Promise<{ spies: ApiSpies }> { const user = userEvent.setup() const { spies } = installApi(overrides) @@ -218,12 +223,17 @@ describe('退群通知 · 真实数据', () => { expect(screen.queryByRole('radio', { name: '张三' })).toBeNull() }) - it('Section 1 没有「触发范围」,只有唯一的「管理监控群聊」入口', async () => { + /* + * 旧版「退群监控 → 通知群聊」的第二层。它曾在迁移里被删掉, + * 导致"规则一启用就全量通知" —— 所以这里锁的是它**必须存在**。 + */ + it('Section 1 有「通知范围」二次勾选,且保留唯一的「管理监控群聊」入口', async () => { await openLeaveNotificationEditor() + // 「触发事件」仍不可配(由退群监控驱动),但这不等于不能筛群。 expect(screen.queryByText('触发范围')).toBeNull() - expect(screen.queryByText('全部已监控群聊')).toBeNull() - expect(screen.queryByText('指定已监控群聊')).toBeNull() + expect(screen.getByRole('radio', { name: '全部已监控群聊' })).toBeChecked() + expect(screen.getByRole('radio', { name: '仅选中的群聊' })).toBeEnabled() expect(screen.getByRole('button', { name: /管理监控群聊/ })).toBeEnabled() }) @@ -236,6 +246,88 @@ describe('退群通知 · 真实数据', () => { }) }) +const MONITORED_GROUPS = [ + { id: 'a@chatroom', name: '甲群' }, + { id: 'b@chatroom', name: '乙群' }, + { id: 'c@chatroom', name: '丙群' } +] + +/** + * 「通知范围」二次勾选 —— 旧版「退群监控 → 通知群聊」的第二层。 + * + * 这组测试锁的是**回归本身**:缺了这一层,规则一启用就等于给全部已监控群聊发通知。 + */ +describe('退群通知 · 通知范围(二次勾选)', () => { + it('默认是「全部已监控群聊」,预览里如实说明覆盖范围', async () => { + await openLeaveNotificationEditor({ automationGroups: MONITORED_GROUPS }) + + expect(screen.getByRole('radio', { name: '全部已监控群聊' })).toBeChecked() + expect(previewFact('通知范围')).toBe('3 个已监控群聊(全部)') + }) + + it('切到「仅选中的群聊」才列候选,且候选只含已监控的群', async () => { + const user = userEvent.setup() + await openLeaveNotificationEditor({ automationGroups: MONITORED_GROUPS }) + + // 还没切过去时不该出现候选项。 + expect(screen.queryByRole('checkbox', { name: '甲群' })).toBeNull() + + await user.click(screen.getByRole('radio', { name: '仅选中的群聊' })) + + for (const name of ['甲群', '乙群', '丙群']) { + expect(screen.getByRole('checkbox', { name })).not.toBeChecked() + } + // 一个都没勾 = 明确不通知,而不是悄悄退回"全部"。 + expect(screen.getByText(/一个群都没勾选/)).toBeVisible() + }) + + it('勾中的群会被保存成 notifyScope=selected + 那份子集', async () => { + const user = userEvent.setup() + const { spies } = await openLeaveNotificationEditor({ automationGroups: MONITORED_GROUPS }) + + await user.click(screen.getByRole('radio', { name: '仅选中的群聊' })) + await user.click(screen.getByRole('checkbox', { name: '甲群' })) + await user.click(screen.getByRole('checkbox', { name: '丙群' })) + await user.click(screen.getByRole('button', { name: '保存' })) + + await waitFor(() => expect(spies.saveLeaveNotificationRule).toHaveBeenCalledTimes(1)) + const draft = spies.saveLeaveNotificationRule.mock.calls[0][0] as AutomationRule + expect(draft.leaveNotification?.notifyScope).toBe('selected') + // 落盘顺序按候选项顺序(本文件里候选按群名拼音排),所以这里只比较集合: + // 勾选先后不该影响存下来的内容。 + expect([...(draft.leaveNotification?.notifyRoomIds ?? [])].sort()).toEqual([ + 'a@chatroom', + 'c@chatroom' + ]) + }) + + it('没动过范围时保存下来的是 all,不会顺手把用户缩到子集', async () => { + const user = userEvent.setup() + const { spies } = await openLeaveNotificationEditor({ automationGroups: MONITORED_GROUPS }) + + await user.click(screen.getByRole('button', { name: '保存' })) + + await waitFor(() => expect(spies.saveLeaveNotificationRule).toHaveBeenCalledTimes(1)) + const draft = spies.saveLeaveNotificationRule.mock.calls[0][0] as AutomationRule + expect(draft.leaveNotification?.notifyScope).toBe('all') + }) + + it('旧版迁移过来的子集在界面上是勾好的,不是空选', async () => { + await openLeaveNotificationEditor({ + automationGroups: MONITORED_GROUPS, + leaveRule: createDefaultLeaveNotificationRule(Date.now(), { + notifyScope: 'selected', + notifyRoomIds: ['b@chatroom'] + }) + }) + + expect(screen.getByRole('radio', { name: '仅选中的群聊' })).toBeChecked() + expect(screen.getByRole('checkbox', { name: '乙群' })).toBeChecked() + expect(screen.getByRole('checkbox', { name: '甲群' })).not.toBeChecked() + expect(previewFact('通知范围')).toBe('1 / 3 个已监控群聊') + }) +}) + describe('退群通知 · 四种目标', () => { it('是四选一,「当前群聊」排第一且默认选中', async () => { await openLeaveNotificationEditor() @@ -479,7 +571,10 @@ describe('退群通知 · 群聊名护栏', () => { ...createDefaultLeaveNotificationRule(Date.now()), leaveNotification: { target: { type: 'source_chat' }, - template: '[退群监测]\n\n用户: {user}' + template: '[退群监测]\n\n用户: {user}', + // 明确写全:本组用例只关心群名护栏,范围取默认的"全部已监控群聊"。 + notifyScope: 'all', + notifyRoomIds: [] } }) diff --git a/tests/component/scheduled-report-automation.test.tsx b/tests/component/scheduled-report-automation.test.tsx index 1fd699d..99894ba 100644 --- a/tests/component/scheduled-report-automation.test.tsx +++ b/tests/component/scheduled-report-automation.test.tsx @@ -8,82 +8,95 @@ import { AutomationWorkspace, type AutomationOpenRuleRequest } from '../../src/renderer/src/features/automation/AutomationWorkspace' -import { ScheduledReportsWorkspace } from '../../src/renderer/src/components/reports/ScheduledReportsWorkspace' import { createDefaultDailyReportRule, - createDefaultLeaveNotificationRule + createDefaultLeaveNotificationRule, + createDefaultScheduledReportRule, + normalizeScheduledReportConfig, + type AutomationRule } from '../../src/shared/automation' -import type { ScheduledReportTask } from '../../src/shared/scheduled-report' +import { SUMMARY_TYPE_OPTIONS } from '../../src/renderer/src/utils/group-report' /** * 「自动化 → 规则 → 定时日报」。 * - * 这一组测试要守住的核心不是"界面长什么样",而是**边界**: - * 任何写操作都不许落到真实定时日报系统上 - * (create / update / delete / setEnabled / runNow / retry / 异常通知开关)。 + * 迁移前这一组测试守的是**边界**("任何写操作都不许落盘")。 + * 迁移后 `scheduled_report` 已经是真实的自动化规则类型,边界随之反转: + * 现在要守的是**写真的发生、且写在正确的通道上**: + * - 保存 → `createAutomationRule` / `updateAutomationRule`,且 `ruleType === 'scheduled_report'`; + * - 立即执行 → `runScheduledReportRule`(与 scheduler 同一条链路); + * - 启停 → `setAutomationRuleEnabled`; + * - 删除 → `deleteAutomationRule`; + * - 且**没有任何** `scheduled-report:*` 旧通道残留。 */ -const TASKS: ScheduledReportTask[] = [ - { - id: 'task-1', +const RULES_FIXTURE: AutomationRule[] = [ + createDefaultScheduledReportRule(Date.now(), { + id: 'rule-1', name: 'TraceMemo 每日晚报', - group: 'TraceMemo 交流群', - scheduleTime: '18:21', - reportRange: 'today', - target: 'TraceMemo 管理群', - enabled: true, - createdAt: '2026-09-01T00:00:00.000Z', - updatedAt: '2026-09-01T00:00:00.000Z', - nextRunAt: '2026-09-24T10:21:00.000Z' - }, - { - id: 'task-2', + config: normalizeScheduledReportConfig({ + schedule: { time: '18:21' }, + report: { + sourceConversationId: 'g1@chatroom', + range: 'today', + messageTypes: ['text', 'image'], + templateId: 'v1', + memberNameMode: 'groupNickname', + timeoutSeconds: 300 + }, + target: { type: 'source_chat' } + }) + }), + createDefaultScheduledReportRule(Date.now(), { + id: 'rule-2', name: '技术交流群日报', - group: '技术交流群', - scheduleTime: '09:00', - reportRange: 'yesterday', - target: '技术交流群', enabled: false, - createdAt: '2026-09-01T00:00:00.000Z', - updatedAt: '2026-09-01T00:00:00.000Z', - nextRunAt: '2026-09-25T01:00:00.000Z' - } + config: normalizeScheduledReportConfig({ + schedule: { time: '09:00' }, + report: { + sourceConversationId: 'g2@chatroom', + range: 'yesterday', + templateId: 'v1', + memberNameMode: 'groupNickname', + timeoutSeconds: 300 + }, + target: { type: 'file_transfer' } + }) + }) ] interface Spies { - createScheduledReport: ReturnType - updateScheduledReport: ReturnType - deleteScheduledReport: ReturnType - setScheduledReportEnabled: ReturnType - runScheduledReportNow: ReturnType - retryScheduledReportSend: ReturnType - testScheduledReportErrorNotification: ReturnType - listScheduledReports: ReturnType + createRule: ReturnType + updateRule: ReturnType + deleteRule: ReturnType + setEnabled: ReturnType + runScheduled: ReturnType + setNotification: ReturnType } -/** 所有"写操作"的集合:任何一条被调用都说明界面越界了。 */ -function writeSpies(spies: Spies): Array<[string, ReturnType]> { - return [ - ['createScheduledReport', spies.createScheduledReport], - ['updateScheduledReport', spies.updateScheduledReport], - ['deleteScheduledReport', spies.deleteScheduledReport], - ['setScheduledReportEnabled', spies.setScheduledReportEnabled], - ['runScheduledReportNow', spies.runScheduledReportNow], - ['retryScheduledReportSend', spies.retryScheduledReportSend], - ['testScheduledReportErrorNotification', spies.testScheduledReportErrorNotification] - ] -} - -function installApi(tasks: ScheduledReportTask[] = TASKS): Spies { +function installApi(rules: AutomationRule[] = RULES_FIXTURE): Spies { const spies: Spies = { - listScheduledReports: vi.fn().mockResolvedValue(tasks), - createScheduledReport: vi.fn(), - updateScheduledReport: vi.fn(), - deleteScheduledReport: vi.fn(), - setScheduledReportEnabled: vi.fn(), - runScheduledReportNow: vi.fn(), - retryScheduledReportSend: vi.fn(), - testScheduledReportErrorNotification: vi.fn() + createRule: vi.fn().mockImplementation((draft: unknown) => + Promise.resolve({ + ...(draft as AutomationRule), + id: 'rule-new', + createdAt: Date.now(), + updatedAt: Date.now() + }) + ), + updateRule: vi.fn().mockImplementation((id: string, draft: unknown) => + Promise.resolve({ ...(draft as AutomationRule), id, createdAt: 0, updatedAt: 0 }) + ), + deleteRule: vi.fn().mockResolvedValue(true), + setEnabled: vi.fn().mockImplementation((id: string, enabled: boolean) => { + const found = rules.find((rule) => rule.id === id) + return Promise.resolve(found ? { ...found, enabled } : null) + }), + runScheduled: vi.fn().mockResolvedValue({ + success: true, + data: { executed: true, executionId: 'exec-1', status: 'success', reportGenerated: true } + }), + setNotification: vi.fn().mockResolvedValue({ success: true, data: { enabled: true } }) } window.api = { @@ -102,12 +115,17 @@ function installApi(tasks: ScheduledReportTask[] = TASKS): Spies { }), listAutomationRules: vi.fn().mockResolvedValue([ createDefaultDailyReportRule(Date.now()), - createDefaultLeaveNotificationRule(Date.now()) + createDefaultLeaveNotificationRule(Date.now()), + ...rules ]), + createAutomationRule: spies.createRule, + updateAutomationRule: spies.updateRule, + deleteAutomationRule: spies.deleteRule, + setAutomationRuleEnabled: spies.setEnabled, listAutomationGroups: vi.fn().mockResolvedValue([ - { id: 'g1', name: 'TraceMemo 交流群' }, - { id: 'g2', name: '技术交流群' }, - { id: 'g3', name: 'TraceMemo 管理群' } + { id: 'g1@chatroom', name: 'TraceMemo 交流群' }, + { id: 'g2@chatroom', name: '技术交流群' }, + { id: 'g3@chatroom', name: 'TraceMemo 管理群' } ]), listAutomationExecutions: vi.fn().mockResolvedValue([]), listSendableContacts: vi.fn().mockResolvedValue([]), @@ -123,11 +141,15 @@ function installApi(tasks: ScheduledReportTask[] = TASKS): Spies { unreadCount: 0 }), onGroupExitMonitorState: vi.fn(() => () => undefined), - // 旧「日报 → 定时日报」页面 mount 时会读这几项;只读、与预览无关。 - listScheduledReportExecutions: vi.fn().mockResolvedValue([]), - getPersonalWechatSendCapability: vi.fn().mockResolvedValue(null), + runScheduledReportRule: spies.runScheduled, + listScheduledReportLegacyExecutions: vi.fn().mockResolvedValue([]), getScheduledReportNotificationSettings: vi.fn().mockResolvedValue({ enabled: false }), - ...spies + getScheduledReportNotificationCapability: vi.fn().mockResolvedValue({ + ready: false, + error: '需要先连接 Agent Hub 微信机器人,才能接收异常通知。' + }), + setScheduledReportNotificationEnabled: spies.setNotification, + testScheduledReportErrorNotification: vi.fn().mockResolvedValue({ success: true }) } as unknown as typeof window.api return spies @@ -174,11 +196,29 @@ describe('自动化 · 规则类型 Tab(定时日报)', () => { expect(await screen.findByRole('heading', { name: '定时日报' })).toBeVisible() expect(screen.getByText('按设定时间自动生成日报,并发送到指定微信会话。')).toBeVisible() expect(screen.getByText('2 条规则')).toBeVisible() - // 列表态不该出现编辑器的保存按钮。 expect(screen.queryByRole('button', { name: '保存' })).toBeNull() }) - it('列表按真实字段渲染(触发 / 日报 / 来源 / 发送到 / 下次执行)', async () => { + it('列表层有「返回规则列表」入口,不切 tab 也能退出', async () => { + installApi() + renderWorkspace() + await openScheduledTab() + const user = userEvent.setup() + + /* + * 这里以前是个死胡同:定时日报先落列表,而列表只有新建/编辑/立即执行/删除, + * 页面级「新建自动化」又因编辑态被隐藏,顶部 规则/执行日志 切换条同样被隐藏 —— + * 唯一的出路竟然是切到别的规则类型 tab,再从那边的「取消」绕回来。 + */ + const back = await screen.findByRole('button', { name: /返回规则列表/ }) + await user.click(back) + + // 回到规则面板(三个类型卡片),本类型的 tab 条随之消失。 + expect(await screen.findByText('退群通知')).toBeVisible() + expect(screen.queryByRole('radio', { name: '定时日报' })).toBeNull() + }) + + it('列表按真实规则字段渲染(触发 / 日报 / 来源 / 模板 / 发送到)', async () => { installApi() renderWorkspace() await openScheduledTab() @@ -194,41 +234,53 @@ describe('自动化 · 规则类型 Tab(定时日报)', () => { ['日报', '今日'], ['来源', 'TraceMemo 交流群'], ['模板', '经典日报'], - // target 与来源群不同 ⇒ 显示 target 的群名;相同时会显示「日报来源群」。 - ['发送到', 'TraceMemo 管理群'], + ['发送到', '发送到日报来源群(TraceMemo 交流群)'], ['下次执行', expect.any(String)] ]) - // 暂停的任务下一次执行显示破折号,而不是一个会误导的时间。 + + // 暂停的规则不再给出"下次执行"时间,避免误导。 const paused = (await screen.findByText('技术交流群日报')).closest('article') as HTMLElement expect(paused.textContent).toContain('已暂停') expect(within(paused).getByText('—')).toBeVisible() - // 这条的 target 与来源群相同 ⇒ 用「日报来源群」这种语义文案,而不是重复群名。 - expect(paused.textContent).toContain('日报来源群') + expect(paused.textContent).toContain('文件传输助手') }) }) -describe('定时日报 · 编辑器与预览联动', () => { - it('点「+ 新建定时日报」进入编辑器(三段 + 预览)', async () => { - installApi() +describe('定时日报 · 真写入', () => { + it('保存新规则 → createAutomationRule,且 ruleType 与完整配置都对', async () => { + const spies = installApi() renderWorkspace() await openScheduledTab() - const user = userEvent.setup() await user.click(await screen.findByRole('button', { name: '+ 新建定时日报' })) - expect(screen.getByRole('heading', { name: '1 · 什么时候触发' })).toBeVisible() - expect(screen.getByRole('heading', { name: '2 · 生成什么日报' })).toBeVisible() - expect(screen.getByRole('heading', { name: '3 · 生成后发送到哪里' })).toBeVisible() - expect(screen.getByRole('heading', { name: '效果预览' })).toBeVisible() - // 只有一个开关:规则级启停。 - expect(screen.getAllByRole('switch')).toHaveLength(1) + await user.type(screen.getByLabelText('定时日报任务名称'), '新的定时日报') + await user.click( + within(screen.getByRole('radiogroup', { name: '日报来源' })).getByRole('radio', { + name: '技术交流群' + }) + ) + await user.click(screen.getByRole('button', { name: '保存' })) + + await waitFor(() => expect(spies.createRule).toHaveBeenCalledTimes(1)) + const draft = spies.createRule.mock.calls[0][0] as { + name: string + ruleType: string + scheduledReport: { report: { sourceConversationId: string }; target: { type: string } } + } + expect(draft.name).toBe('新的定时日报') + expect(draft.ruleType).toBe('scheduled_report') + // 来源群存的是**稳定会话 id**,不是群名。 + expect(draft.scheduledReport.report.sourceConversationId).toBe('g2@chatroom') + // 新建默认发到文件传输助手(不会误打扰群聊)。 + expect(draft.scheduledReport.target.type).toBe('file_transfer') + expect(spies.updateRule).not.toHaveBeenCalled() }) - it('点卡片「编辑」进入编辑器,并带出该任务的值', async () => { - installApi() + it('编辑既有规则 → updateAutomationRule(不是新建)', async () => { + const spies = installApi() renderWorkspace() await openScheduledTab() - const user = userEvent.setup() const card = (await screen.findByText('TraceMemo 每日晚报')).closest('article') as HTMLElement await user.click(within(card).getByRole('button', { name: '编辑' })) @@ -236,103 +288,31 @@ describe('定时日报 · 编辑器与预览联动', () => { expect(screen.getByLabelText('定时日报任务名称')).toHaveValue('TraceMemo 每日晚报') expect(screen.getByLabelText('执行时间小时')).toHaveValue('18') expect(screen.getByLabelText('执行时间分钟')).toHaveValue('21') - expect( - within(screen.getByRole('radiogroup', { name: '日报来源' })).getByRole('radio', { - name: 'TraceMemo 交流群' - }) - ).toBeChecked() + + await user.click(screen.getByRole('button', { name: '保存' })) + await waitFor(() => expect(spies.updateRule).toHaveBeenCalledTimes(1)) + expect(spies.updateRule.mock.calls[0][0]).toBe('rule-1') + expect(spies.createRule).not.toHaveBeenCalled() }) - /* - * 任务里存的群名与群列表是两个数据源,不保证一致 - * (群被移出监控、或列表来源变了)。这时**必须回显当前值**, - * 否则编辑已有任务会出现"一串群里没有任何一项被选中"。 - */ - it('来源群不在群列表里时,仍会作为候选项被选中', async () => { - installApi([{ ...TASKS[0], group: '已不在列表的群', target: '已不在列表的群' }]) - renderWorkspace() - await openScheduledTab() - - const user = userEvent.setup() - const card = (await screen.findByText('TraceMemo 每日晚报')).closest('article') as HTMLElement - await user.click(within(card).getByRole('button', { name: '编辑' })) - - expect( - within(screen.getByRole('radiogroup', { name: '日报来源' })).getByRole('radio', { - name: '已不在列表的群' - }) - ).toBeChecked() - }) - - it('改执行时间 → 预览与「预计下次执行」同时更新', async () => { - installApi() - renderWorkspace() - await openScheduledTab() - const user = userEvent.setup() - await user.click(await screen.findByRole('button', { name: '+ 新建定时日报' })) - - const hourInput = screen.getByLabelText('执行时间小时') - await user.clear(hourInput) - await user.type(hourInput, '07') - const minuteInput = screen.getByLabelText('执行时间分钟') - await user.clear(minuteInput) - await user.type(minuteInput, '05') - - expect(screen.getByTestId('scheduled-next-run').textContent).toMatch(/0?7:05/) - const facts = document.querySelector('.automation-preview-facts')?.textContent ?? '' - expect(facts).toContain('每天 07:05') - }) - - it('改日报来源 → 预览的生成目标跟着变', async () => { - installApi() - renderWorkspace() - await openScheduledTab() - const user = userEvent.setup() - await user.click(await screen.findByRole('button', { name: '+ 新建定时日报' })) - - await user.click( - within(screen.getByRole('radiogroup', { name: '日报来源' })).getByRole('radio', { - name: '技术交流群' - }) - ) - - const flow = document.querySelector('.automation-preview-flow')?.textContent ?? '' - expect(flow).toContain('技术交流群') - const facts = document.querySelector('.automation-preview-facts')?.textContent ?? '' - expect(facts).toContain('技术交流群') - }) - - it('发送目标是四选一,且**没有**「指定群聊」这种"发到另一个群"的选项', async () => { - installApi() - renderWorkspace() - await openScheduledTab() - const user = userEvent.setup() - await user.click(await screen.findByRole('button', { name: '+ 新建定时日报' })) - - const group = screen.getByRole('radiogroup', { name: '生成后发送到哪里' }) - const labels = Array.from(group.querySelectorAll('label')).map((el) => el.textContent?.trim()) - expect(labels).toEqual(['文件传输助手', '发送到日报来源群', '发给自己', '指定好友']) - expect(screen.queryByRole('radio', { name: '指定群聊' })).toBeNull() - - // 新建默认 = 文件传输助手(不会误打扰群聊)。 - expect(screen.getByRole('radio', { name: '文件传输助手' })).toBeChecked() - }) - - it('保存只提示,不调用任何真实写接口', async () => { + it('缺名称 / 缺来源群时**不允许保存**,并说明原因', async () => { const spies = installApi() renderWorkspace() await openScheduledTab() const user = userEvent.setup() await user.click(await screen.findByRole('button', { name: '+ 新建定时日报' })) - await user.click(screen.getByRole('button', { name: '保存' })) - expect(await screen.findByText('UI 预览模式,配置暂未保存')).toBeVisible() - for (const [name, spy] of writeSpies(spies)) { - expect(spy, `${name} 不该被调用`).not.toHaveBeenCalled() - } + expect(screen.getByTestId('scheduled-save-blocker')).toHaveTextContent('请填写任务名称') + expect(screen.getByRole('button', { name: '保存' })).toBeDisabled() + + await user.type(screen.getByLabelText('定时日报任务名称'), '有名字了') + expect(screen.getByTestId('scheduled-save-blocker')).toHaveTextContent('请选择日报来源群') + expect(screen.getByRole('button', { name: '保存' })).toBeDisabled() + + expect(spies.createRule).not.toHaveBeenCalled() }) - it('「立即执行」不触发真实执行', async () => { + it('「立即执行」走 runScheduledReportRule(与 scheduler 同一条链路)', async () => { const spies = installApi() renderWorkspace() await openScheduledTab() @@ -340,13 +320,10 @@ describe('定时日报 · 编辑器与预览联动', () => { const card = (await screen.findByText('TraceMemo 每日晚报')).closest('article') as HTMLElement await user.click(within(card).getByRole('button', { name: '立即执行' })) - expect(await screen.findByText('UI 预览模式,不会执行真实日报任务')).toBeVisible() - for (const [name, spy] of writeSpies(spies)) { - expect(spy, `${name} 不该被调用`).not.toHaveBeenCalled() - } + await waitFor(() => expect(spies.runScheduled).toHaveBeenCalledWith('rule-1')) }) - it('toggle 只改本地状态并提示,不写回 store', async () => { + it('toggle 真写 setAutomationRuleEnabled', async () => { const spies = installApi() renderWorkspace() await openScheduledTab() @@ -354,16 +331,10 @@ describe('定时日报 · 编辑器与预览联动', () => { const card = (await screen.findByText('TraceMemo 每日晚报')).closest('article') as HTMLElement await user.click(within(card).getByRole('switch')) - - expect(await screen.findByText('UI 预览模式,配置暂未保存')).toBeVisible() - expect(within(card).getByText('已暂停')).toBeVisible() - expect(spies.setScheduledReportEnabled).not.toHaveBeenCalled() - for (const [name, spy] of writeSpies(spies)) { - expect(spy, `${name} 不该被调用`).not.toHaveBeenCalled() - } + await waitFor(() => expect(spies.setEnabled).toHaveBeenCalledWith('rule-1', false)) }) - it('删除只是提示,不移除任务', async () => { + it('删除要经过确认弹层,确认后才真删', async () => { const spies = installApi() renderWorkspace() await openScheduledTab() @@ -371,24 +342,50 @@ describe('定时日报 · 编辑器与预览联动', () => { const card = (await screen.findByText('TraceMemo 每日晚报')).closest('article') as HTMLElement await user.click(within(card).getByRole('button', { name: '删除' })) - expect(await screen.findByText('UI 预览模式,不会删除现有任务')).toBeVisible() - expect(screen.getByText('TraceMemo 每日晚报')).toBeVisible() - for (const [name, spy] of writeSpies(spies)) { - expect(spy, `${name} 不该被调用`).not.toHaveBeenCalled() - } + const dialog = await screen.findByRole('alertdialog', { name: '删除自动化' }) + expect(dialog).toHaveTextContent('删除「TraceMemo 每日晚报」?') + expect(spies.deleteRule).not.toHaveBeenCalled() + + await user.click(within(dialog).getByRole('button', { name: '删除' })) + await waitFor(() => expect(spies.deleteRule).toHaveBeenCalledWith('rule-1')) }) - it('任务列表为空时给空态与新建入口', async () => { - installApi([]) + it('不再有任何旧的 scheduled-report:* 通道被调用', async () => { + installApi() renderWorkspace() await openScheduledTab() - expect(await screen.findByText('还没有定时日报')).toBeVisible() - expect(screen.getAllByRole('button', { name: '+ 新建定时日报' }).length).toBeGreaterThan(0) + const api = window.api as unknown as Record + for (const legacy of [ + 'listScheduledReports', + 'createScheduledReport', + 'updateScheduledReport', + 'deleteScheduledReport', + 'setScheduledReportEnabled', + 'runScheduledReportNow', + 'retryScheduledReportSend' + ]) { + expect(api[legacy], `旧通道 ${legacy} 仍然存在于 preload 契约里`).toBeUndefined() + } }) }) describe('定时日报 · 完整日报配置(不丢旧能力)', () => { + it('新建时「纳入的消息类型」默认只勾选文本', async () => { + installApi() + renderWorkspace() + await openScheduledTab() + const user = userEvent.setup() + await user.click(await screen.findByRole('button', { name: '+ 新建定时日报' })) + + // 逐个断言,而不是只看"有没有被勾":默认值改了方向的话,全选也是"有被勾"。 + for (const option of SUMMARY_TYPE_OPTIONS) { + const box = screen.getByRole('checkbox', { name: option.label }) + if (option.value === 'text') expect(box).toBeChecked() + else expect(box).not.toBeChecked() + } + }) + it('Section 2 承载旧「定时日报」的全部日报配置', async () => { installApi() renderWorkspace() @@ -405,25 +402,7 @@ describe('定时日报 · 完整日报配置(不丢旧能力)', () => { expect(screen.getByText('模型配置')).toBeVisible() expect(screen.getByText('更改模型')).toBeVisible() expect(screen.getByText('日报内容')).toBeVisible() - }) - - it('纳入的消息类型用旧页面的真实类型与说明(默认全选)', async () => { - installApi() - renderWorkspace() - await openScheduledTab() - const user = userEvent.setup() - await user.click(await screen.findByRole('button', { name: '+ 新建定时日报' })) - expect(screen.getByText('纳入的消息类型')).toBeVisible() - expect(screen.getByText('至少选择一种')).toBeVisible() - - // 七类都来自 SUMMARY_TYPE_OPTIONS,不是新写的一套。 - for (const label of ['文本', '图片', '表情包', '视频', '语音', '分享/引用', '系统消息']) { - expect(screen.getByRole('checkbox', { name: label })).toBeVisible() - } - // 旧页面默认全选。 - expect(screen.getByRole('checkbox', { name: '文本' })).toBeChecked() - expect(screen.getByRole('checkbox', { name: '系统消息' })).toBeChecked() }) it('成员名称保留旧页面的三个选项', async () => { @@ -440,31 +419,24 @@ describe('定时日报 · 完整日报配置(不丢旧能力)', () => { }) }) -describe('定时日报 · 返回与四选一发送目标', () => { - it('新建页有「← 返回定时日报」,点击回到规则列表', async () => { +describe('定时日报 · 四选一发送目标', () => { + it('只有四选一,且**没有**「指定群聊」这种"发到另一个群"的选项', async () => { installApi() renderWorkspace() await openScheduledTab() const user = userEvent.setup() await user.click(await screen.findByRole('button', { name: '+ 新建定时日报' })) - await user.click(screen.getByRole('button', { name: '← 返回定时日报' })) - // 回到「定时日报」列表,而不是自动化首页。 - expect(await screen.findByText('2 条规则')).toBeVisible() - expect(screen.getByRole('radio', { name: '定时日报' })).toBeChecked() - }) - - it('编辑页也有返回入口,点击回到规则列表', async () => { - installApi() - renderWorkspace() - await openScheduledTab() - const user = userEvent.setup() - const card = (await screen.findByText('TraceMemo 每日晚报')).closest('article') as HTMLElement - await user.click(within(card).getByRole('button', { name: '编辑' })) - expect(screen.getByRole('button', { name: '← 返回定时日报' })).toBeVisible() - - await user.click(screen.getByRole('button', { name: '← 返回定时日报' })) - expect(await screen.findByText('2 条规则')).toBeVisible() + const group = screen.getByRole('radiogroup', { name: '生成后发送到哪里' }) + const labels = Array.from(group.querySelectorAll('label')).map((el) => el.textContent?.trim()) + expect(labels).toEqual([ + '发送到日报来源群', + '文件传输助手', + '发给自己', + '指定好友' + ]) + expect(screen.queryByRole('radio', { name: '指定群聊' })).toBeNull() + expect(screen.getByRole('radio', { name: '文件传输助手' })).toBeChecked() }) it('只有「指定好友」才展开好友选择器', async () => { @@ -473,6 +445,14 @@ describe('定时日报 · 返回与四选一发送目标', () => { await openScheduledTab() const user = userEvent.setup() await user.click(await screen.findByRole('button', { name: '+ 新建定时日报' })) + // 先把名称 / 来源群补齐:阻断提示是有优先级的(名称 → 来源群 → 指定好友), + // 否则这里看到的是「请填写任务名称」,断言就打偏了。 + await user.type(screen.getByLabelText('定时日报任务名称'), '发给指定好友的日报') + await user.click( + within(screen.getByRole('radiogroup', { name: '日报来源' })).getByRole('radio', { + name: '技术交流群' + }) + ) for (const label of ['文件传输助手', '发送到日报来源群', '发给自己']) { await user.click(screen.getByRole('radio', { name: label })) @@ -481,80 +461,83 @@ describe('定时日报 · 返回与四选一发送目标', () => { await user.click(screen.getByRole('radio', { name: '指定好友' })) expect(screen.getByPlaceholderText('搜索好友')).toBeVisible() - }) - - it('Preview 随发送目标变化,且「发送到日报来源群」显示来源群名', async () => { - installApi() - renderWorkspace() - await openScheduledTab() - const user = userEvent.setup() - await user.click(await screen.findByRole('button', { name: '+ 新建定时日报' })) - - await user.click( - within(screen.getByRole('radiogroup', { name: '日报来源' })).getByRole('radio', { - name: '技术交流群' - }) + // 选了「指定好友」却没选人 → 不允许保存(否则运行期必然失败)。 + expect(screen.getByTestId('scheduled-save-blocker')).toHaveTextContent( + '请选择要发送的指定好友' ) - - const targetOf = (): string => { - const rows = Array.from(document.querySelectorAll('.automation-preview-facts > div')) - const row = rows.find((item) => item.querySelector('dt')?.textContent === '发送目标') - return row?.querySelector('dd')?.textContent ?? '' - } - - expect(targetOf()).toBe('文件传输助手') - await user.click(screen.getByRole('radio', { name: '发送到日报来源群' })) - expect(targetOf()).toBe('技术交流群') - await user.click(screen.getByRole('radio', { name: '发给自己' })) - expect(targetOf()).toBe('我') - }) - - it('Preview 随来源群与模板变化', async () => { - installApi() - renderWorkspace() - await openScheduledTab() - const user = userEvent.setup() - await user.click(await screen.findByRole('button', { name: '+ 新建定时日报' })) - - await user.click( - within(screen.getByRole('radiogroup', { name: '日报来源' })).getByRole('radio', { - name: '技术交流群' - }) - ) - const flow = document.querySelector('.automation-preview-flow')?.textContent ?? '' - expect(flow).toContain('技术交流群') - expect(flow).toContain('经典日报') - - await user.click(screen.getByLabelText('日报模板')) - // 选项文案来自 REPORT_TEMPLATES 的 `label · name`。 - await user.click(await screen.findByRole('option', { name: /Mobile 01/ })) - expect(document.querySelector('.automation-preview-flow')?.textContent).not.toContain('经典日报') + expect(screen.getByRole('button', { name: '保存' })).toBeDisabled() }) }) -describe('定时日报 · 不向用户暴露内部标识', () => { +describe('定时日报 · 迁移遗留与内部标识', () => { + it('targetNeedsReview 的规则在列表上明确提示"需要重选目标"', async () => { + const needsReview = createDefaultScheduledReportRule(Date.now(), { + id: 'rule-review', + name: '旧规则', + enabled: false, + config: { + ...normalizeScheduledReportConfig({}), + target: { type: 'source_chat' }, + targetNeedsReview: true, + legacyTarget: '某个旧群' + } + }) + installApi([needsReview]) + renderWorkspace() + await openScheduledTab() + + const card = (await screen.findByText('旧规则')).closest('article') as HTMLElement + expect(card.textContent).toContain('发送目标需要重新选择') + expect(card.textContent).toContain('某个旧群') + // 目标待重选时不允许执行(执行必然失败)。 + expect(within(card).getByRole('button', { name: '立即执行' })).toBeDisabled() + }) + it('列表把 room id / hash 解析成显示名,绝不显示裸 id', async () => { - // 合成值:形态与真实 room id / hash 相同(纯数字 + @chatroom、32 位十六进制), - // 但取值本身可一眼辨认是假数据。与仓库其它测试保持一致。 const FAKE_ROOM_ID = '12345678@chatroom' const FAKE_GROUP_HASH = '0123456789abcdef0123456789abcdef' - installApi([ - { ...TASKS[0], group: FAKE_ROOM_ID, target: FAKE_ROOM_ID }, - { ...TASKS[1], group: FAKE_GROUP_HASH } + createDefaultScheduledReportRule(Date.now(), { + id: 'rule-internal', + name: '内部标识规则', + config: normalizeScheduledReportConfig({ + report: { sourceConversationId: FAKE_ROOM_ID, messageTypes: ['text'] } + }) + }), + createDefaultScheduledReportRule(Date.now(), { + id: 'rule-internal-2', + name: '内部标识规则 2', + config: normalizeScheduledReportConfig({ + report: { sourceConversationId: FAKE_GROUP_HASH, messageTypes: ['text'] } + }) + }) ]) renderWorkspace() await openScheduledTab() - await screen.findByText('TraceMemo 每日晚报') + await screen.findByText('内部标识规则') const body = document.body.textContent ?? '' expect(body).not.toContain(FAKE_ROOM_ID) expect(body).not.toContain(FAKE_GROUP_HASH.slice(0, 12)) - // 对不上群名时给中性文案,而不是把 id 当名字。 expect(screen.getAllByText('未知群聊').length).toBeGreaterThan(0) }) }) +describe('定时日报 · 微信异常通知入口', () => { + it('编辑器里保留异常通知开关,并给出 Agent Hub 的去处', async () => { + installApi() + renderWorkspace() + await openScheduledTab() + const user = userEvent.setup() + await user.click(await screen.findByRole('button', { name: '+ 新建定时日报' })) + + expect(screen.getByRole('heading', { name: '微信异常通知' })).toBeVisible() + expect( + screen.getByText('需要先连接 Agent Hub 微信机器人,才能接收异常通知。') + ).toBeVisible() + }) +}) + describe('定时日报 · 首页汇总卡', () => { it('首页把它当"多条规则"汇总,而不是一条 singleton', async () => { installApi() @@ -563,7 +546,6 @@ describe('定时日报 · 首页汇总卡', () => { const card = (await screen.findByText('定时日报')).closest('article') as HTMLElement expect(card.textContent).toContain('2 条规则 · 1 条运行中') expect(card.textContent).toContain('按设定时间') - // 汇总卡不该有单个启停开关。 expect(within(card).queryByRole('switch')).toBeNull() await userEvent.setup().click(within(card).getByRole('button', { name: '管理定时日报 →' })) @@ -571,7 +553,7 @@ describe('定时日报 · 首页汇总卡', () => { }) }) -describe('定时日报 · 深链与旧页面', () => { +describe('定时日报 · 深链', () => { it('深链直接落到「自动化 → 规则 → 定时日报」', async () => { installApi() @@ -592,38 +574,15 @@ describe('定时日报 · 深链与旧页面', () => { expect(await screen.findByText('2 条规则')).toBeVisible() expect(screen.getByRole('radio', { name: '定时日报' })).toBeChecked() }) - - it('旧「日报 → 定时日报」页面仍在,且入口能触发跳转', async () => { - installApi() - const onOpenAutomation = vi.fn() - - render( - - {}} - onOpenAgentHub={() => {}} - onNotice={() => {}} - onOpenAutomation={onOpenAutomation} - /> - - ) - - expect(screen.getByRole('heading', { name: '定时日报' })).toBeVisible() - - const user = userEvent.setup() - await user.click(screen.getByRole('button', { name: '在自动化中配置 →' })) - expect(onOpenAutomation).toHaveBeenCalledTimes(1) - }) }) -/** 让 `waitFor` 在本文件被使用(空态/异步断言需要)。 */ -describe('定时日报 · 异步加载', () => { - it('加载完成后不再显示 loading', async () => { - installApi() +describe('定时日报 · 空态', () => { + it('没有规则时给空态与新建入口', async () => { + installApi([]) renderWorkspace() await openScheduledTab() - await waitFor(() => expect(screen.queryByText('正在读取定时日报…')).toBeNull()) + expect(await screen.findByText('还没有定时日报')).toBeVisible() + expect(screen.getAllByRole('button', { name: '+ 新建定时日报' }).length).toBeGreaterThan(0) }) }) diff --git a/tests/component/scheduled-reports-workspace.test.tsx b/tests/component/scheduled-reports-workspace.test.tsx deleted file mode 100644 index 8a35db9..0000000 --- a/tests/component/scheduled-reports-workspace.test.tsx +++ /dev/null @@ -1,259 +0,0 @@ -import { fireEvent, render, screen, waitFor, within } from '@testing-library/react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { ScheduledReportsWorkspace } from '../../src/renderer/src/components/reports/ScheduledReportsWorkspace' -import type { Contact } from '../../src/shared/types' -import type { PersonalWechatSendCapability } from '../../src/shared/personal-wechat' - -const group: Contact = { - md5: 'group-md5', - m_nsUsrName: 'group@chatroom', - m_nsNickName: '技术交流', - type: 'group' -} - -const capability = ( - status: PersonalWechatSendCapability['status'] -): PersonalWechatSendCapability => ({ - supported: status !== 'unsupported', - ready: status === 'ready', - status, - capabilities: { text: status === 'ready', image: status === 'ready', voice: status === 'ready' }, - senderStatus: {} as PersonalWechatSendCapability['senderStatus'], - message: '' -}) - -const task = { - id: 'task-1', - name: '技术交流 · 每日日报', - group: group.md5, - target: group.m_nsUsrName, - scheduleTime: '09:00', - reportRange: 'yesterday' as const, - enabled: true, - createdAt: '2026-08-26T00:00:00.000Z', - updatedAt: '2026-08-26T00:00:00.000Z', - nextRunAt: '2026-08-28T01:00:00.000Z' -} - -describe('ScheduledReportsWorkspace', () => { - const onNotice = vi.fn() - const onOpenWechatSettings = vi.fn() - const onOpenAgentHub = vi.fn() - - beforeEach(() => { - vi.clearAllMocks() - Object.defineProperty(window, 'api', { - configurable: true, - value: { - getPersonalWechatSendCapability: vi.fn(async () => capability('ready')), - getScheduledReportNotificationSettings: vi.fn(async () => ({ enabled: false })), - setScheduledReportNotificationEnabled: vi.fn(async (enabled: boolean) => ({ - success: true, - data: { enabled } - })), - listScheduledReports: vi.fn(async () => [task]), - listScheduledReportExecutions: vi.fn(async () => []), - createScheduledReport: vi.fn(async () => ({ success: true, data: task })), - updateScheduledReport: vi.fn(async () => ({ success: true, data: task })), - deleteScheduledReport: vi.fn(async () => ({ success: true, data: { deletedId: task.id } })), - setScheduledReportEnabled: vi.fn(async () => ({ success: true, data: task })), - runScheduledReportNow: vi.fn(async () => ({ success: true, data: { status: 'success' } })), - retryScheduledReportSend: vi.fn(async () => ({ - success: true, - data: { status: 'success' } - })), - testScheduledReportErrorNotification: vi.fn(async () => ({ - success: true, - data: { status: 'failed', notificationStatus: 'sent' } - })) - } - }) - }) - - afterEach(() => { - vi.unstubAllEnvs() - }) - - it('loads capability and renders a scheduled report task', async () => { - render( - - ) - expect(screen.getByText('正在检查微信发送能力…')).toBeInTheDocument() - await waitFor(() => expect(screen.getByText('✓ 微信发送能力已就绪')).toBeVisible()) - expect(screen.getByText(task.name)).toBeVisible() - expect(screen.getByText('每天 09:00')).toBeVisible() - }) - - it('keeps creation available and offers settings when WeChat is unsupported', async () => { - vi.mocked(window.api.getPersonalWechatSendCapability).mockResolvedValue( - capability('unsupported') - ) - render( - - ) - await waitFor(() => - expect(screen.getByText('微信消息发送目前仅支持 macOS 和 Windows')).toBeVisible() - ) - expect(screen.getByRole('button', { name: /新建定时日报/ })).toBeEnabled() - }) - - it('opens the shared dialog and creates a task', async () => { - vi.mocked(window.api.listScheduledReports).mockResolvedValue([]) - render( - - ) - await waitFor(() => expect(screen.getByText('还没有定时日报')).toBeVisible()) - fireEvent.click(screen.getAllByRole('button', { name: /新建定时日报/ })[0]) - expect(screen.getByRole('dialog')).toBeVisible() - const dialog = screen.getByRole('dialog') - fireEvent.change(within(dialog).getByPlaceholderText('例如:技术交流 · 每日日报'), { - target: { value: '测试日报' } - }) - fireEvent.click(within(dialog).getByRole('button', { name: '创建定时日报' })) - await waitFor(() => - expect(window.api.createScheduledReport).toHaveBeenCalledWith( - expect.objectContaining({ name: '测试日报', group: group.md5, target: group.m_nsUsrName }) - ) - ) - }) - - it('keeps scheduled report range labels readable in compact layouts', async () => { - vi.mocked(window.api.listScheduledReports).mockResolvedValue([]) - render( - - ) - - await waitFor(() => expect(screen.getByText('还没有定时日报')).toBeVisible()) - fireEvent.click(screen.getAllByRole('button', { name: /新建定时日报/ })[0]) - - const range = screen.getByRole('radiogroup', { name: '总结范围' }) - expect(range).toHaveClass('grid-cols-2', 'sm:grid-cols-5') - expect(screen.getByText('自定义', { exact: true })).toHaveClass('whitespace-nowrap') - expect(screen.getByText('即将支持', { exact: true })).toHaveClass('whitespace-nowrap') - }) - - it('keeps the notification switch off by default and loads the persisted setting', async () => { - render( - - ) - - const notificationSwitch = screen.getByRole('switch', { name: '微信异常通知' }) - await waitFor(() => expect(notificationSwitch).toHaveAttribute('aria-checked', 'false')) - expect(window.api.getScheduledReportNotificationSettings).toHaveBeenCalledOnce() - }) - - it('restores the switch off and offers Agent Hub when enabling fails', async () => { - vi.mocked(window.api.setScheduledReportNotificationEnabled).mockResolvedValue({ - success: false, - data: { enabled: false }, - reason: 'agent_hub_offline', - error: '需要先连接 Agent Hub 微信机器人,才能接收异常通知。' - }) - render( - - ) - - await waitFor(() => expect(screen.getByText('微信异常通知')).toBeVisible()) - fireEvent.click(screen.getByRole('switch', { name: '微信异常通知' })) - await waitFor(() => - expect(screen.getByText('需要先连接 Agent Hub 微信机器人,才能接收异常通知。')).toBeVisible() - ) - expect(screen.getByRole('switch', { name: '微信异常通知' })).toHaveAttribute( - 'aria-checked', - 'false' - ) - fireEvent.click(screen.getByRole('button', { name: '前往 Agent Hub' })) - expect(onOpenAgentHub).toHaveBeenCalledOnce() - }) - - it('shows the debug notification action only when enabled and sends the test request', async () => { - vi.stubEnv('VITE_SCHEDULED_REPORT_DEBUG', 'true') - render( - - ) - - await waitFor(() => expect(screen.getByText(task.name)).toBeVisible()) - fireEvent.click(screen.getByRole('button', { name: '测试错误信息发送' })) - - await waitFor(() => - expect(window.api.testScheduledReportErrorNotification).toHaveBeenCalledWith(task.id) - ) - expect(onNotice).toHaveBeenCalledWith( - '测试错误信息已发送到 Agent Hub 微信通知接收者', - 'success' - ) - }) - - it('confirms a manual resend and does not retry when the dialog is cancelled', async () => { - const execution = { - id: 'execution-1', - taskId: task.id, - startedAt: '2026-08-27T01:00:00.000Z', - finishedAt: '2026-08-27T01:01:00.000Z', - status: 'waiting_to_send' as const, - sendTarget: group.m_nsUsrName, - userMessage: '日报已生成,但微信暂不可用' - } - vi.mocked(window.api.listScheduledReportExecutions).mockResolvedValue([execution]) - render( - - ) - - await waitFor(() => expect(screen.getByText('日报已生成,但微信暂不可用')).toBeVisible()) - fireEvent.click(screen.getByRole('button', { name: '重新发送' })) - const dialog = await screen.findByRole('alertdialog') - expect(within(dialog).getByText('目标:' + group.m_nsUsrName)).toBeVisible() - expect(within(dialog).getByText('日报:技术交流 · 每日日报')).toBeVisible() - expect(within(dialog).getByText(/日期:/)).toBeVisible() - fireEvent.click(within(dialog).getByRole('button', { name: '取消' })) - expect(window.api.retryScheduledReportSend).not.toHaveBeenCalled() - - fireEvent.click(screen.getByRole('button', { name: '重新发送' })) - const secondDialog = await screen.findByRole('alertdialog') - fireEvent.click(within(secondDialog).getByRole('button', { name: '确认重新发送' })) - await waitFor(() => - expect(window.api.retryScheduledReportSend).toHaveBeenCalledWith(execution.id) - ) - expect(window.api.retryScheduledReportSend).toHaveBeenCalledTimes(1) - }) -}) diff --git a/tests/integration/automation-chain.test.ts b/tests/integration/automation-chain.test.ts index 14b2528..e45c80d 100644 --- a/tests/integration/automation-chain.test.ts +++ b/tests/integration/automation-chain.test.ts @@ -22,7 +22,10 @@ vi.mock('electron', () => ({ BrowserWindow: { getAllWindows: () => [] } })) -import { AUTOMATION_SEND_PURPOSE } from '../../src/shared/automation' +import { + AUTOMATION_SEND_PURPOSE, + BUILTIN_DAILY_REPORT_RULE_ID +} from '../../src/shared/automation' import type { PersonalWechatSendCapability, PersonalWechatSendRequest @@ -273,4 +276,53 @@ describe('automation end-to-end chain', () => { await chain.service.handleMessage(message({ content: '@我 今日日报', mentionTargets: [] })) expect(chain.sends).toHaveLength(0) }) + + it('同一条 store 里存在定时日报规则时,@我消息只触发 @我日报规则(规则类型硬分派)', async () => { + const chain = buildChain() + + // 定时日报是**时间驱动**,不参与消息匹配。硬分派一旦写错,它的 + // 「空关键词 + 不要求 @我」会让它命中**每一条群消息** —— + // 这正是这条用例要守住的边界。 + const scheduled = chain.store.createRule({ + name: '每天 09:00 的定时日报', + enabled: true, + ruleType: 'scheduled_report', + trigger: 'message', + scope: 'group', + conditions: { + requireMentionMe: false, + keyword: '', + keywordMatchMode: 'contains', + conversationIds: [], + ignoreSelf: true + }, + actions: [], + cooldownSeconds: 0, + replyDelaySeconds: 0, + scheduledReport: { + schedule: { time: '09:00' }, + report: { + sourceConversationId: GROUP_ID, + range: 'yesterday', + messageTypes: ['text'], + templateId: 'v1', + memberNameMode: 'groupNickname', + timeoutSeconds: 300 + }, + target: { type: 'file_transfer' } + } + }) + expect(scheduled.ruleType).toBe('scheduled_report') + + await chain.service.handleMessage(message()) + + // 只有 @我日报那条留下执行记录;定时日报规则**一条都没有**。 + const records = chain.log.list() + expect(records).toHaveLength(1) + expect(records[0].ruleId).toBe(BUILTIN_DAILY_REPORT_RULE_ID) + expect(records.some((record) => record.ruleId === scheduled.id)).toBe(false) + + // 发送也只有 @我日报的那两次(确认文字 + 日报图片),没有第三条。 + expect(chain.sends.map((send) => send.type)).toEqual(['text', 'image']) + }) }) diff --git a/tests/integration/preload-contract.test.ts b/tests/integration/preload-contract.test.ts index a45f6f3..7432eb3 100644 --- a/tests/integration/preload-contract.test.ts +++ b/tests/integration/preload-contract.test.ts @@ -160,13 +160,62 @@ describe('preload IPC contract', () => { expect(invoke).toHaveBeenLastCalledWith('wechat-personal:send', reportImageRequest) await api.getPersonalWechatVoiceDiagnostic() expect(invoke).toHaveBeenLastCalledWith('wechat-personal:getVoiceDiagnostic') + }) + + /** + * 定时日报退役后,专属能力全部改走 `automation:*`。 + * + * 单独成一个用例(而不是塞进上面那条超长用例)有两个理由: + * 1. 上面的用例在 `voice:recognize` 上会因 vitest 4 对**尾随 undefined** 的严格比较而失败, + * 放在一起会让这几条断言永远跑不到; + * 2. 「旧通道必须彻底消失」本身就是一条独立契约,值得有自己的名字。 + */ + it('routes scheduled-report capabilities through automation channels only', async () => { + const api = await loadApi() + invoke.mockResolvedValue({ success: true }) + + await api.runScheduledReportRule('rule-1') + expect(invoke).toHaveBeenLastCalledWith('automation:runScheduledReportRule', 'rule-1') + + await api.listScheduledReportLegacyExecutions('rule-1') + expect(invoke).toHaveBeenLastCalledWith( + 'automation:listScheduledReportLegacyExecutions', + 'rule-1' + ) await api.getScheduledReportNotificationSettings() - expect(invoke).toHaveBeenLastCalledWith('scheduled-report:getNotificationSettings') + expect(invoke).toHaveBeenLastCalledWith('automation:getScheduledReportNotificationSettings') + await api.getScheduledReportNotificationCapability() + expect(invoke).toHaveBeenLastCalledWith('automation:getScheduledReportNotificationCapability') await api.setScheduledReportNotificationEnabled(true) - expect(invoke).toHaveBeenLastCalledWith('scheduled-report:setNotificationEnabled', true) - await api.testScheduledReportErrorNotification('task-1') - expect(invoke).toHaveBeenLastCalledWith('scheduled-report:testErrorNotification', 'task-1') + expect(invoke).toHaveBeenLastCalledWith( + 'automation:setScheduledReportNotificationEnabled', + true + ) + await api.testScheduledReportErrorNotification('rule-1') + expect(invoke).toHaveBeenLastCalledWith( + 'automation:testScheduledReportErrorNotification', + 'rule-1' + ) + + // 旧的 `scheduled-report:*` 通道必须**彻底不存在**: + // 留一个"能用但没人维护"的入口,等于给双写 / 双读留后门。 + const channels = invoke.mock.calls.map((call) => String(call[0])) + expect(channels.some((channel) => channel.startsWith('scheduled-report:'))).toBe(false) + + const legacyMethods = [ + 'listScheduledReports', + 'listScheduledReportExecutions', + 'createScheduledReport', + 'updateScheduledReport', + 'deleteScheduledReport', + 'setScheduledReportEnabled', + 'runScheduledReportNow', + 'retryScheduledReportSend' + ] + for (const method of legacyMethods) { + expect(api).not.toHaveProperty(method) + } }) it('preserves key API return values without exposing ipcRenderer', async () => { diff --git a/tests/integration/scheduled-report-api.test.ts b/tests/integration/scheduled-report-api.test.ts index 8fa5b19..1715fd5 100644 --- a/tests/integration/scheduled-report-api.test.ts +++ b/tests/integration/scheduled-report-api.test.ts @@ -4,7 +4,9 @@ const fixture = vi.hoisted(() => ({ capability: { supported: true, ready: true, - status: 'ready' as const, + // 显式标注成联合类型:否则会被收窄成字面量 'ready', + // 后面把它改成 'needs_binding' 时就不合法了。 + status: 'ready' as PersonalWechatSendCapabilityState, capabilities: { text: true, image: true, voice: true }, senderStatus: {} as never, message: '个人微信已准备好发送日报' @@ -58,7 +60,14 @@ vi.mock('../../src/main/services/agent-hub-service', () => ({ })) import { startHttpServer, type HttpServerHandle } from '../../src/main/http-server' -import type { ScheduledReportTask } from '../../src/shared/scheduled-report' +import type { + ScheduledReportCreateInput, + ScheduledReportExecution, + ScheduledReportResult, + ScheduledReportTask, + ScheduledReportUpdateInput +} from '../../src/shared/scheduled-report' +import type { PersonalWechatSendCapabilityState } from '../../src/shared/personal-wechat' import type { ScheduledReportApiDependencies } from '../../src/main/services/scheduled-report-api-service' const TOKEN = 'A'.repeat(43) @@ -87,57 +96,76 @@ async function startFixture( options: { tasks?: ScheduledReportTask[] } = {} ): Promise<{ handle: HttpServerHandle; service: ScheduledReportApiDependencies['service'] }> { const tasks = options.tasks || [] - const executions: Array> = [] - const service = { - listTasks: vi.fn(async () => tasks), - listExecutions: vi.fn(async (taskId?: string) => - taskId ? executions.filter((item) => item.taskId === taskId) : executions + const executions: ScheduledReportExecution[] = [] + const service: ScheduledReportApiDependencies['service'] = { + listTasks: vi.fn(async (): Promise => tasks), + listExecutions: vi.fn( + async (taskId?: string): Promise => + taskId ? executions.filter((item) => item.taskId === taskId) : executions ), - createTask: vi.fn(async (input: Record) => { - const created = makeTask({ - id: `task-${tasks.length + 1}`, - name: String(input.name), - group: String(input.group), - target: String(input.target), - scheduleTime: String(input.scheduleTime), - reportRange: input.reportRange as ScheduledReportTask['reportRange'], - enabled: Boolean(input.enabled) - }) - tasks.push(created) - return { success: true, data: created } - }), - updateTask: vi.fn(async (id: string, input: Record) => { - const current = tasks.find((item) => item.id === id) - if (!current) return { success: false, error: '未找到定时日报任务' } - const updated = { ...current, ...input, updatedAt: '2026-08-27T02:00:00.000Z' } - tasks[tasks.indexOf(current)] = updated - return { success: true, data: updated } - }), - deleteTask: vi.fn(async (id: string) => { - const index = tasks.findIndex((item) => item.id === id) - if (index < 0) return { success: false, error: '未找到定时日报任务' } - tasks.splice(index, 1) - return { success: true, data: { deletedId: id } } - }), - setTaskEnabled: vi.fn(async (id: string, enabled: boolean) => { - const current = tasks.find((item) => item.id === id) - if (!current) return { success: false, error: '未找到定时日报任务' } - const updated = { ...current, enabled } - tasks[tasks.indexOf(current)] = updated - return { success: true, data: updated } - }), - runScheduledReportNow: vi.fn(async (id: string) => { - const execution = { - id: `execution-${executions.length + 1}`, - taskId: id, - startedAt: '2026-08-27T01:00:00.000Z', - finishedAt: '2026-08-27T01:01:00.000Z', - status: 'success' as const, - message: '日报生成成功,微信发送成功' + createTask: vi.fn( + async ( + input: ScheduledReportCreateInput + ): Promise> => { + const created = makeTask({ + id: `task-${tasks.length + 1}`, + name: String(input.name), + group: String(input.group), + target: String(input.target), + scheduleTime: String(input.scheduleTime), + reportRange: input.reportRange as ScheduledReportTask['reportRange'], + enabled: Boolean(input.enabled) + }) + tasks.push(created) + return { success: true, data: created } } - executions.push(execution) - return { success: true, data: execution } - }) + ), + updateTask: vi.fn( + async ( + id: string, + input: ScheduledReportUpdateInput + ): Promise> => { + const current = tasks.find((item) => item.id === id) + if (!current) return { success: false, error: '未找到定时日报任务' } + const updated = { ...current, ...input, updatedAt: '2026-08-27T02:00:00.000Z' } + tasks[tasks.indexOf(current)] = updated + return { success: true, data: updated } + } + ), + deleteTask: vi.fn( + async (id: string): Promise> => { + const index = tasks.findIndex((item) => item.id === id) + if (index < 0) return { success: false, error: '未找到定时日报任务' } + tasks.splice(index, 1) + return { success: true, data: { deletedId: id } } + } + ), + setTaskEnabled: vi.fn( + async ( + id: string, + enabled: boolean + ): Promise> => { + const current = tasks.find((item) => item.id === id) + if (!current) return { success: false, error: '未找到定时日报任务' } + const updated = { ...current, enabled } + tasks[tasks.indexOf(current)] = updated + return { success: true, data: updated } + } + ), + runScheduledReportNow: vi.fn( + async (id: string): Promise> => { + const execution: ScheduledReportExecution = { + id: `execution-${executions.length + 1}`, + taskId: id, + startedAt: '2026-08-27T01:00:00.000Z', + finishedAt: '2026-08-27T01:01:00.000Z', + status: 'success', + message: '日报生成成功,微信发送成功' + } + executions.push(execution) + return { success: true, data: execution } + } + ) } const handle = await startHttpServer('127.0.0.1', 0, { tokenProvider: () => TOKEN, @@ -222,6 +250,13 @@ describe('scheduled report Local HTTP API', () => { }) expect(await capability.json()).toMatchObject({ capability: { status: 'ready' } }) expect(service.createTask).toHaveBeenCalledOnce() + // HTTP 契约把群解析成**会话 md5**、目标解析成 roomId 后交给服务层 + //(服务层再把这些标识收敛成稳定会话 id)。 + expect(vi.mocked(service.createTask).mock.calls[0][0]).toMatchObject({ + group: 'tech-md5', + target: 'tech@chatroom', + scheduleTime: '09:00' + }) }) it('allows unavailable sending capability while still rejecting ambiguous groups', async () => { diff --git a/tests/integration/scheduled-report-automation.test.ts b/tests/integration/scheduled-report-automation.test.ts new file mode 100644 index 0000000..2cd01bb --- /dev/null +++ b/tests/integration/scheduled-report-automation.test.ts @@ -0,0 +1,659 @@ +import fs from 'fs-extra' +import path from 'path' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const root = vi.hoisted(() => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const fs = require('node:fs') as typeof import('node:fs') + // eslint-disable-next-line @typescript-eslint/no-require-imports + const os = require('node:os') as typeof import('node:os') + // eslint-disable-next-line @typescript-eslint/no-require-imports + const path = require('node:path') as typeof import('node:path') + return fs.mkdtempSync(path.join(os.tmpdir(), 'wxe-scheduled-report-automation-')) +}) + +const SELF = 'my_account_9527' + +/** 联系人表必须在 `vi.hoisted` 里 —— `vi.mock` 的工厂是提前求值的。 */ +const contacts = vi.hoisted(() => [ + { + md5: 'tech-md5', + m_nsUsrName: 'tech@chatroom', + m_nsNickName: '技术交流群', + type: 'group' as const + }, + { + m_nsUsrName: 'wxid_friend', + m_nsNickName: '好友昵称', + remark: '好友备注', + type: 'user' as const + } +]) + +vi.mock('electron', () => ({ + app: { getPath: () => root }, + safeStorage: { + isEncryptionAvailable: () => false, + encryptString: (value: string) => Buffer.from(value, 'utf8'), + decryptString: (value: Buffer) => value.toString('utf8') + }, + BrowserWindow: { getAllWindows: () => [] } +})) +vi.mock('../../src/main/services/chat-service', () => ({ + listContacts: () => contacts, + getSelfAccountInfo: () => ({ wxid: 'my_account_9527' }), + // 头像补全是可选增强:这里显式给一个空实现,避免 runner 落到未 mock 的导出上刷警告。 + getContactAvatars: async () => ({}) +})) + +import type { + PersonalWechatSendCapability, + PersonalWechatSendRequest +} from '../../src/shared/personal-wechat' +import type { Wcdb4Client } from '../../src/main/wcdb4-client' +import type { GeneratedReportRecord } from '../../src/shared/report-history' +import type { AgentGroupReportResult } from '../../src/main/services/agent-group-report-service' +import type { + AutomationRule, + AutomationRuleDraft, + ScheduledReportTargetType +} from '../../src/shared/automation' +import { WechatActionGateway } from '../../src/main/services/wechat-action-gateway' +import { AutomationRuleStore } from '../../src/main/services/automation-rule-store' +import { AutomationExecutionLogService } from '../../src/main/services/automation-execution-log-service' +import { AutomationActionRunner } from '../../src/main/services/automation-action-runner' +import { AutomationService } from '../../src/main/services/automation-service' +import { ScheduledReportService } from '../../src/main/services/scheduled-report-service' + +/** + * 定时日报的**端到端链路**。 + * + * Scheduler(`ScheduledReportService.tick`)→ `AutomationService.executeScheduledRule` + * → `AutomationActionRunner.runScheduledReport` → `WechatActionGateway` → 被替换掉的传输层。 + * + * 除了「微信怎么把图片发出去」,其余**全部是生产实现**。这个文件的存在理由: + * - `AUTOMATION_PURPOSE_ALLOWLIST` 一旦漏登记 `automation_scheduled_report`, + * 整条链会在策略层被**静默**拦下(ACTION_NOT_ALLOWED),而每个单元测试都还是绿的; + * - `scheduledInFlight` / 确定性 executionId 这两道并发闸,只有真的拼起来才看得出来; + * - 四个发送目标、槽位游标、执行日志的 trigger 区分,都是"拼起来"才成立的语义。 + */ + +function readyCapability(): PersonalWechatSendCapability { + return { + supported: true, + ready: true, + status: 'ready', + capabilities: { text: true, image: true, voice: false }, + message: '个人微信已准备好发送日报', + senderStatus: { + state: 'online', + platform: 'darwin', + arch: 'arm64', + sipDisabled: false, + wechatRunning: true, + endpoint: '127.0.0.1:0', + endpointReady: true, + runtimeReady: true, + attachReady: true, + baseAddressReady: true, + textHookInstalled: true, + textHookReady: true, + imageHookInstalled: true, + imageHookReady: true, + messageListenerReady: true, + canSend: true, + canSendText: true, + canSendImage: true, + canSendVoice: false, + message: '个人微信已准备好发送日报' + } + } +} + +const RULE_NAME = '技术交流群 · 每日晚报' +const SCHEDULE_TIME = '18:30' +/** 规则的创建时间固定在 2026-08-20,必须早于所有参与断言的槽位。 */ +const CREATED_AT = Date.parse('2026-08-20T09:00:00+08:00') +/** 2026-08-27 18:31,当天 18:30 的槽位已经到点。 */ +const AFTER_SLOT = new Date('2026-08-27T18:31:00+08:00') +/** + * `AutomationService` / `AutomationRuleStore` 的 `now` 是**毫秒数**, + * `ScheduledReportService` 的 `now` 是 **`Date`** —— 两者刻意分开命名, + * 避免把毫秒数丢给需要 `Date` 的那一个(那会在推通知时抛 `toISOString` 错误)。 + */ +const AFTER_SLOT_MS = AFTER_SLOT.getTime() +const SLOT_ISO = new Date('2026-08-27T18:30:00+08:00').toISOString() + +function scheduledDraft( + target: ScheduledReportTargetType, + options: { contactId?: string; enabled?: boolean; postfixText?: string } = {} +): AutomationRuleDraft { + return { + name: RULE_NAME, + enabled: options.enabled !== false, + ruleType: 'scheduled_report', + trigger: 'message', + scope: 'group', + conditions: { + requireMentionMe: false, + keyword: '', + keywordMatchMode: 'contains', + conversationIds: [], + ignoreSelf: true + }, + actions: [], + cooldownSeconds: 0, + replyDelaySeconds: 2, + scheduledReport: { + schedule: { time: SCHEDULE_TIME }, + report: { + sourceConversationId: 'tech@chatroom', + range: 'yesterday', + messageTypes: ['text'], + templateId: 'v1', + memberNameMode: 'groupNickname', + timeoutSeconds: 300 + }, + target: + target === 'contact' + ? { type: 'contact', contactId: options.contactId ?? 'wxid_friend' } + : { type: target }, + // 只有显式给了后置词才写进规则:空字符串意味着"只发图片", + // 这正是存量规则迁移过来的形态,不能在测试工厂里被悄悄补上默认值。 + ...(options.postfixText ? { postfixText: options.postfixText } : {}) + } + } +} + +interface Chain { + scheduler: ScheduledReportService + service: AutomationService + store: AutomationRuleStore + log: AutomationExecutionLogService + sends: PersonalWechatSendRequest[] + ruleId: string +} + +function buildChain( + options: { + capability?: PersonalWechatSendCapability + target?: ScheduledReportTargetType + contactId?: string + enabled?: boolean + postfixText?: string + generate?: () => Promise + } = {} +): Chain { + const sends: PersonalWechatSendRequest[] = [] + const savedReports: GeneratedReportRecord[] = [] + const capability = options.capability ?? readyCapability() + + const gateway = new WechatActionGateway({ + getUserDataPath: () => root, + getCapability: async () => capability, + // 传输层是唯一被替换的部分:策略、幂等、审计、节流都是真的。 + send: async (request) => { + sends.push(request) + return { success: true, status: readyCapability().senderStatus } + }, + wait: async () => undefined + }) + + // `now` 固定:否则 `createdAt` 会变成墙上时钟,把「槽位必须晚于创建时间」那道闸踩掉。 + const store = new AutomationRuleStore({ userDataPath: () => root, now: () => CREATED_AT }) + const log = new AutomationExecutionLogService({ userDataPath: () => root }) + const runner = new AutomationActionRunner({ + executeAction: (request) => gateway.execute(request), + generateReport: + options.generate ?? + (async () => ({ + success: true, + groupName: '技术交流群', + pngPath: path.join(root, 'scheduled-report.png'), + messageCount: 42 + })), + saveGeneratedReport: async (request) => { + const record: GeneratedReportRecord = { + id: 'report-1', + contactId: request.contactId, + contactName: request.contactName, + source: 'scheduled', + dateRange: request.dateRange, + messageCount: request.messageCount, + generatedAt: request.generatedAt, + reportDate: '2026-08-26', + pngPath: path.join(root, 'scheduled-report.png'), + htmlStatus: 'ready', + pngStatus: 'ready' + } + savedReports.push(record) + return { success: true, record } + }, + resolveReportContact: () => ({ + md5: 'tech-md5', + m_nsUsrName: 'tech@chatroom', + m_nsNickName: '技术交流群' + }) + }) + + const client = { + getMyUsernameCandidates: () => [SELF], + getSessions: () => [{ username: 'tech@chatroom', nickname: '技术交流群' }] + } as unknown as Wcdb4Client + + const service = new AutomationService(client, { + ruleStore: store, + executionLog: log, + runner, + getCapability: async () => capability, + now: () => AFTER_SLOT_MS + }) + + const ruleId = store.createRule( + scheduledDraft(options.target ?? 'source_chat', { + ...(options.contactId ? { contactId: options.contactId } : {}), + ...(options.enabled === false ? { enabled: false } : {}), + ...(options.postfixText ? { postfixText: options.postfixText } : {}) + }) + ).id + + const scheduler = new ScheduledReportService({ + storageDir: path.join(root, 'scheduled-reports-legacy'), + executeRule: (id, runOptions) => service.executeScheduledRule(id, runOptions), + listRules: () => store.listRules(), + listExecutions: (input) => log.list(input), + isDatabaseReady: () => true, + now: () => AFTER_SLOT + }) + + return { scheduler, service, store, log, sends, ruleId } +} + +/** 每个用例都换一个干净的 userData 目录,避免审计 / 幂等记录互相污染。 */ +const workdirs: string[] = [] +function isolatedRoot(): void { + const dir = fs.mkdtempSync(path.join(root, 'case-')) + workdirs.push(dir) +} + +describe('scheduled report end-to-end chain', () => { + beforeEach(() => { + fs.removeSync(path.join(root, 'automation')) + fs.removeSync(path.join(root, 'actions')) + fs.removeSync(path.join(root, 'scheduled-reports-legacy')) + isolatedRoot() + }) + + afterAll(() => { + for (const dir of workdirs) fs.removeSync(dir) + fs.removeSync(root) + }) + + it('到点 ⇒ 生成日报 → 落日报历史 → 穿过 Gateway 发出,并记录 schedule 触发', async () => { + const chain = buildChain() + + await chain.scheduler.tick(AFTER_SLOT) + await chain.scheduler.settle() + + // 真的发出去了:说明 purpose 通过了 `AUTOMATION_PURPOSE_ALLOWLIST`。 + expect(chain.sends).toHaveLength(1) + expect(chain.sends[0]).toMatchObject({ + type: 'image', + to: 'tech@chatroom', + isGroup: true, + filePath: path.join(root, 'scheduled-report.png') + }) + + const record = chain.log.list()[0] + expect(record.trigger).toBe('schedule') + expect(record.status).toBe('success') + expect(record.steps.map((step) => `${step.key}:${step.status}`)).toEqual([ + 'schedule_triggered:success', + 'report_generating:success', + 'report_generated:success', + 'send_resolved:success', + 'report_sent:success' + ]) + // `send_resolved` 的 detail 是**用户可读的目标名**,不是 roomId。 + expect(record.steps.find((step) => step.key === 'send_resolved')?.detail).toBe('技术交流群') + }) + + it('配置了后置词 ⇒ 图片 sent 之后补发一条文本,两条各自独立', async () => { + const chain = buildChain({ postfixText: '今日日报' }) + + await chain.scheduler.tick(AFTER_SLOT) + await chain.scheduler.settle() + + /* + * 两条:先图后文。 + * + * 后置词走的是**另一个 purpose**(`automation_scheduled_report_postfix`)—— + * 只要它在 `AUTOMATION_PURPOSE_ALLOWLIST` 里漏登记,第二次发送就会被策略层 + * 以 ACTION_NOT_ALLOWED 静默拦下,这里会只剩 1 条。这正是本用例存在的理由。 + */ + expect(chain.sends.map((send) => send.type)).toEqual(['image', 'text']) + expect(chain.sends[0]).toMatchObject({ + type: 'image', + to: 'tech@chatroom', + isGroup: true, + filePath: path.join(root, 'scheduled-report.png') + }) + expect(chain.sends[1]).toMatchObject({ + type: 'text', + to: 'tech@chatroom', + isGroup: true, + text: '今日日报' + }) + // 两条都成功,整次执行才是 success(后置词失败会拖垮整次判定,见下一条)。 + expect(chain.log.list()[0].status).toBe('success') + }) + + it('未配置后置词 ⇒ 只发图片,绝不"顺手补一句"', async () => { + const chain = buildChain() + + await chain.scheduler.tick(AFTER_SLOT) + await chain.scheduler.settle() + + expect(chain.sends.map((send) => send.type)).toEqual(['image']) + expect(chain.log.list()[0].status).toBe('success') + }) + + it('图片能力缺失 ⇒ 后置词绝不发送(图片没发出去,就没有"之后")', async () => { + const capability = readyCapability() + const chain = buildChain({ + postfixText: '今日日报', + capability: { ...capability, capabilities: { ...capability.capabilities, image: false } } + }) + + await chain.scheduler.tick(AFTER_SLOT) + await chain.scheduler.settle() + + // 图片都没发出去,后置词更不可能发 —— 传输层零调用。 + expect(chain.sends).toHaveLength(0) + expect(chain.log.list()[0].status).toBe('failed') + }) + + it('图片已发出但后置词失败 ⇒ 如实判失败,不吞掉也不回滚', async () => { + const capability = readyCapability() + // 文字能力缺失:图片照发,但后置词那一步会被 SEND_NOT_READY 拦下。 + const chain = buildChain({ + postfixText: '今日日报', + capability: { ...capability, capabilities: { ...capability.capabilities, text: false } } + }) + + await chain.scheduler.tick(AFTER_SLOT) + await chain.scheduler.settle() + + // 图片确实发出去了 —— 微信发送不支持回滚,所以**必须留痕**,不能假装没发生。 + expect(chain.sends.map((send) => send.type)).toEqual(['image']) + const record = chain.log.list()[0] + expect(record.status).toBe('failed') + // 失败点必须精确落在"后置词",而不是伪装成"日报没生成/没发送"。 + expect(record.steps.find((step) => step.key === 'report_sent')?.error).toContain( + '后置词发送失败' + ) + }) + + it('同一槽位重放 ⇒ 图片与后置词各自幂等,都不会重复发', async () => { + const chain = buildChain({ postfixText: '今日日报' }) + + await chain.service.executeScheduledRule(chain.ruleId, { + trigger: 'schedule', + scheduledSlot: SLOT_ISO + }) + await chain.service.executeScheduledRule(chain.ruleId, { + trigger: 'schedule', + scheduledSlot: SLOT_ISO + }) + + // 两次重放合起来仍然只有一条图 + 一条文:两条各有独立幂等位, + // 少给后置词一个 key 会让它在第二次重放时"被图片的幂等位短路掉"。 + expect(chain.sends.map((send) => send.type)).toEqual(['image', 'text']) + }) + + it('四个发送目标各自解析正确(同一个 Gateway,不绑作用域锁)', async () => { + const cases: Array<{ target: ScheduledReportTargetType; to: string; isGroup: boolean }> = [ + { target: 'source_chat', to: 'tech@chatroom', isGroup: true }, + { target: 'file_transfer', to: 'filehelper', isGroup: false }, + { target: 'self', to: SELF, isGroup: false }, + { target: 'contact', to: 'wxid_friend', isGroup: false } + ] + + for (const item of cases) { + const chain = buildChain({ target: item.target }) + + await chain.scheduler.tick(AFTER_SLOT) + await chain.scheduler.settle() + + expect( + chain.sends.map((send) => ({ type: send.type, to: send.to, isGroup: send.isGroup })) + ).toEqual([{ type: 'image', to: item.to, isGroup: item.isGroup }]) + expect(chain.log.list()[0].status).toBe('success') + } + }) + + it('目标失效 ⇒ 生成成功但**绝不** fallback 到别处,且如实判失败', async () => { + const chain = buildChain({ target: 'contact', contactId: 'wxid_gone' }) + + await chain.scheduler.tick(AFTER_SLOT) + await chain.scheduler.settle() + + expect(chain.sends).toHaveLength(0) + const record = chain.log.list()[0] + expect(record.status).toBe('failed') + // 失败点在"发到哪",不能伪装成"生成失败"。 + expect(record.steps.find((step) => step.key === 'report_generating')?.status).toBe('success') + expect(record.steps.find((step) => step.key === 'send_resolved')?.status).toBe('failed') + expect(record.steps.find((step) => step.key === 'report_sent')?.status).toBe('skipped') + }) + + it('停用的规则完全不执行(连日报都不生成)', async () => { + const chain = buildChain({ enabled: false }) + + await chain.scheduler.tick(AFTER_SLOT) + await chain.scheduler.settle() + + expect(chain.sends).toHaveLength(0) + expect(chain.log.list()).toHaveLength(0) + }) + + it('图片能力缺失 ⇒ 日报照生成,但不发送,并说明原因', async () => { + const capability = readyCapability() + const chain = buildChain({ + capability: { ...capability, capabilities: { ...capability.capabilities, image: false } } + }) + + await chain.scheduler.tick(AFTER_SLOT) + await chain.scheduler.settle() + + expect(chain.sends).toHaveLength(0) + const record = chain.log.list()[0] + expect(record.status).toBe('failed') + expect(record.steps.find((step) => step.key === 'report_generating')?.status).toBe('success') + expect(record.steps.find((step) => step.key === 'report_sent')?.error).toContain('无法发送图片') + }) + + it('手动执行与定时执行**共用同一条链路**,只有 trigger 不同', async () => { + const chain = buildChain() + + const outcome = await chain.service.executeScheduledRule(chain.ruleId, { trigger: 'manual' }) + + expect(outcome.executed).toBe(true) + expect(chain.sends).toHaveLength(1) + expect(chain.log.list()[0].trigger).toBe('manual') + }) + + it('同一条规则并发触发 ⇒ 第二个被 inFlight 挡掉,只发一次', async () => { + const chain = buildChain() + + const [first, second] = await Promise.all([ + chain.service.executeScheduledRule(chain.ruleId, { + trigger: 'schedule', + scheduledSlot: SLOT_ISO + }), + chain.service.executeScheduledRule(chain.ruleId, { + trigger: 'schedule', + scheduledSlot: SLOT_ISO + }) + ]) + + const executed = [first, second].filter((item) => item.executed) + const blocked = [first, second].filter((item) => !item.executed) + expect(executed).toHaveLength(1) + expect(blocked).toHaveLength(1) + expect(blocked[0].reason).toBe('in_flight') + expect(chain.sends).toHaveLength(1) + }) + + it('同一槽位被重复触发 ⇒ 确定性 executionId 让 Gateway 去重,不会重复发', async () => { + const chain = buildChain() + + const first = await chain.service.executeScheduledRule(chain.ruleId, { + trigger: 'schedule', + scheduledSlot: SLOT_ISO + }) + const second = await chain.service.executeScheduledRule(chain.ruleId, { + trigger: 'schedule', + scheduledSlot: SLOT_ISO + }) + + // executionId 由 ruleId + slot 确定性派生,所以两次完全一致。 + expect(first.executionId).toBeTruthy() + expect(second.executionId).toBe(first.executionId) + // 传输层只被调用一次 —— 幂等拦在 Gateway,不是靠上层自觉。 + expect(chain.sends).toHaveLength(1) + }) + + it('槽位游标:定时触发消费槽位,手动执行不消费', async () => { + const chain = buildChain() + + await chain.service.executeScheduledRule(chain.ruleId, { trigger: 'manual' }) + const afterManual = chain.store.getRule(chain.ruleId)?.scheduledReport + expect(afterManual?.lastRunAt).toBeTruthy() + // 手动执行若写了槽位,就会把今天安排好的那次定时执行一起吞掉。 + expect(afterManual?.lastScheduledSlot).toBeUndefined() + + await chain.service.executeScheduledRule(chain.ruleId, { + trigger: 'schedule', + scheduledSlot: SLOT_ISO + }) + expect(chain.store.getRule(chain.ruleId)?.scheduledReport?.lastScheduledSlot).toBe(SLOT_ISO) + }) + + it('调度器只读规则:槽位已消费后重启不再补跑', async () => { + const chain = buildChain() + + await chain.scheduler.tick(AFTER_SLOT) + await chain.scheduler.settle() + expect(chain.sends).toHaveLength(1) + + // 新建一个调度器实例(= 应用重启):规则与游标都从盘上读。 + const restarted = new ScheduledReportService({ + storageDir: path.join(root, 'scheduled-reports-legacy'), + executeRule: (id, runOptions) => chain.service.executeScheduledRule(id, runOptions), + listRules: () => chain.store.listRules(), + listExecutions: (input) => chain.log.list(input), + isDatabaseReady: () => true, + now: () => AFTER_SLOT + }) + await restarted.tick(AFTER_SLOT) + await restarted.settle() + + expect(chain.sends).toHaveLength(1) + }) + + it('执行日志与步骤 detail 不泄露 roomId / wxid / 文件路径', async () => { + const chain = buildChain({ target: 'contact' }) + + await chain.scheduler.tick(AFTER_SLOT) + await chain.scheduler.settle() + + const record = chain.log.list()[0] + const serialized = JSON.stringify(record) + expect(serialized).not.toContain('@chatroom') + expect(serialized).not.toContain('wxid_friend') + expect(serialized).not.toContain(root) + // 但必须留下可读的目标名(备注优先)。 + expect(record.steps.find((step) => step.key === 'send_resolved')?.detail).toBe('好友备注') + }) + + it('定时失败 ⇒ 推一条微信异常通知,且"今天执行"统计与那条日志是同一份', async () => { + const store = new AutomationRuleStore({ userDataPath: () => root, now: () => CREATED_AT }) + const log = new AutomationExecutionLogService({ userDataPath: () => root }) + const runner = new AutomationActionRunner({ + // 生成直接失败 ⇒ 定时执行判 failed。 + generateReport: async () => ({ + success: false, + error: '模型调用超时', + errorCode: 'AI_TIMEOUT' + }) + }) + const service = new AutomationService( + { + getMyUsernameCandidates: () => [SELF], + getSessions: () => [{ username: 'tech@chatroom', nickname: '技术交流群' }] + } as unknown as Wcdb4Client, + { + ruleStore: store, + executionLog: log, + runner, + getCapability: async () => readyCapability(), + now: () => AFTER_SLOT_MS + } + ) + const ruleId = store.createRule(scheduledDraft('source_chat')).id + + const notifications: string[] = [] + const scheduler = new ScheduledReportService({ + storageDir: path.join(root, 'scheduled-reports-legacy'), + executeRule: (id, runOptions) => service.executeScheduledRule(id, runOptions), + listRules: () => store.listRules(), + listExecutions: (input) => log.list(input), + sendNotification: async (request) => { + notifications.push(request.text) + return { success: true, status: 'sent' } + }, + getNotificationRecipient: () => SELF, + getAgentHubStatus: () => ({ hub: 'online', connector: 'online', updatedAt: 0 }), + isDatabaseReady: () => true, + now: () => AFTER_SLOT + }) + + // 开启异常通知会先试发一条,把它排掉,后面只看执行失败那一条。 + await scheduler.setNotificationEnabled(true) + expect(notifications).toHaveLength(1) + notifications.length = 0 + + await scheduler.tick(AFTER_SLOT) + await scheduler.settle() + + expect(log.list()[0].status).toBe('failed') + expect(log.list()[0].ruleId).toBe(ruleId) + expect(notifications).toHaveLength(1) + expect(notifications[0]).toContain(RULE_NAME) + expect(notifications[0]).toContain('模型调用超时') + }) + + it('规则不存在 / 目标待重选 ⇒ 明确拒绝,不执行也不发送', async () => { + const chain = buildChain() + + const missing = await chain.service.executeScheduledRule('rule_does_not_exist', { + trigger: 'manual' + }) + expect(missing.executed).toBe(false) + expect(missing.reason).toBe('rule_not_found') + + // 把规则改成「目标待重选」:迁移遗留态,执行必然失败 ⇒ 直接拒绝。 + const current = chain.store.getRule(chain.ruleId) as AutomationRule + chain.store.updateRule(chain.ruleId, { + ...(current as unknown as AutomationRuleDraft), + scheduledReport: { ...current.scheduledReport!, targetNeedsReview: true } + }) + const needsReview = await chain.service.executeScheduledRule(chain.ruleId, { + trigger: 'manual' + }) + expect(needsReview.executed).toBe(false) + expect(needsReview.reason).toBe('target_needs_review') + expect(chain.sends).toHaveLength(0) + }) +}) diff --git a/tests/typecheck-baseline.json b/tests/typecheck-baseline.json index a82dcb7..915cccc 100644 --- a/tests/typecheck-baseline.json +++ b/tests/typecheck-baseline.json @@ -1,6 +1,6 @@ { "note": "测试文件类型检查基线:只允许下降,不允许上升。用 node scripts/typecheck-tests.cjs --update 下调。", - "total": 234, + "total": 209, "files": { "tests/component/ai-model-settings.test.tsx": 2, "tests/component/ai-search-cache-consent.test.tsx": 3, @@ -10,7 +10,7 @@ "tests/component/database-connection.test.tsx": 2, "tests/component/export-voice-transcript.test.tsx": 1, "tests/component/export-workspace.test.tsx": 8, - "tests/component/group-exit-monitor-workspace.test.tsx": 11, + "tests/component/group-exit-monitor-workspace.test.tsx": 10, "tests/component/image-bubble.test.tsx": 1, "tests/component/image-text-index-card.test.tsx": 5, "tests/component/local-image-text-recognition.test.tsx": 1, @@ -32,13 +32,11 @@ "tests/integration/image-text-index-synthetic-e2e.test.ts": 22, "tests/integration/query-agent-image-ocr-text.test.ts": 16, "tests/integration/query-agent-self-direction.test.ts": 7, - "tests/integration/scheduled-report-api.test.ts": 3, "tests/unit/agent-hub-routing.test.ts": 1, "tests/unit/ai-search-pipeline-service.test.ts": 1, "tests/unit/ai-search-provider-consent.test.ts": 5, "tests/unit/ai-search-workspace-pure-functions.test.ts": 13, "tests/unit/ask-wechat-service.test.ts": 8, - "tests/unit/group-exit-monitor-service.test.ts": 1, "tests/unit/group-report.test.ts": 1, "tests/unit/http-media-service.test.ts": 2, "tests/unit/knowledge-freshness.test.ts": 1, @@ -47,8 +45,6 @@ "tests/unit/query-agent-image-ocr-coverage.test.ts": 1, "tests/unit/query-agent-service.test.ts": 4, "tests/unit/report-template-market.test.ts": 1, - "tests/unit/scheduled-report-api-service.test.ts": 2, - "tests/unit/scheduled-report-service.test.ts": 18, "tests/unit/system-ocr-service.test.ts": 3, "tests/unit/voice-pipeline.test.ts": 1 } diff --git a/tests/unit/automation-rule-store.test.ts b/tests/unit/automation-rule-store.test.ts index 48ce571..cede5c0 100644 --- a/tests/unit/automation-rule-store.test.ts +++ b/tests/unit/automation-rule-store.test.ts @@ -220,16 +220,37 @@ describe('AutomationRuleStore', () => { }) }) + /* + * 存量安装:早期版本把"部分勾选"判成无法无损映射,于是落盘了 + * `targetNeedsReview: true`。这个标记会拦发送(`handleGroupExit` 第②道闸门), + * 所以**用户重存一次必须能清掉它** —— 否则那条规则永远发不出去。 + * + * 注意种的是"已经落盘的存量状态",不是靠迁移产出:迁移现在不再产生这个标记了。 + */ it('保存退群通知会清掉迁移遗留的「目标待重选」标记', () => { - writeLegacyMonitorState({ - monitoredRoomIds: ['a@chatroom', 'b@chatroom'], - notificationRoomIds: ['a@chatroom'] - }) const store = new AutomationRuleStore(deps(root)) + store.saveLeaveNotificationRule({ + name: '退群通知', + enabled: false, + ruleType: 'leave_notification', + scope: 'group', + conditions: {}, + actions: [], + cooldownSeconds: 0, + replyDelaySeconds: 2, + leaveNotification: { + target: { type: 'source_chat' }, + template: '[退群监测]', + notifyScope: 'all', + notifyRoomIds: [], + targetNeedsReview: true + } + }) expect( store.getRule(BUILTIN_LEAVE_NOTIFICATION_RULE_ID)?.leaveNotification?.targetNeedsReview ).toBe(true) + // 用户来重存一次:草稿里不带这个标记 ⇒ 归一化后它就该消失。 store.saveLeaveNotificationRule({ ...store.getRule(BUILTIN_LEAVE_NOTIFICATION_RULE_ID), enabled: true, @@ -384,14 +405,17 @@ describe('AutomationRuleStore', () => { ) expect(leave?.enabled).toBe(false) - // 关闭状态下 target 只是占位值(= 默认目标),不会发送任何东西。 + // 关闭状态下 target 只是占位值(= 「发回本群」),不会发送任何东西。 expect(leave?.leaveNotification?.target).toEqual({ type: 'source_chat' }) + // 范围本身也精确:`selected` + 空集 = 「一个群都不通知」。 + expect(leave?.leaveNotification?.notifyScope).toBe('selected') + expect(leave?.leaveNotification?.notifyRoomIds).toEqual([]) // 用户写的模板内容保留;同一次启动的"补群名"升级会另加一行。 expect(leave?.leaveNotification?.template).toContain('退群: {user}') expect(leave?.leaveNotification?.template).toContain('群聊: {groupName}') }) - it('旧配置在每个被监控群都通知 → 无损映射为「当前群聊」且保持启用', () => { + it('旧配置在每个被监控群都通知 → 无损映射为「当前群聊」+ 全部范围,且保持启用', () => { writeLegacyMonitorState({ monitoredRoomIds: ['a@chatroom', 'b@chatroom'], notificationRoomIds: ['a@chatroom', 'b@chatroom'] @@ -403,15 +427,16 @@ describe('AutomationRuleStore', () => { expect(leave?.enabled).toBe(true) expect(leave?.leaveNotification?.target).toEqual({ type: 'source_chat' }) + expect(leave?.leaveNotification?.notifyScope).toBe('all') expect(leave?.leaveNotification?.targetNeedsReview).toBeUndefined() }) /* - * 关键的一条:旧「通知群聊」是**逐群**的多值配置,新目标是**单值**。 - * 只勾了一部分时无法无损映射 —— 这时候: - * 不取第一个群、不改写成当前群聊、保持关闭、要求用户重选,并留一份备份。 + * 关键的一条:旧「通知群聊」是**逐群**的多值配置,恢复 `notifyScope` 之后 + * 它就是一个正常可表达的字段 —— 所以这里**不再降级成 needs_review**: + * 那份勾选被原样保留、规则保持启用、不会再多一次"被无辜停用"的体验。 */ - it('旧配置只勾了部分群 → 标记待重选、不发送、留下备份,绝不猜', () => { + it('旧配置只勾了部分群 → 原样保留那份子集,保持启用,不做有损改写', () => { writeLegacyMonitorState({ monitoredRoomIds: ['a@chatroom', 'b@chatroom', 'c@chatroom'], notificationRoomIds: ['b@chatroom'], @@ -421,11 +446,13 @@ describe('AutomationRuleStore', () => { const store = new AutomationRuleStore(deps(root)) const leave = store.getRule(BUILTIN_LEAVE_NOTIFICATION_RULE_ID) - expect(leave?.enabled).toBe(false) - expect(leave?.leaveNotification?.targetNeedsReview).toBe(true) - // 不回落到「取第一个群」,也不改写成别的具体目标 —— 只留默认占位(当前群聊), - // 且因为 enabled=false + targetNeedsReview=true,在用户重选之前不会发送。 + expect(leave?.enabled).toBe(true) + expect(leave?.leaveNotification?.targetNeedsReview).toBeUndefined() + // 收件人恒为事件所在群(旧版 notifyGroup 的事实)⇒ target 仍是 source_chat。 expect(leave?.leaveNotification?.target).toEqual({ type: 'source_chat' }) + // 二次勾选逐字保留:只通知 b,不多不少。 + expect(leave?.leaveNotification?.notifyScope).toBe('selected') + expect(leave?.leaveNotification?.notifyRoomIds).toEqual(['b@chatroom']) // 用户写的模板内容保留;同一次启动的"补群名"升级会另加一行。 expect(leave?.leaveNotification?.template).toContain('退群: {user}') expect(leave?.leaveNotification?.template).toContain('群聊: {groupName}') @@ -434,10 +461,10 @@ describe('AutomationRuleStore', () => { path.join(root, 'automation', 'leave-notification-migration-backup.json') ) expect(backup).toMatchObject({ - outcome: 'needs_review', + outcome: 'lossless_selected_groups', legacy: { notificationRoomIds: ['b@chatroom'] } }) - expect(store.getLastLeaveNotificationMigration()?.outcome).toBe('needs_review') + expect(store.getLastLeaveNotificationMigration()?.outcome).toBe('lossless_selected_groups') }) it('迁移只跑一次:重启两次也只有一条退群通知规则', () => { @@ -477,6 +504,179 @@ describe('AutomationRuleStore', () => { }) }) +/** + * 旧「定时日报任务」→ `scheduled_report` 规则的 store 级迁移。 + * + * 纯映射器的逐字段断言在 `scheduled-report-migration.test.ts`; + * 这里只守 store 负责的三件事:**插几条 / 幂等 / 解析器不可用时推迟**。 + */ +describe('AutomationRuleStore · 定时日报迁移', () => { + const legacyTasksFile = (): string => path.join(root, 'scheduled-reports', 'tasks.json') + const backupFile = (): string => + path.join(root, 'automation', 'scheduled-report-migration-backup.json') + + /** 与 main 侧注入的真实解析器同口径(`@chatroom` 原样透传)。 */ + const resolver = (raw: string): string | undefined => { + const key = raw.trim() + if (!key) return undefined + if (key.endsWith('@chatroom')) return key + return { 'tech-md5': 'tech@chatroom', 'product-md5': 'product@chatroom' }[key] + } + + const writeLegacyTasks = (value: unknown): void => { + fs.ensureDirSync(path.dirname(legacyTasksFile())) + fs.writeJsonSync(legacyTasksFile(), value) + } + + const legacyTasks = (): unknown[] => [ + { + id: 'scheduled_report_a', + name: 'A 群日报', + group: 'tech-md5', + target: 'tech@chatroom', + scheduleTime: '09:00', + reportRange: 'yesterday', + enabled: true, + createdAt: '2026-08-01T01:00:00.000Z', + updatedAt: '2026-08-01T01:00:00.000Z' + }, + { + id: 'scheduled_report_b', + name: 'B 群日报', + group: 'product-md5', + target: 'product@chatroom', + scheduleTime: '10:00', + reportRange: 'today', + enabled: false, + createdAt: '2026-08-02T01:00:00.000Z', + updatedAt: '2026-08-02T01:00:00.000Z' + }, + { + id: 'scheduled_report_c', + name: '发到别的群', + group: 'tech-md5', + target: 'product@chatroom', + scheduleTime: '11:00', + reportRange: 'today', + enabled: true, + createdAt: '2026-08-03T01:00:00.000Z', + updatedAt: '2026-08-03T01:00:00.000Z' + } + ] + + beforeEach(() => { + fs.removeSync(path.join(root, 'automation')) + fs.removeSync(path.join(root, 'scheduled-reports')) + }) + + it('3 条旧任务 ⇒ 3 条 scheduled_report 规则(连同内置规则一起),且判定分布如实', () => { + writeLegacyTasks(legacyTasks()) + const store = new AutomationRuleStore(deps(root)) + store.setLegacyConversationResolver(resolver) + + const scheduled = store.listRules().filter((rule) => rule.ruleType === 'scheduled_report') + expect(scheduled.map((rule) => rule.id).sort()).toEqual([ + 'scheduled_report_a', + 'scheduled_report_b', + 'scheduled_report_c' + ]) + + const summary = store.getLastScheduledReportMigration() + expect(summary).toEqual({ + total: 3, + migrated: 3, + lossless: 2, + needsReview: 1, + duplicatesSkipped: 0 + }) + + // 「发到别的群」那条:不自动改写目标,且必须停用。 + const needsReview = scheduled.find((rule) => rule.id === 'scheduled_report_c') + expect(needsReview?.enabled).toBe(false) + expect(needsReview?.scheduledReport?.targetNeedsReview).toBe(true) + // 旧任务是启用的,但迁移后必须停用 —— 不能替用户决定发到哪儿。 + expect(store.getRule('scheduled_report_a')?.enabled).toBe(true) + }) + + it('二次启动不会重复迁移(仍只有 3 条,不是 6 条)', () => { + writeLegacyTasks(legacyTasks()) + + const first = new AutomationRuleStore(deps(root)) + first.setLegacyConversationResolver(resolver) + expect( + first.listRules().filter((rule) => rule.ruleType === 'scheduled_report') + ).toHaveLength(3) + + // 第二个实例 = 重启:从盘上读,旧 tasks.json 仍在原处。 + const second = new AutomationRuleStore(deps(root)) + second.setLegacyConversationResolver(resolver) + const ids = second + .listRules() + .filter((rule) => rule.ruleType === 'scheduled_report') + .map((rule) => rule.id) + expect(ids).toHaveLength(3) + expect(new Set(ids).size).toBe(3) + // 已迁移标记落盘 ⇒ 不再有待迁任务。 + expect(second.hasPendingScheduledReportMigration()).toBe(false) + }) + + it('解析器不可用时**推迟**迁移:不置标记、不插规则、如实报告待迁', () => { + writeLegacyTasks(legacyTasks()) + const store = new AutomationRuleStore(deps(root)) + + expect(store.listRules().filter((rule) => rule.ruleType === 'scheduled_report')).toHaveLength(0) + expect(store.hasPendingScheduledReportMigration()).toBe(true) + expect(store.getLastScheduledReportMigration()).toBeNull() + + // 数据库就绪后注入解析器 ⇒ 立刻补跑。 + store.setLegacyConversationResolver(resolver) + expect(store.listRules().filter((rule) => rule.ruleType === 'scheduled_report')).toHaveLength(3) + expect(store.hasPendingScheduledReportMigration()).toBe(false) + }) + + it('迁移前把旧任务原样落一份 backup(用户数据可回溯)', () => { + const tasks = legacyTasks() + writeLegacyTasks(tasks) + + const store = new AutomationRuleStore(deps(root)) + // 先注入解析器、后首次加载:这正是 main 的真实顺序(构造函数不读盘)。 + // `setLegacyConversationResolver` 在未加载时只记住 resolver, + // 迁移推迟到第一次 `ensureLoaded()`,注入顺序不影响结果。 + store.setLegacyConversationResolver(resolver) + store.listRules() + + expect(fs.pathExistsSync(backupFile())).toBe(true) + const backup = fs.readJsonSync(backupFile()) as { + summary: { migrated: number } + legacyTasks: unknown[] + } + expect(backup.summary.migrated).toBe(3) + // 备份里保留**原始字段**(包括旧目标原文),否则出了问题无法回溯。 + expect(backup.legacyTasks).toEqual(tasks) + // 旧 tasks.json 不被删除或改写 —— 迁移必须是可逆的。 + expect(fs.readJsonSync(legacyTasksFile())).toEqual(tasks) + }) + + it('没有旧任务时走全新安装路径:不插任何定时日报规则,也不报待迁', () => { + const store = new AutomationRuleStore(deps(root)) + store.setLegacyConversationResolver(resolver) + + expect(store.listRules().filter((rule) => rule.ruleType === 'scheduled_report')).toHaveLength(0) + expect(store.hasPendingScheduledReportMigration()).toBe(false) + expect(store.getLastScheduledReportMigration()).toBeNull() + expect(fs.pathExistsSync(backupFile())).toBe(false) + }) + + it('旧文件损坏时按「没有旧任务」处理,绝不抛异常挡住启动', () => { + fs.ensureDirSync(path.dirname(legacyTasksFile())) + fs.writeFileSync(legacyTasksFile(), 'not json at all', 'utf8') + + const store = new AutomationRuleStore(deps(root)) + store.setLegacyConversationResolver(resolver) + expect(store.listRules().filter((rule) => rule.ruleType === 'scheduled_report')).toHaveLength(0) + }) +}) + describe('AutomationExecutionLogService', () => { beforeEach(() => { fs.removeSync(path.join(root, 'automation')) diff --git a/tests/unit/automation-service-group-exit.test.ts b/tests/unit/automation-service-group-exit.test.ts index ca92a47..d1a2f1a 100644 --- a/tests/unit/automation-service-group-exit.test.ts +++ b/tests/unit/automation-service-group-exit.test.ts @@ -201,6 +201,65 @@ describe('AutomationService.handleGroupExit', () => { expect(harness.executions).toHaveLength(0) }) + /* + * 「通知范围」=历史「退群监控 → 通知群聊」的第二层。 + * 缺了它,规则一启用就等于给全部已监控群聊发通知 —— 就是那个被报回来的问题。 + */ + it('范围 = 仅选中的群聊:勾中的群照常发送', async () => { + const harness = createHarness({ + config: { notifyScope: 'selected', notifyRoomIds: ['room@chatroom'] } + }) + + await harness.service.handleGroupExit(exitEvent()) + + expect(harness.requests).toHaveLength(1) + expect(harness.executions).toHaveLength(1) + }) + + it('范围 = 仅选中的群聊:没勾的群不发,且不创建 execution', async () => { + const harness = createHarness({ + config: { notifyScope: 'selected', notifyRoomIds: ['other@chatroom'] } + }) + + await harness.service.handleGroupExit(exitEvent()) + + expect(harness.requests).toHaveLength(0) + // 被范围排除**不是**"一次失败的执行",所以不能留失败记录吓人。 + expect(harness.executions).toHaveLength(0) + }) + + it('范围 = 全部已监控群聊:不筛群,照常发送', async () => { + const harness = createHarness({ config: { notifyScope: 'all', notifyRoomIds: [] } }) + + await harness.service.handleGroupExit(exitEvent()) + + expect(harness.requests).toHaveLength(1) + }) + + /* + * 范围判定必须在幂等**之前**:否则被排除的群会白白占用该事件的幂等位, + * 用户随后补勾了那个群,同一个事件反而再也发不出去。 + */ + it('范围外不占用幂等位:补勾之后同一事件仍能发出', async () => { + const harness = createHarness({ + config: { notifyScope: 'selected', notifyRoomIds: [] } + }) + + await harness.service.handleGroupExit(exitEvent()) + expect(harness.requests).toHaveLength(0) + expect(harness.executions).toHaveLength(0) + + harness.setRule( + createDefaultLeaveNotificationRule(NOW, { + notifyScope: 'selected', + notifyRoomIds: ['room@chatroom'] + }) + ) + await harness.service.handleGroupExit(exitEvent()) + + expect(harness.requests).toHaveLength(1) + }) + it('缺少 eventId 时直接跳过,绝不发送', async () => { const harness = createHarness() @@ -260,7 +319,7 @@ describe('AutomationService.handleGroupExit', () => { }) /* - * §「旧联系人失效」:不偷偷发给别人,execution 记 failed,Gateway 一次都不调。 + * 「旧联系人失效」:不偷偷发给别人,execution 记 failed,Gateway 一次都不调。 */ it('指定好友已不存在 → execution failed 且不调用 Gateway', async () => { const harness = createHarness({ @@ -353,7 +412,7 @@ describe('AutomationService.handleGroupExit', () => { }) /* - * §「不同事件」:同群两个成员先后退出就是两条通知。 + * 不同事件:同群两个成员先后退出就是两条通知。 * 退群通知**不复用**消息型规则的 cooldown,否则第二次会被时间窗吞掉。 */ it('不同 eventId 各自执行一次,不被合并', async () => { diff --git a/tests/unit/leave-notification-target.test.ts b/tests/unit/leave-notification-target.test.ts index c810bf5..2a0dc8c 100644 --- a/tests/unit/leave-notification-target.test.ts +++ b/tests/unit/leave-notification-target.test.ts @@ -45,6 +45,23 @@ const contacts: LeaveNotificationCandidateContact[] = [ { m_nsUsrName: 'wxid_self', m_nsNickName: '我自己', type: 'user' } ] +/** + * 构造一份合法配置。 + * + * 本文件关心的是**目标解析**,通知范围(`notifyScope`)与之正交, + * 所以在这里统一补默认值 —— 契约再新增字段时也只改这一处, + * 不必去逐个补十几处字面量(那才是漏字段的根源)。 + */ +function config(overrides: Partial = {}): LeaveNotificationConfig { + return { + target: { type: 'source_chat' }, + template: '', + notifyScope: 'all', + notifyRoomIds: [], + ...overrides + } +} + function resolve( config: LeaveNotificationConfig, overrides: { selfWxid?: string; event?: GroupMemberExitedEvent } = {} @@ -59,7 +76,7 @@ function resolve( describe('leaveNotificationTarget · 四种目标', () => { it('当前群聊 = 事件所在群,不是"最后活跃会话"', () => { - const result = resolve({ target: { type: 'source_chat' }, template: '' }) + const result = resolve(config()) expect(result).toEqual({ ok: true, @@ -71,23 +88,42 @@ describe('leaveNotificationTarget · 四种目标', () => { }) it('当前群聊在群名缺失时用中性称呼,不回落成 roomId', () => { - const result = resolveLeaveNotificationTarget({ - config: { target: { type: 'source_chat' }, template: '' }, + // 事件里没带群名,但本地联系人表里有这个群 ⇒ 用会话昵称, + // 比「当前群聊」更具体,且**仍然不是** roomId。 + const withNickname = resolveLeaveNotificationTarget({ + config: config(), event: { ...EVENT, groupName: undefined }, contacts, selfWxid: 'wxid_self' }) + expect(withNickname.ok).toBe(true) + if (withNickname.ok) { + expect(withNickname.target.recipient.name).toBe('测试群') + expect(withNickname.target.displayName).toBe('测试群') + } - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.target.recipient.name).toBe('当前群聊') - expect(result.target.displayName).toBe('发生退群事件的群聊') + // 群名与联系人表**都**拿不到 ⇒ 退到中性称呼。 + // 唯一不能发生的事是把 roomId / wxid 当显示名吐出去。 + const fallback = resolveLeaveNotificationTarget({ + config: config(), + event: { ...EVENT, groupName: undefined }, + contacts: [], + selfWxid: 'wxid_self' + }) + expect(fallback.ok).toBe(true) + if (fallback.ok) { + expect(fallback.target.recipient.name).toBe('当前群聊') + expect(fallback.target.displayName).toBe('发生退群事件的群聊') + // `recipient.id` 本来就该是真实会话 id(发送要用它), + // 这条断言约束的是**显示名**:不许把 id 当称呼给人看。 + expect(fallback.target.recipient.name).not.toContain('@chatroom') + expect(fallback.target.displayName).not.toContain('@chatroom') } }) it('当前群聊在缺少 conversationId 时直接失败', () => { const result = resolveLeaveNotificationTarget({ - config: { target: { type: 'source_chat' }, template: '' }, + config: config(), event: { ...EVENT, conversationId: '' }, contacts, selfWxid: 'wxid_self' @@ -97,7 +133,7 @@ describe('leaveNotificationTarget · 四种目标', () => { }) it('发给自己 = 当前登录账号的真实 wxid', () => { - const result = resolve({ target: { type: 'self' }, template: '' }, { selfWxid: 'wxid_me' }) + const result = resolve(config({ target: { type: 'self' } }), { selfWxid: 'wxid_me' }) expect(result).toEqual({ ok: true, @@ -113,7 +149,7 @@ describe('leaveNotificationTarget · 四种目标', () => { * 那会让用户以为"发给自己"生效了,实际消息去了别处。 */ it('拿不到自身身份时失败,且不 fallback 文件传输助手', () => { - const result = resolve({ target: { type: 'self' }, template: '' }, { selfWxid: '' }) + const result = resolve(config({ target: { type: 'self' } }), { selfWxid: '' }) expect(result.ok).toBe(false) if (!result.ok) { @@ -123,7 +159,7 @@ describe('leaveNotificationTarget · 四种目标', () => { }) it('文件传输助手用协议级固定身份,不靠昵称搜索', () => { - const result = resolve({ target: { type: 'file_transfer' }, template: '' }) + const result = resolve(config({ target: { type: 'file_transfer' } })) expect(result.ok).toBe(true) if (result.ok) { @@ -136,10 +172,9 @@ describe('leaveNotificationTarget · 四种目标', () => { }) it('指定好友用稳定 id,显示名用备注', () => { - const result = resolve({ - target: { type: 'contact', contactId: 'wxid_friend' }, - template: '' - }) + const result = resolve( + config({ target: { type: 'contact', contactId: 'wxid_friend' } }) + ) expect(result).toEqual({ ok: true, @@ -153,14 +188,14 @@ describe('leaveNotificationTarget · 四种目标', () => { describe('leaveNotificationTarget · 失效与非法', () => { it('联系人不在了 → 失败,且不往别处发', () => { - const result = resolve({ target: { type: 'contact', contactId: 'wxid_gone' }, template: '' }) + const result = resolve(config({ target: { type: 'contact', contactId: 'wxid_gone' } })) expect(result.ok).toBe(false) if (!result.ok) expect(result.error).toContain('已不存在或当前无法发送') }) it('还没选好友 → 失败并提示重新选择', () => { - const result = resolve({ target: { type: 'contact' }, template: '' }) + const result = resolve(config({ target: { type: 'contact' } })) expect(result.ok).toBe(false) if (!result.ok) expect(result.error).toContain('还没有选择通知联系人') @@ -173,7 +208,7 @@ describe('leaveNotificationTarget · 失效与非法', () => { WECHAT_FILE_HELPER_USERNAME, 'wxid_self' ]) { - const result = resolve({ target: { type: 'contact', contactId }, template: '' }) + const result = resolve(config({ target: { type: 'contact', contactId } })) expect(result.ok).toBe(false) } }) @@ -188,10 +223,7 @@ describe('leaveNotificationTarget · 失效与非法', () => { }) it('未知目标类型 → 失败(不允许静默当成默认值)', () => { - const result = resolve({ - target: { type: 'webhook' as never }, - template: '' - }) + const result = resolve(config({ target: { type: 'webhook' as never } })) expect(result.ok).toBe(false) }) }) diff --git a/tests/unit/scheduled-report-api-service.test.ts b/tests/unit/scheduled-report-api-service.test.ts index b9eed52..d26e791 100644 --- a/tests/unit/scheduled-report-api-service.test.ts +++ b/tests/unit/scheduled-report-api-service.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it, vi } from 'vitest' import type { PersonalWechatSendCapability } from '../../src/shared/personal-wechat' -import type { ScheduledReportTask } from '../../src/shared/scheduled-report' +import type { + ScheduledReportCreateInput, + ScheduledReportExecution, + ScheduledReportResult, + ScheduledReportTask, + ScheduledReportUpdateInput +} from '../../src/shared/scheduled-report' import { ScheduledReportApiService, type ScheduledReportApiDependencies @@ -60,36 +66,60 @@ function makeApi( ): ApiTestResult { const tasks = options.tasks || [] const service = { - listTasks: vi.fn(async () => tasks), - listExecutions: vi.fn(async () => []), - createTask: vi.fn(async (input: Record) => { - const created = task({ - id: `task-${tasks.length + 1}`, - name: String(input.name), - group: String(input.group), - target: String(input.target), - scheduleTime: String(input.scheduleTime), - reportRange: input.reportRange as ScheduledReportTask['reportRange'], - enabled: Boolean(input.enabled) - }) - tasks.push(created) - return { success: true, data: created } - }), - updateTask: vi.fn(async () => ({ success: true, data: tasks[0] })), - deleteTask: vi.fn(async (id: string) => ({ success: true, data: { deletedId: id } })), - setTaskEnabled: vi.fn(async (id: string, enabled: boolean) => ({ - success: true, - data: task({ id, enabled }) - })), - runScheduledReportNow: vi.fn(async (id: string) => ({ - success: true, - data: { - id: 'execution-1', - taskId: id, - startedAt: '2026-08-27T01:00:00.000Z', - status: 'success' as const + listTasks: vi.fn(async (): Promise => tasks), + listExecutions: vi.fn( + async (_taskId?: string): Promise => [] + ), + createTask: vi.fn( + async (input: ScheduledReportCreateInput): Promise> => { + const created = task({ + id: `task-${tasks.length + 1}`, + name: String(input.name), + group: String(input.group), + target: String(input.target), + scheduleTime: String(input.scheduleTime), + reportRange: input.reportRange as ScheduledReportTask['reportRange'], + enabled: Boolean(input.enabled) + }) + tasks.push(created) + return { success: true, data: created } } - })) + ), + updateTask: vi.fn( + async ( + _taskId: string, + _input: ScheduledReportUpdateInput + ): Promise> => ({ + success: true, + data: tasks[0] + }) + ), + deleteTask: vi.fn( + async (id: string): Promise> => ({ + success: true, + data: { deletedId: id } + }) + ), + setTaskEnabled: vi.fn( + async ( + id: string, + enabled: boolean + ): Promise> => ({ + success: true, + data: task({ id, enabled }) + }) + ), + runScheduledReportNow: vi.fn( + async (id: string): Promise> => ({ + success: true, + data: { + id: 'execution-1', + taskId: id, + startedAt: '2026-08-27T01:00:00.000Z', + status: 'success' as const + } + }) + ) } return { api: new ScheduledReportApiService({ diff --git a/tests/unit/scheduled-report-migration.test.ts b/tests/unit/scheduled-report-migration.test.ts new file mode 100644 index 0000000..bb84c86 --- /dev/null +++ b/tests/unit/scheduled-report-migration.test.ts @@ -0,0 +1,296 @@ +import { describe, expect, it } from 'vitest' + +import { + planScheduledReportMigration, + planScheduledReportMigrationBatch, + scheduledReportMigrationRuleId, + type LegacyScheduledReportTask +} from '../../src/main/services/scheduled-report-migration' +import { SCHEDULED_REPORT_DEFAULT_MEMBER_NAME_MODE } from '../../src/shared/automation' + +/** + * 旧「定时日报任务」→ `AutomationRule(ruleType='scheduled_report')` 的映射器。 + * + * 这是本次迁移里**唯一会改变用户既有行为**的地方,所以每条断言都对应一条产品约束: + * - Rule ID 优先复用旧 taskId(不许拿显示名当稳定 ID); + * - 配置无损映射(时间 / 范围 / 消息类型 / 模板 / 成员名口径 / 超时); + * - 只有"能证明是同一个群"才无损映射成 `source_chat`; + * 历史行为事实上支持"生成 A 群日报 → 发 B 群",新产品不支持 ⇒ 必须 `targetNeedsReview` + * 且 **enabled = false**,绝不自动改写、绝不静默丢掉目标; + * - 解析不到群标识时**保留原值**,运行期如实失败,而不是在这里猜一个群; + * - 批次按 id 去重 ⇒ 幂等(同一份文件跑两次结果一致)。 + */ + +const NOW = Date.parse('2026-08-27T10:00:00+08:00') + +/** 假联系人表:md5 / 群名 → roomId。 */ +const CONTACTS: Record = { + 'tech-md5': 'tech@chatroom', + 'product-md5': 'product@chatroom', + '技术交流群': 'tech@chatroom' +} + +/** + * 与 main 侧 `setLegacyConversationResolver` 注入的真实解析器**同口径**: + * - 以 `@chatroom` 结尾 ⇒ 原样返回(旧 UI 的 `target` 存的就是 roomId, + * 而 `chat.resolveMd5` 对 roomId 是查不到的,所以这条 pass-through 是必需的); + * - 否则按 md5 / 群名查联系人表。 + */ +const resolve = (raw: string): string | undefined => { + const key = raw.trim() + if (!key) return undefined + if (key.endsWith('@chatroom')) return key + return CONTACTS[key] +} + +const task = (overrides: LegacyScheduledReportTask = {}): LegacyScheduledReportTask => ({ + id: 'scheduled_report_old_1', + name: '技术交流群 · 每日日报', + group: 'tech-md5', + target: 'tech@chatroom', + scheduleTime: '09:15', + reportRange: 'yesterday', + messageTypes: ['text', 'image'], + templateId: 'v1', + memberNameMode: 'wechatNickname', + timeoutSeconds: 600, + enabled: true, + createdAt: '2026-08-01T01:00:00.000Z', + updatedAt: '2026-08-20T01:00:00.000Z', + ...overrides +}) + +describe('定时日报迁移 · Rule ID', () => { + it('优先原样复用旧 taskId(不拿显示名当稳定 ID)', () => { + expect(scheduledReportMigrationRuleId(task())).toBe('scheduled_report_old_1') + + // 改名字不影响 id —— 显示名不是身份。 + expect(scheduledReportMigrationRuleId(task({ name: '换个名字' }))).toBe( + 'scheduled_report_old_1' + ) + }) + + it('旧 id 缺失时退化成由稳定字段派生的确定性 id(重复迁移不产生新规则)', () => { + const broken = task({ id: undefined }) + const first = scheduledReportMigrationRuleId(broken) + const second = scheduledReportMigrationRuleId({ ...broken }) + + expect(first).toMatch(/^scheduled-report:[0-9a-f]{32}$/) + expect(second).toBe(first) + }) + + it('id 全是空白字符时同样走派生路径', () => { + expect(scheduledReportMigrationRuleId(task({ id: ' ' }))).toMatch(/^scheduled-report:/) + }) +}) + +describe('定时日报迁移 · 逐字段无损映射', () => { + it('配置字段一个不丢(不是"看起来差不多")', () => { + const plan = planScheduledReportMigration(task(), resolve, NOW) + + expect(plan.outcome).toBe('lossless_source_chat') + expect(plan.name).toBe('技术交流群 · 每日日报') + expect(plan.enabled).toBe(true) + expect(plan.config.schedule).toEqual({ time: '09:15' }) + expect(plan.config.report).toEqual({ + // 来源群存**稳定会话 id**:旧值 'tech-md5' 被解析成 roomId。 + sourceConversationId: 'tech@chatroom', + range: 'yesterday', + messageTypes: ['text', 'image'], + templateId: 'v1', + memberNameMode: 'wechatNickname', + timeoutSeconds: 600 + }) + expect(plan.config.target).toEqual({ type: 'source_chat' }) + expect(plan.config.targetNeedsReview).toBeUndefined() + expect(plan.config.legacyTarget).toBeUndefined() + // 来源群被改写过 ⇒ 留下原文方便用户核对。 + expect(plan.config.legacySourceGroup).toBe('tech-md5') + }) + + it('createdAt / updatedAt 继承旧值(ISO → 毫秒),非法值回落迁移时刻', () => { + const plan = planScheduledReportMigration(task(), resolve, NOW) + expect(plan.createdAt).toBe(Date.parse('2026-08-01T01:00:00.000Z')) + expect(plan.updatedAt).toBe(Date.parse('2026-08-20T01:00:00.000Z')) + + const broken = planScheduledReportMigration( + task({ createdAt: '不是时间', updatedAt: undefined }), + resolve, + NOW + ) + expect(broken.createdAt).toBe(NOW) + expect(broken.updatedAt).toBe(NOW) + }) + + it('继承调度游标(lastRunAt / lastScheduledSlot),避免迁移后重复补跑', () => { + const slot = new Date('2026-08-26T09:15:00+08:00').toISOString() + const plan = planScheduledReportMigration( + task({ lastRunAt: '2026-08-26T01:15:00.000Z', lastScheduledSlot: slot }), + resolve, + NOW + ) + + expect(plan.config.lastRunAt).toBe('2026-08-26T01:15:00.000Z') + expect(plan.config.lastScheduledSlot).toBe(slot) + }) + + it('脏值一律收敛到合法默认值,绝不落一条跑不起来的规则', () => { + const plan = planScheduledReportMigration( + task({ + scheduleTime: '99:99', + reportRange: '一个不存在的范围', + messageTypes: ['不存在的类型'], + templateId: '不存在的模板', + memberNameMode: '不存在的口径', + timeoutSeconds: 999999 + }), + resolve, + NOW + ) + + expect(plan.config.schedule.time).toBe('18:30') + expect(plan.config.report.range).toBe('today') + expect(plan.config.report.messageTypes).toEqual(['text']) + expect(plan.config.report.templateId).toBe('v1') + expect(plan.config.report.memberNameMode).toBe(SCHEDULED_REPORT_DEFAULT_MEMBER_NAME_MODE) + expect(plan.config.report.timeoutSeconds).toBe(1800) + }) + + it('名字缺失时回落到「定时日报」,不会留一条空名字的规则', () => { + expect(planScheduledReportMigration(task({ name: ' ' }), resolve, NOW).name).toBe('定时日报') + }) +}) + +describe('定时日报迁移 · 发送目标(唯一会改变行为的地方)', () => { + it('target 为空 ⇒ 旧系统本就回落到来源群,等价于 source_chat', () => { + const plan = planScheduledReportMigration(task({ target: '' }), resolve, NOW) + expect(plan.outcome).toBe('lossless_source_chat') + expect(plan.config.target).toEqual({ type: 'source_chat' }) + expect(plan.config.targetNeedsReview).toBeUndefined() + }) + + it('target 与 group 文本相同 ⇒ 无损映射(不依赖能否解析)', () => { + const plan = planScheduledReportMigration( + task({ group: 'tech-md5', target: 'tech-md5' }), + () => undefined, + NOW + ) + expect(plan.outcome).toBe('lossless_source_chat') + expect(plan.config.targetNeedsReview).toBeUndefined() + }) + + it('两者都解析到同一个 roomId ⇒ 无损映射(旧 UI 的常态:group=md5, target=roomId)', () => { + const plan = planScheduledReportMigration( + task({ group: 'tech-md5', target: 'tech@chatroom' }), + resolve, + NOW + ) + expect(plan.outcome).toBe('lossless_source_chat') + + // 这条无损判定**依赖**解析器对 `@chatroom` 的 pass-through: + // 少了它,旧 UI 里每一组 (md5, roomId) 都会退化成 needs_review, + // 等于把所有用户的定时日报全部停用。 + expect(resolve('tech@chatroom')).toBe('tech@chatroom') + }) + + it('target 指向**另一个群** ⇒ needs_review + 强制停用 + 保留旧目标原文', () => { + const plan = planScheduledReportMigration( + task({ group: 'tech-md5', target: 'product@chatroom' }), + resolve, + NOW + ) + + expect(plan.outcome).toBe('needs_review') + // 不能自动改写目标,也不能在用户确认前就被定时发出去。 + expect(plan.enabled).toBe(false) + expect(plan.config.targetNeedsReview).toBe(true) + expect(plan.config.legacyTarget).toBe('product@chatroom') + }) + + it('target 无法解析 ⇒ 一律 needs_review(拿不到证据就不自动改写)', () => { + const plan = planScheduledReportMigration( + task({ group: 'tech-md5', target: 'unknown-id' }), + resolve, + NOW + ) + expect(plan.outcome).toBe('needs_review') + expect(plan.enabled).toBe(false) + expect(plan.config.legacyTarget).toBe('unknown-id') + }) + + it('needs_review 的规则即使旧任务是启用的也必须停用', () => { + const plan = planScheduledReportMigration( + task({ target: 'product@chatroom', enabled: true, group: 'tech-md5' }), + resolve, + NOW + ) + expect(plan.enabled).toBe(false) + }) + + it('来源群解析不到 ⇒ 保留原值,不在迁移期猜一个群', () => { + const plan = planScheduledReportMigration(task({ group: '未知群', target: '' }), resolve, NOW) + // 原值留着 —— 运行期会如实失败,比"悄悄换一个群发"安全得多。 + expect(plan.config.report.sourceConversationId).toBe('未知群') + expect(plan.config.legacySourceGroup).toBeUndefined() + }) +}) + +describe('定时日报迁移 · 批次幂等', () => { + const tasks: LegacyScheduledReportTask[] = [ + task({ id: 'scheduled_report_a', name: 'A', group: 'tech-md5', target: 'tech@chatroom' }), + task({ id: 'scheduled_report_b', name: 'B', group: 'product-md5', target: 'product@chatroom' }), + task({ id: 'scheduled_report_c', name: 'C', group: 'tech-md5', target: 'product@chatroom' }) + ] + + it('3 条旧任务 ⇒ 3 条规则,并给出判定分布', () => { + const { plans, summary } = planScheduledReportMigrationBatch(tasks, resolve, NOW) + + expect(plans.map((plan) => plan.ruleId)).toEqual([ + 'scheduled_report_a', + 'scheduled_report_b', + 'scheduled_report_c' + ]) + expect(summary).toEqual({ + total: 3, + migrated: 3, + lossless: 2, + needsReview: 1, + duplicatesSkipped: 0 + }) + }) + + it('重复执行结果完全一致(幂等,不是"看起来没多")', () => { + const first = planScheduledReportMigrationBatch(tasks, resolve, NOW) + const second = planScheduledReportMigrationBatch(tasks, resolve, NOW) + expect(second).toEqual(first) + }) + + it('同一 id 出现两次只迁一条,并如实计入 duplicatesSkipped', () => { + const { plans, summary } = planScheduledReportMigrationBatch( + [tasks[0], task({ id: 'scheduled_report_a', name: '重复' })], + resolve, + NOW + ) + + expect(plans).toHaveLength(1) + expect(plans[0].name).toBe('A') + expect(summary.total).toBe(2) + expect(summary.migrated).toBe(1) + expect(summary.duplicatesSkipped).toBe(1) + }) + + it('文件里混进非对象元素时跳过而不是崩掉', () => { + const { plans } = planScheduledReportMigrationBatch( + [tasks[0], null as unknown as LegacyScheduledReportTask, 'x' as unknown as LegacyScheduledReportTask], + resolve, + NOW + ) + expect(plans).toHaveLength(1) + }) + + it('空文件 ⇒ 0 条规则(全新安装路径)', () => { + const { plans, summary } = planScheduledReportMigrationBatch([], resolve, NOW) + expect(plans).toHaveLength(0) + expect(summary.migrated).toBe(0) + }) +}) diff --git a/tests/unit/scheduled-report-service.test.ts b/tests/unit/scheduled-report-service.test.ts index 6f1793e..afdbe6b 100644 --- a/tests/unit/scheduled-report-service.test.ts +++ b/tests/unit/scheduled-report-service.test.ts @@ -1,111 +1,33 @@ -import { mkdtemp, readFile, writeFile } from 'fs/promises' +import { mkdtemp, writeFile } from 'fs/promises' import { tmpdir } from 'os' import { join } from 'path' -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('electron', () => ({ app: { getPath: () => '/tmp/tracememo-test-user-data' } })) + import { - calculateNextRunAt, ScheduledReportService, + calculateNextRunAt, + resolveDueScheduledSlot, validateScheduleTime } from '../../src/main/services/scheduled-report-service' -import type { AgentHubStatus } from '../../src/shared/agent-hub' -import type { PersonalWechatSendCapability } from '../../src/shared/personal-wechat' -import type { GeneratedReportRecord } from '../../src/shared/report-history' -import type { - ScheduledReportExecution, - ScheduledReportNotification -} from '../../src/shared/scheduled-report' import type { ScheduledReportDependencies } from '../../src/main/services/scheduled-report-service' +import type { AgentHubStatus } from '../../src/shared/agent-hub' +import { + createDefaultScheduledReportRule, + normalizeScheduledReportConfig, + type AutomationRule +} from '../../src/shared/automation' +import type { ScheduledRuleRunOutcome } from '../../src/shared/automation' -const capability: PersonalWechatSendCapability = { - supported: true, - ready: true, - status: 'ready', - capabilities: { text: true, image: true, voice: true }, - senderStatus: {} as never, - message: 'ready' -} - -const unavailableCapability: PersonalWechatSendCapability = { - ...capability, - ready: false, - status: 'initializing', - capabilities: { text: false, image: false, voice: false }, - message: '请先完成微信消息能力检测' -} - -const reportRecord = (pngPath: string): GeneratedReportRecord => ({ - id: 'report-1', - contactId: 'group-md5', - contactName: '研发群', - dateRange: '昨日', - messageCount: 12, - generatedAt: '2026-08-27T01:00:00.000Z', - reportDate: '2026-08-26', - pngPath, - htmlPath: `${pngPath}.html`, - htmlStatus: 'ready' as const, - pngStatus: 'ready' as const -}) - -function makeDependencies(overrides: Record = {}): ScheduledReportDependencies { - return { - getCapability: async () => capability, - generateReport: async () => ({ - success: true, - groupName: '研发群', - pngPath: '/tmp/generated.png', - messageCount: 12 - }), - saveGeneratedReport: async () => ({ - success: true, - record: reportRecord('/tmp/saved.png') - }), - send: async () => ({ success: true, status: capability.senderStatus }), - sendNotification: async () => ({ success: true, status: 'sent' as const }), - getNotificationRecipient: () => 'owner-wxid', - isDatabaseReady: () => true, - ...overrides - } -} - -async function enableNotifications(storageDir: string): Promise { - await writeFile(join(storageDir, 'settings.json'), JSON.stringify({ enabled: true })) -} - -// tick() 为保证调度不阻塞而 fire-and-forget 启动执行,测试只能轮询执行记录来等待结束。 -// 该路径耗时几乎全部是临时目录的 JSON 落盘:本地约 16ms,Windows CI(含杀毒扫描)实测 -// 50~120ms,最慢一次耗尽旧的 200ms 预算后误报“未结束”。窗口按最坏观测值留足余量, -// 但仍在 5s 的 vitest 用例超时之内,保留有界失败而不是无限等待。 -const EXECUTION_WAIT_TIMEOUT_MS = 4_000 -const EXECUTION_WAIT_POLL_INTERVAL_MS = 10 - -async function runScheduled( - service: ScheduledReportService, - taskId: string -): Promise { - const task = (await service.listTasks()).find((item) => item.id === taskId) - if (!task) throw new Error('scheduled task not found') - const scheduledSlot = task.nextRunAt - await service.tick(new Date(Date.parse(scheduledSlot) + 1_000)) - const deadline = Date.now() + EXECUTION_WAIT_TIMEOUT_MS - let lastStatus = 'no execution recorded' - for (;;) { - // 取最新一次执行,而不是数组首项(executions.json 按追加顺序保存)。 - const latest = (await service.listExecutions(taskId)) - .slice() - .sort((left, right) => Date.parse(right.startedAt) - Date.parse(left.startedAt))[0] - if (latest && latest.status !== 'running') return latest - lastStatus = latest?.status ?? 'no execution recorded' - if (Date.now() >= deadline) { - throw new Error( - `scheduled execution did not finish within ${EXECUTION_WAIT_TIMEOUT_MS}ms ` + - `(slot ${scheduledSlot}, last status: ${lastStatus})` - ) - } - await new Promise((resolve) => setTimeout(resolve, EXECUTION_WAIT_POLL_INTERVAL_MS)) - } -} +/** + * 定时日报服务(退役后)只剩三件事:调度 / 异常通知 / 旧数据只读存档。 + * + * 这一组测试守的是**任务来源已经换成 AutomationRuleStore**: + * - 调度器**只读规则**,执行一律委派给注入的 `executeRule`; + * - 槽位已消费 / 已停用 / 目标待重选 / 数据库未就绪 ⇒ **不执行**; + * - 定时失败 ⇒ 推异常通知;手动执行 / `NO_MESSAGES` ⇒ **不推**; + * - 旧 `tasks.json` / `executions.json` **只读**,且新执行日志按 ruleId 投影。 + */ const onlineAgentHubStatus = (): AgentHubStatus => ({ hub: 'online' as const, @@ -113,863 +35,488 @@ const onlineAgentHubStatus = (): AgentHubStatus => ({ updatedAt: Date.now() }) -describe('scheduled report scheduling', () => { - it('validates daily HH:mm and computes the next local occurrence', () => { +const SCHEDULE_TIME = '18:30' +/** 一个"当天 18:30 之后"的时刻,保证当日槽位已到点。 */ +const AFTER_SLOT = new Date('2026-08-27T18:31:00+08:00') +/** + * 规则的创建时间:**固定**在 2026-08-20。 + * + * 必须早于所有参与断言的槽位,否则「槽位晚于创建时间」这道闸会把一切挡住; + * 也绝不能用 `Date.now()` —— 那样断言会随墙上时钟漂移。 + */ +const CREATED_AT = Date.parse('2026-08-20T00:00:00+08:00') + +function makeRule(overrides: Partial = {}): AutomationRule { + return { + ...createDefaultScheduledReportRule(CREATED_AT, { + id: 'rule-1', + name: '每日晚报', + config: normalizeScheduledReportConfig({ + schedule: { time: SCHEDULE_TIME }, + report: { sourceConversationId: 'g1@chatroom', messageTypes: ['text'] } + }) + }), + ...overrides + } +} + +interface Harness { + service: ScheduledReportService + storageDir: string + rules: AutomationRule[] + /** **真正注入进 service 的那个** mock(可能来自 `overrides`)。 */ + executeRule: ReturnType + sendNotification: ReturnType + listExecutions: ReturnType +} + +async function makeHarness( + overrides: Partial = {}, + rules: AutomationRule[] = [makeRule()] +): Promise { + const storageDir = await mkdtemp(join(tmpdir(), 'tracememo-scheduled-')) + const executeRule = vi.fn( + async (): Promise => ({ + executed: true, + executionId: 'exec-1', + status: 'success', + reportGenerated: true + }) + ) + const sendNotification = vi.fn().mockResolvedValue({ success: true, status: 'sent' }) + const listExecutions = vi.fn().mockReturnValue([]) + const deps = { + storageDir, + executeRule, + listRules: () => rules, + listExecutions, + sendNotification, + getNotificationRecipient: () => 'owner-wxid', + getAgentHubStatus: onlineAgentHubStatus, + isDatabaseReady: () => true, + now: () => AFTER_SLOT, + ...overrides + } as unknown as ScheduledReportDependencies + const service = new ScheduledReportService(deps) + return { + service, + storageDir, + rules, + // 用 dep 上的**最终**实现回填,避免 `overrides` 覆盖后断言打空。 + executeRule: deps.executeRule as unknown as ReturnType, + sendNotification: deps.sendNotification as unknown as ReturnType, + listExecutions: deps.listExecutions as unknown as ReturnType + } +} + +describe('定时日报 · 时间计算', () => { + it('校验 HH:mm 并计算下一次本地时刻', () => { expect(validateScheduleTime('09:05')).toBe(true) expect(validateScheduleTime('24:00')).toBe(false) + expect(validateScheduleTime('9:5')).toBe(false) const from = new Date('2026-08-27T10:00:00+08:00') - const expectedNext = new Date(from) - expectedNext.setHours(9, 5, 0, 0) - if (expectedNext.getTime() <= from.getTime()) expectedNext.setDate(expectedNext.getDate() + 1) - expect(calculateNextRunAt('09:05', from)).toBe(expectedNext.toISOString()) - const expectedSameDay = new Date(from) - expectedSameDay.setHours(11, 5, 0, 0) - expect(calculateNextRunAt('11:05', from)).toBe(expectedSameDay.toISOString()) - }) - - it('defaults notification settings to off and persists a successful enablement', async () => { - const storageDir = await mkdtemp(join(tmpdir(), 'tracememo-scheduled-report-settings-')) - const sendNotification = vi.fn(async () => ({ success: true, status: 'sent' as const })) - const service = new ScheduledReportService({ - storageDir, - getAgentHubStatus: onlineAgentHubStatus, - getNotificationRecipient: () => 'owner-wxid', - sendNotification - }) - - await expect(service.getNotificationSettings()).resolves.toEqual({ enabled: false }) - const enabled = await service.setNotificationEnabled(true) - - expect(enabled).toEqual({ success: true, data: { enabled: true } }) - expect(sendNotification).toHaveBeenCalledWith({ - to: 'owner-wxid', - text: `✅ TraceMemo 定时日报通知已开启 - -以后定时日报生成或发送出现异常时, -我会通过这里通知你。` - }) - const restored = new ScheduledReportService({ storageDir }) - await expect(restored.getNotificationSettings()).resolves.toEqual({ enabled: true }) - await expect(service.setNotificationEnabled(false)).resolves.toEqual({ - success: true, - data: { enabled: false } - }) - await expect(restored.getNotificationSettings()).resolves.toEqual({ enabled: true }) - const afterClose = new ScheduledReportService({ storageDir }) - await expect(afterClose.getNotificationSettings()).resolves.toEqual({ enabled: false }) - }) - - it.each([ - { - name: 'rejects an offline Agent Hub', - status: { hub: 'offline' as const, connector: 'online' as const, updatedAt: Date.now() }, - recipient: 'owner-wxid', - reason: 'agent_hub_offline' as const - }, - { - name: 'rejects an offline connector', - status: { hub: 'online' as const, connector: 'disconnected' as const, updatedAt: Date.now() }, - recipient: 'owner-wxid', - reason: 'connector_offline' as const - }, - { - name: 'rejects a missing notification recipient', - status: onlineAgentHubStatus(), - recipient: undefined, - reason: 'recipient_not_bound' as const - } - ])('$name before saving enabled=true', async ({ status, recipient, reason }) => { - const storageDir = await mkdtemp(join(tmpdir(), 'tracememo-scheduled-report-capability-')) - const sendNotification = vi.fn() - const service = new ScheduledReportService({ - storageDir, - getAgentHubStatus: () => status, - getNotificationRecipient: () => recipient, - sendNotification - }) - - const result = await service.setNotificationEnabled(true) - - expect(result).toMatchObject({ success: false, data: { enabled: false }, reason }) - expect(sendNotification).not.toHaveBeenCalled() - await expect(service.getNotificationSettings()).resolves.toEqual({ enabled: false }) - }) - - it('does not enable notifications when the real test send fails', async () => { - const storageDir = await mkdtemp(join(tmpdir(), 'tracememo-scheduled-report-send-test-')) - const sendNotification = vi.fn(async () => ({ - success: false, - status: 'send_failed' as const, - error: '连接器返回 500' - })) - const service = new ScheduledReportService({ - storageDir, - getAgentHubStatus: onlineAgentHubStatus, - getNotificationRecipient: () => 'owner-wxid', - sendNotification - }) - - const result = await service.setNotificationEnabled(true) - - expect(result).toMatchObject({ - success: false, - data: { enabled: false }, - reason: 'send_failed', - error: '连接器返回 500' - }) - await expect(service.getNotificationSettings()).resolves.toEqual({ enabled: false }) - }) - - 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 saveHistory = vi.fn().mockResolvedValue({ success: true }) - 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 } - }, - saveGeneratedReport: saveHistory, - 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(saveHistory).toHaveBeenCalledWith( - expect.objectContaining({ - contactName: '研发群', - reportDate: undefined, - pngPath: generatedPath, - messageCount: 0 - }) + expect(calculateNextRunAt('18:30', from)).toBe( + new Date('2026-08-27T18:30:00+08:00').toISOString() ) - 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('微信发送成功') - }) - - it('passes a selected today range through a scheduled 20:00 execution', async () => { - const storageDir = await mkdtemp(join(tmpdir(), 'tracememo-scheduled-report-today-')) - let currentTime = new Date(2026, 7, 27, 19, 59, 0) - const generateReport = vi.fn(async () => ({ - success: true, - groupName: '研发群', - pngPath: '/tmp/generated.png', - messageCount: 1 - })) - const service = new ScheduledReportService({ - storageDir, - now: () => currentTime, - getCapability: async () => capability, - generateReport, - saveGeneratedReport: async () => ({ success: true, record: reportRecord('/tmp/saved.png') }), - send: async () => ({ success: true, status: capability.senderStatus }), - isDatabaseReady: () => true - }) - const created = await service.createTask({ - name: '今日日报', - group: '研发群', - target: '研发群@chatroom', - scheduleTime: '20:00', - reportRange: 'today' - }) - expect(created.success).toBe(true) - currentTime = new Date(2026, 7, 27, 20, 0, 0) - - const execution = await runScheduled(service, created.data!.id) - - expect(execution.status).toBe('success') - expect(generateReport).toHaveBeenCalledWith( - expect.objectContaining({ group: '研发群', range: 'today' }) + // 已经过了今天的点 ⇒ 顺延到明天。 + expect(calculateNextRunAt('09:00', from)).toBe( + new Date('2026-08-28T09:00:00+08:00').toISOString() ) }) - it('does not send a generated report when history persistence fails', async () => { - const storageDir = await mkdtemp(join(tmpdir(), 'tracememo-scheduled-report-history-')) - const generatedPath = join(storageDir, 'report.png') - const send = vi.fn().mockResolvedValue({ success: true, status: capability.senderStatus }) - const service = new ScheduledReportService({ - storageDir, - getCapability: async () => capability, - generateReport: async () => ({ success: true, pngPath: generatedPath }), - saveGeneratedReport: vi.fn().mockResolvedValue({ success: false, error: '磁盘不可写' }), - send, - isDatabaseReady: () => true - }) - const created = await service.createTask({ - name: '历史失败日报', - group: '研发群', - target: '研发群@chatroom', - scheduleTime: '09:00' - }) - - const result = await service.runScheduledReportNow(created.data!.id) - - expect(result.success).toBe(false) - expect(result.data?.error).toContain('report_history_save_failed:磁盘不可写') - expect(send).not.toHaveBeenCalled() - }) - - it('records manual failures without creating user notifications', async () => { - const storageDir = await mkdtemp(join(tmpdir(), 'tracememo-scheduled-report-manual-error-')) - const sendNotification = vi.fn() - const service = new ScheduledReportService({ - storageDir, - ...makeDependencies({ - sendNotification, - generateReport: async () => ({ - success: false, - error: 'maximum context length exceeded', - errorStage: 'ai' as const - }) - }) - }) - const created = await service.createTask({ - name: '手动失败日报', - group: '研发群', - target: '研发群@chatroom', - scheduleTime: '09:00' - }) - - const result = await service.runScheduledReportNow(created.data!.id) - - expect(result.data).toMatchObject({ status: 'failed', triggerType: 'manual' }) - expect(result.data?.notificationStatus).toBe('not_needed') - expect(sendNotification).not.toHaveBeenCalled() - expect(await service.listNotifications()).toHaveLength(0) - }) - - it('sends a debug scheduled failure notification without generating or sending a report', async () => { - const storageDir = await mkdtemp( - join(tmpdir(), 'tracememo-scheduled-report-debug-notification-') + it('resolveDueScheduledSlot 给出"已经到点、最近的那个"槽位', () => { + // 18:31 时,18:30 已经到点 ⇒ 槽位是今天 18:30。 + expect(resolveDueScheduledSlot(SCHEDULE_TIME, AFTER_SLOT)).toBe( + new Date('2026-08-27T18:30:00+08:00').toISOString() ) - await enableNotifications(storageDir) - const generateReport = vi.fn() - const send = vi.fn() - const sendNotification = vi.fn(async () => ({ success: true, status: 'sent' as const })) - const service = new ScheduledReportService({ - storageDir, - ...makeDependencies({ generateReport, send, sendNotification }) - }) - const created = await service.createTask({ - name: '调试日报', - group: '研发群', - target: '研发群@chatroom', - scheduleTime: '09:00' - }) - - const result = await service.testScheduledReportErrorNotification(created.data!.id) - - expect(result.success).toBe(true) - expect(result.data).toMatchObject({ - taskId: created.data!.id, - triggerType: 'scheduled', - status: 'failed', - errorCode: 'DEBUG_TEST_NOTIFICATION', - notificationStatus: 'sent' - }) - expect(result.data?.technicalMessage).toContain('没有调用 AI') - expect(generateReport).not.toHaveBeenCalled() - expect(send).not.toHaveBeenCalled() - expect(sendNotification).toHaveBeenCalledWith({ - to: 'owner-wxid', - text: expect.stringContaining('定时日报错误通知测试') - }) - expect(await service.listNotifications()).toHaveLength(1) - }) - - it('turns NO_MESSAGES into a neutral skipped execution without notification', async () => { - const storageDir = await mkdtemp(join(tmpdir(), 'tracememo-scheduled-report-no-messages-')) - await enableNotifications(storageDir) - const sendNotification = vi.fn(async () => ({ success: true, status: 'sent' as const })) - const service = new ScheduledReportService({ - storageDir, - ...makeDependencies({ - sendNotification, - getAgentHubStatus: onlineAgentHubStatus, - generateReport: async () => ({ - success: false, - error: '所选时间范围没有可总结的消息', - errorCode: 'NO_MESSAGES', - errorStage: 'data' as const - }) - }) - }) - const created = await service.createTask({ - name: '空消息日报', - group: '研发群', - target: '研发群@chatroom', - scheduleTime: '09:00' - }) - - const execution = await runScheduled(service, created.data!.id) - - expect(execution).toMatchObject({ - status: 'skipped', - errorCode: 'NO_MESSAGES', - userTitle: '暂无可生成的日报', - userMessage: '所选时间范围内没有可总结的聊天消息。', - notificationStatus: 'not_needed' - }) - expect(sendNotification).not.toHaveBeenCalled() - expect(await service.listNotifications()).toHaveLength(0) - }) - - it('suppresses pending notifications when disabled and does not flush them after reopening', async () => { - const storageDir = await mkdtemp(join(tmpdir(), 'tracememo-scheduled-report-suppressed-')) - await enableNotifications(storageDir) - let hubStatus = onlineAgentHubStatus() - const sendNotification = vi.fn(async () => ({ - success: false, - status: 'connector_offline' as const, - error: '连接器暂时不可用' - })) - const service = new ScheduledReportService({ - storageDir, - ...makeDependencies({ - getAgentHubStatus: () => hubStatus, - sendNotification, - send: async () => ({ - success: false, - status: capability.senderStatus, - error: '连接器暂时不可用' - }) - }) - }) - const created = await service.createTask({ - name: '待关闭通知日报', - group: '研发群', - target: '研发群@chatroom', - scheduleTime: '09:00' - }) - expect(await service.getNotificationSettings()).toEqual({ enabled: true }) - const execution = await runScheduled(service, created.data!.id) - expect(execution.notificationStatus).toBe('pending') - expect(await service.listNotifications()).toEqual([ - expect.objectContaining({ status: 'pending' }) - ]) - - await expect(service.setNotificationEnabled(false)).resolves.toMatchObject({ - success: true, - data: { enabled: false } - }) - expect(await service.listNotifications()).toEqual([ - expect.objectContaining({ status: 'suppressed' }) - ]) - - hubStatus = onlineAgentHubStatus() - sendNotification.mockResolvedValue({ success: true, status: 'sent' }) - await expect(service.setNotificationEnabled(true)).resolves.toMatchObject({ - success: true, - data: { enabled: true } - }) - expect(sendNotification).toHaveBeenCalledTimes(2) - expect((await service.listNotifications())[0].status).toBe('suppressed') - }) - - it('suppresses stale pending notifications when already disabled', async () => { - const storageDir = await mkdtemp(join(tmpdir(), 'tracememo-scheduled-report-stale-pending-')) - const notification: ScheduledReportNotification = { - id: 'notification-1', - executionId: 'execution-1', - taskId: 'task-1', - type: 'failure', - severity: 'error', - title: '日报发送失败', - message: '连接器暂时不可用', - dedupeKey: 'execution-1:failure', - channel: 'agent_hub', - recipient: 'owner-wxid', - status: 'pending', - createdAt: '2026-09-02T02:00:00.000Z', - attempts: 0 - } - await writeFile(join(storageDir, 'notifications.json'), JSON.stringify([notification])) - const service = new ScheduledReportService({ - storageDir, - now: () => new Date('2026-09-02T03:00:00.000Z') - }) - - await expect(service.setNotificationEnabled(false)).resolves.toEqual({ - success: true, - data: { enabled: false } - }) - expect(await service.listNotifications()).toEqual([ - expect.objectContaining({ - status: 'suppressed', - suppressedAt: '2026-09-02T03:00:00.000Z', - lastError: '定时日报微信异常通知已关闭。' - }) - ]) - }) - - it('allows creating and executing a report when personal WeChat is unavailable', async () => { - const storageDir = await mkdtemp(join(tmpdir(), 'tracememo-scheduled-report-waiting-')) - await enableNotifications(storageDir) - const generateReport = vi.fn(async () => ({ - success: true, - pngPath: '/tmp/generated.png', - groupName: '研发群' - })) - const saveHistory = vi.fn(async () => ({ - success: true as const, - record: reportRecord('/tmp/saved.png') - })) - const send = vi.fn() - const service = new ScheduledReportService({ - storageDir, - ...makeDependencies({ - getCapability: async () => unavailableCapability, - generateReport, - saveGeneratedReport: saveHistory, - send - }) - }) - - const created = await service.createTask({ - name: '未发送日报', - group: '研发群', - target: '研发群@chatroom', - scheduleTime: '09:00' - }) - expect(created.success).toBe(true) - - const execution = await runScheduled(service, created.data!.id) - - expect(execution).toMatchObject({ - status: 'waiting_to_send', - errorCode: 'WECHAT_SEND_UNAVAILABLE', - pngPath: '/tmp/saved.png', - reportId: 'report-1', - sendStatus: 'unavailable', - notificationStatus: 'not_needed' - }) - expect(generateReport).toHaveBeenCalledOnce() - expect(saveHistory).toHaveBeenCalledOnce() - expect(send).not.toHaveBeenCalled() - expect(await service.listNotifications()).toHaveLength(0) - }) - - it('keeps a generated report waiting without creating a notification when sending is unavailable', async () => { - const storageDir = await mkdtemp(join(tmpdir(), 'tracememo-scheduled-report-send-block-')) - await enableNotifications(storageDir) - const sendAction = vi.fn(async () => ({ - actionId: 'send-unavailable-action', - status: 'failed' as const, - decision: 'allow' as const, - errorCode: 'SEND_CAPABILITY_UNAVAILABLE' as const, - reason: '当前微信发送能力不可用', - startedAt: '2026-08-27T01:00:00.000Z', - finishedAt: '2026-08-27T01:00:01.000Z' - })) - const sendNotification = vi.fn() - const service = new ScheduledReportService({ - storageDir, - ...makeDependencies({ sendAction, sendNotification }) - }) - const created = await service.createTask({ - name: '发送能力未就绪日报', - group: '研发群', - target: '研发群@chatroom', - scheduleTime: '09:00' - }) - - const execution = await runScheduled(service, created.data!.id) - - expect(execution).toMatchObject({ - status: 'waiting_to_send', - errorCode: 'WECHAT_SEND_UNAVAILABLE', - sendStatus: 'unavailable', - notificationStatus: 'not_needed', - pngPath: '/tmp/saved.png' - }) - expect(sendNotification).not.toHaveBeenCalled() - expect(await service.listNotifications()).toHaveLength(0) - }) - - it('keeps unavailable sending waiting on retry without creating a notification', async () => { - const storageDir = await mkdtemp(join(tmpdir(), 'tracememo-scheduled-report-send-retry-')) - await enableNotifications(storageDir) - const sendAction = vi.fn(async () => ({ - actionId: 'send-unavailable-action', - status: 'failed' as const, - decision: 'allow' as const, - errorCode: 'SEND_CAPABILITY_UNAVAILABLE' as const, - reason: '当前微信发送能力不可用', - startedAt: '2026-08-27T01:00:00.000Z', - finishedAt: '2026-08-27T01:00:01.000Z' - })) - const sendNotification = vi.fn() - const service = new ScheduledReportService({ - storageDir, - ...makeDependencies({ sendAction, sendNotification }) - }) - const created = await service.createTask({ - name: '发送能力未就绪重试日报', - group: '研发群', - target: '研发群@chatroom', - scheduleTime: '09:00' - }) - const first = await runScheduled(service, created.data!.id) - - const retried = await service.retryScheduledReportSend(first.id) - - expect(retried.data).toMatchObject({ - status: 'waiting_to_send', - errorCode: 'WECHAT_SEND_UNAVAILABLE', - retryCount: 1, - sendStatus: 'unavailable', - notificationStatus: 'not_needed' - }) - expect(sendAction).toHaveBeenCalledTimes(2) - expect(sendNotification).not.toHaveBeenCalled() - expect(await service.listNotifications()).toHaveLength(0) - }) - - it('keeps notifications pending when no reliable recipient is available', async () => { - const storageDir = await mkdtemp(join(tmpdir(), 'tracememo-scheduled-report-no-recipient-')) - await enableNotifications(storageDir) - const sendNotification = vi.fn() - const service = new ScheduledReportService({ - storageDir, - ...makeDependencies({ - send: async () => ({ - success: false, - status: capability.senderStatus, - error: '微信发送失败' - }), - getNotificationRecipient: () => undefined, - sendNotification - }) - }) - const created = await service.createTask({ - name: '待通知日报', - group: '研发群', - target: '研发群@chatroom', - scheduleTime: '09:00' - }) - - const execution = await runScheduled(service, created.data!.id) - const [notification] = await service.listNotifications() - - expect(execution.notificationStatus).toBe('pending') - expect(notification).toMatchObject({ status: 'pending', attempts: 0 }) - expect(notification.recipient).toBeUndefined() - expect(sendNotification).not.toHaveBeenCalled() - }) - - it('keeps notifications pending when Agent Hub delivery fails', async () => { - const storageDir = await mkdtemp(join(tmpdir(), 'tracememo-scheduled-report-hub-offline-')) - await enableNotifications(storageDir) - const sendNotification = vi.fn(async () => ({ - success: false, - status: 'connector_offline' as const, - error: 'Agent Hub 连接器不可用' - })) - const service = new ScheduledReportService({ - storageDir, - ...makeDependencies({ - send: async () => ({ - success: false, - status: capability.senderStatus, - error: '微信发送失败' - }), - sendNotification - }) - }) - const created = await service.createTask({ - name: '连接器异常日报', - group: '研发群', - target: '研发群@chatroom', - scheduleTime: '09:00' - }) - - const execution = await runScheduled(service, created.data!.id) - const [notification] = await service.listNotifications() - - expect(execution.notificationStatus).toBe('pending') - expect(notification).toMatchObject({ - status: 'pending', - attempts: 1, - lastError: 'Agent Hub 连接器不可用' - }) - expect(sendNotification).toHaveBeenCalledOnce() - }) - - it('keeps the generated artifact when the image send attempt fails', async () => { - const storageDir = await mkdtemp(join(tmpdir(), 'tracememo-scheduled-report-partial-')) - const service = new ScheduledReportService({ - storageDir, - ...makeDependencies({ - send: vi.fn(async () => ({ - success: false, - status: capability.senderStatus, - error: '连接器响应超时' - })) - }) - }) - const created = await service.createTask({ - name: '发送失败日报', - group: '研发群', - target: '研发群@chatroom', - scheduleTime: '09:00' - }) - - const result = await service.runScheduledReportNow(created.data!.id) - - expect(result.success).toBe(true) - expect(result.data).toMatchObject({ - status: 'partial_success', - errorCode: 'WECHAT_SEND_FAILED', - pngPath: '/tmp/saved.png', - sendStatus: 'failed', - sendTarget: '研发群@chatroom' - }) - }) - - it('routes scheduled report sends and retries through the Action Gateway adapter', async () => { - const storageDir = await mkdtemp(join(tmpdir(), 'tracememo-scheduled-report-gateway-')) - const send = vi.fn() - const sendAction = vi - .fn() - .mockResolvedValueOnce({ - actionId: 'action-1', - status: 'failed' as const, - decision: 'allow' as const, - errorCode: 'SEND_FAILED' as const, - reason: '连接器暂时不可用', - startedAt: '2026-08-27T01:00:00.000Z', - finishedAt: '2026-08-27T01:00:01.000Z' - }) - .mockResolvedValueOnce({ - actionId: 'action-2', - status: 'sent' as const, - decision: 'allow' as const, - startedAt: '2026-08-27T01:01:00.000Z', - finishedAt: '2026-08-27T01:01:01.000Z' - }) - const service = new ScheduledReportService({ - storageDir, - ...makeDependencies({ send, sendAction }) - }) - const created = await service.createTask({ - name: 'Gateway 日报', - group: '研发群', - target: '研发群@chatroom', - scheduleTime: '09:00' - }) - - const first = await runScheduled(service, created.data!.id) - const recovered = await service.retryScheduledReportSend(first.id) - - expect(first).toMatchObject({ - status: 'partial_success', - pngPath: '/tmp/saved.png', - sendTarget: '研发群@chatroom' - }) - expect(recovered.data).toMatchObject({ status: 'success', retryCount: 1 }) - expect(send).not.toHaveBeenCalled() - expect(sendAction).toHaveBeenNthCalledWith( - 1, - expect.objectContaining({ - target: '研发群@chatroom', - filePath: '/tmp/saved.png', - triggerType: 'scheduled', - executionId: first.id, - taskId: created.data!.id - }) + // 18:29 时,今天的点还没到 ⇒ 最近一个槽位是昨天。 + expect(resolveDueScheduledSlot(SCHEDULE_TIME, new Date('2026-08-27T18:29:00+08:00'))).toBe( + new Date('2026-08-26T18:30:00+08:00').toISOString() ) - expect(sendAction).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - target: '研发群@chatroom', - filePath: '/tmp/saved.png', - triggerType: 'scheduled', - executionId: first.id, - retryCount: 1, - taskId: created.data!.id - }) - ) + }) +}) + +describe('定时日报 · 调度器', () => { + it('到点且未消费 ⇒ 以 schedule 触发,并把槽位交给 AutomationService', async () => { + const { service, executeRule } = await makeHarness() + + await service.tick(AFTER_SLOT) + await service.settle() + + expect(executeRule).toHaveBeenCalledTimes(1) + const [ruleId, options] = executeRule.mock.calls[0] + expect(ruleId).toBe('rule-1') + expect(options).toEqual({ + trigger: 'schedule', + scheduledSlot: new Date('2026-08-27T18:30:00+08:00').toISOString() + }) }) - it('retries an existing PNG without generating the report again', async () => { - const storageDir = await mkdtemp(join(tmpdir(), 'tracememo-scheduled-report-retry-')) - await enableNotifications(storageDir) - const generateReport = vi.fn(async () => ({ - success: true, - pngPath: '/tmp/generated.png', - groupName: '研发群' - })) - let sendCount = 0 - const send = vi.fn(async (request: { filePath: string }) => { - sendCount += 1 - if (sendCount === 1) { - return { success: false, status: capability.senderStatus, error: '连接器暂时不可用' } + it('槽位已消费 ⇒ 不再触发(重启后不重复补跑)', async () => { + const rule = makeRule({ + scheduledReport: { + ...normalizeScheduledReportConfig({ + schedule: { time: SCHEDULE_TIME }, + report: { sourceConversationId: 'g1@chatroom', messageTypes: ['text'] } + }), + lastScheduledSlot: new Date('2026-08-27T18:30:00+08:00').toISOString() } - expect(request.filePath).toBe('/tmp/saved.png') - return { success: true, status: capability.senderStatus } }) - const service = new ScheduledReportService({ - storageDir, - ...makeDependencies({ generateReport, send }) + const { service, executeRule } = await makeHarness({}, [rule]) + + await service.tick(AFTER_SLOT) + await service.settle() + + expect(executeRule).not.toHaveBeenCalled() + }) + + it('停用的规则不调度', async () => { + const { service, executeRule } = await makeHarness({}, [makeRule({ enabled: false })]) + await service.tick(AFTER_SLOT) + await service.settle() + expect(executeRule).not.toHaveBeenCalled() + }) + + it('目标待重选的规则不调度(执行必然失败)', async () => { + const rule = makeRule({ + scheduledReport: { + ...normalizeScheduledReportConfig({ + schedule: { time: SCHEDULE_TIME }, + report: { sourceConversationId: 'g1@chatroom', messageTypes: ['text'] } + }), + targetNeedsReview: true + } }) - const created = await service.createTask({ - name: '可恢复日报', - group: '研发群', - target: '研发群@chatroom', - scheduleTime: '09:00' + const { service, executeRule } = await makeHarness({}, [rule]) + await service.tick(AFTER_SLOT) + await service.settle() + expect(executeRule).not.toHaveBeenCalled() + }) + + it('数据库未就绪时不调度', async () => { + const { service, executeRule } = await makeHarness({ isDatabaseReady: () => false }) + await service.tick(AFTER_SLOT) + await service.settle() + expect(executeRule).not.toHaveBeenCalled() + }) + + it('今天的点还没到 + 昨天已消费 ⇒ 不触发', async () => { + // 规则在昨天 18:30 跑过(游标 = 昨天 18:30),现在是今天 18:29: + // 最近的槽位仍然是「昨天 18:30」,但它已经消费过 ⇒ 什么都别做。 + const rule = makeRule({ + scheduledReport: { + ...normalizeScheduledReportConfig({ + schedule: { time: SCHEDULE_TIME }, + report: { sourceConversationId: 'g1@chatroom', messageTypes: ['text'] } + }), + lastScheduledSlot: new Date('2026-08-26T18:30:00+08:00').toISOString() + } }) - const first = { data: await runScheduled(service, created.data!.id) } - const recovered = await service.retryScheduledReportSend(first.data!.id) + const { service, executeRule } = await makeHarness({}, [rule]) + + await service.tick(new Date('2026-08-27T18:29:00+08:00')) + await service.settle() + + expect(executeRule).not.toHaveBeenCalled() + }) - expect(first.data?.status).toBe('partial_success') - expect(recovered).toMatchObject({ - success: true, - data: { status: 'success', sendStatus: 'success', retryCount: 1 } + it('槽位早于规则创建时间 ⇒ 不触发(新建规则不会被立刻补发)', async () => { + // 今天 12:00 建的规则、执行时间 18:30。今天 18:30 还没到, + // 最近的槽位是**昨天 18:30** —— 它早于 createdAt,不属于这条规则。 + // 少了这道闸,新建规则会在下一次 15 秒 tick 里立刻发一份昨天的报告。 + const rule = makeRule({ createdAt: Date.parse('2026-08-27T12:00:00+08:00') }) + const { service, executeRule } = await makeHarness({}, [rule]) + + await service.tick(new Date('2026-08-27T18:29:00+08:00')) + await service.settle() + + expect(executeRule).not.toHaveBeenCalled() + }) + + it('停机错过一个槽位 ⇒ 补跑最近那个未消费的槽位(不静默丢一天)', async () => { + // 规则 08-20 创建、游标停在 08-25 18:30,08-26 那晚应用没开: + // 08-27 18:29 启动时,最近未消费的槽位是 08-26 18:30,应当补上。 + const rule = makeRule({ + scheduledReport: { + ...normalizeScheduledReportConfig({ + schedule: { time: SCHEDULE_TIME }, + report: { sourceConversationId: 'g1@chatroom', messageTypes: ['text'] } + }), + lastScheduledSlot: new Date('2026-08-25T18:30:00+08:00').toISOString() + } }) - expect(recovered.data).not.toHaveProperty('error') - expect(recovered.data).not.toHaveProperty('errorCode') - expect(recovered.data).not.toHaveProperty('failedStage') - expect(recovered.data).not.toHaveProperty('technicalMessage') - expect(recovered.data).not.toHaveProperty('sendError') - expect(generateReport).toHaveBeenCalledOnce() - expect(send).toHaveBeenCalledTimes(2) - expect((await service.listNotifications()).map((item) => item.type)).toContain('recovery') - }) - - it('deduplicates repeated failure notifications for one execution', async () => { - const storageDir = await mkdtemp(join(tmpdir(), 'tracememo-scheduled-report-dedupe-')) - await enableNotifications(storageDir) - const send = vi.fn(async () => ({ - success: false, - status: capability.senderStatus, - error: '连接器暂时不可用' - })) - const sendNotification = vi.fn(async () => ({ success: true, status: 'sent' as const })) - const service = new ScheduledReportService({ - storageDir, - ...makeDependencies({ send, sendNotification }) + const { service, executeRule } = await makeHarness({}, [rule]) + + await service.tick(new Date('2026-08-27T18:29:00+08:00')) + await service.settle() + + expect(executeRule).toHaveBeenCalledTimes(1) + expect(executeRule.mock.calls[0][1]).toEqual({ + trigger: 'schedule', + scheduledSlot: new Date('2026-08-26T18:30:00+08:00').toISOString() }) - const created = await service.createTask({ - name: '重复失败日报', - group: '研发群', - target: '研发群@chatroom', - scheduleTime: '09:00' + }) + + it('游标比槽位新(用户改过执行时间)⇒ 旧槽位已过期,不再补', async () => { + const rule = makeRule({ + scheduledReport: { + ...normalizeScheduledReportConfig({ + schedule: { time: SCHEDULE_TIME }, + report: { sourceConversationId: 'g1@chatroom', messageTypes: ['text'] } + }), + lastScheduledSlot: new Date('2026-08-27T18:30:00+08:00').toISOString() + } }) + const { service, executeRule } = await makeHarness({}, [rule]) - const first = { data: await runScheduled(service, created.data!.id) } - const retried = await service.retryScheduledReportSend(first.data!.id) + // 18:31 时最近槽位正好等于游标 ⇒ 不触发;再把时间回拨到 18:29, + // 槽位退化成 08-26 18:30,仍然**比游标旧** ⇒ 同样不触发。 + await service.tick(AFTER_SLOT) + await service.tick(new Date('2026-08-27T18:29:00+08:00')) + await service.settle() - expect(retried.data?.status).toBe('partial_success') - expect(await service.listNotifications()).toHaveLength(1) - expect(sendNotification).toHaveBeenCalledOnce() + expect(executeRule).not.toHaveBeenCalled() }) - it('normalizes AI and unknown generation failures without a fake WeChat error', async () => { - const contextService = new ScheduledReportService({ - storageDir: await mkdtemp(join(tmpdir(), 'tracememo-scheduled-report-context-')), - ...makeDependencies({ - generateReport: async () => ({ - success: false, - error: 'maximum context length exceeded', - errorStage: 'ai' as const - }) - }) + it('schedule 触发的执行结果不会走 conversation gate(并发由 AutomationService 管)', async () => { + // 这里只断言"调度器把两次 tick 都原样交给 executeRule": + // 真正的 ruleId inFlight 去重在 AutomationService,见 automation-service 的单测。 + const { service, executeRule } = await makeHarness() + await service.tick(AFTER_SLOT) + await service.tick(AFTER_SLOT) + await service.settle() + expect(executeRule).toHaveBeenCalledTimes(2) + }) +}) + +describe('定时日报 · 立即执行', () => { + it('手动执行走 manual,且**不消耗槽位**', async () => { + const { service, executeRule } = await makeHarness() + + const result = await service.runScheduledReportNow('rule-1') + + expect(result.success).toBe(true) + expect(executeRule).toHaveBeenCalledWith('rule-1', { trigger: 'manual' }) + }) + + it('规则不存在时如实报错,不执行', async () => { + const { service, executeRule } = await makeHarness() + const result = await service.runScheduledReportNow('missing') + expect(result.success).toBe(false) + expect(result.error).toContain('未找到') + expect(executeRule).not.toHaveBeenCalled() + }) +}) + +describe('定时日报 · 微信异常通知', () => { + it('定时执行失败 ⇒ 推一条异常通知(能力开启时)', async () => { + const { service, storageDir, executeRule, sendNotification } = await makeHarness({ + executeRule: vi.fn(async () => ({ + executed: true, + executionId: 'exec-fail', + status: 'failed', + reportGenerated: false, + errorSummary: '日报生成失败' + })) as unknown as ScheduledReportDependencies['executeRule'] }) - const contextTask = await contextService.createTask({ - name: '上下文日报', - group: '研发群', - target: '研发群@chatroom', - scheduleTime: '09:00' + await writeFile(join(storageDir, 'settings.json'), JSON.stringify({ enabled: true })) + + await service.tick(AFTER_SLOT) + await service.settle() + + expect(executeRule).toHaveBeenCalledTimes(1) + expect(sendNotification).toHaveBeenCalledTimes(1) + const text = sendNotification.mock.calls[0][0].text as string + expect(text).toContain('每日晚报') + expect(text).toContain('日报生成失败') + + const notifications = await service.listNotifications() + expect(notifications).toHaveLength(1) + expect(notifications[0].status).toBe('sent') + }) + + it('NO_MESSAGES 不是"失败",不打扰用户', async () => { + const { service, storageDir, sendNotification } = await makeHarness({ + executeRule: vi.fn(async () => ({ + executed: true, + executionId: 'exec-empty', + status: 'failed', + reportGenerated: false, + errorCode: 'NO_MESSAGES', + errorSummary: '暂无可生成的日报' + })) as unknown as ScheduledReportDependencies['executeRule'] }) - const contextResult = await contextService.runScheduledReportNow(contextTask.data!.id) - expect(contextResult.data).toMatchObject({ errorCode: 'AI_CONTEXT_LIMIT', failedStage: 'ai' }) - expect(contextResult.data?.error).not.toContain('wechat_not_ready') - - const unknownService = new ScheduledReportService({ - storageDir: await mkdtemp(join(tmpdir(), 'tracememo-scheduled-report-unknown-')), - ...makeDependencies({ - generateReport: async () => { - throw new Error('unexpected generation failure') - } - }) + await writeFile(join(storageDir, 'settings.json'), JSON.stringify({ enabled: true })) + + await service.tick(AFTER_SLOT) + await service.settle() + + expect(sendNotification).not.toHaveBeenCalled() + expect(await service.listNotifications()).toHaveLength(0) + }) + + it('通知关闭时不推送,也不排队', async () => { + const { service, sendNotification } = await makeHarness({ + executeRule: vi.fn(async () => ({ + executed: true, + executionId: 'exec-fail', + status: 'failed', + reportGenerated: false, + errorSummary: '日报生成失败' + })) as unknown as ScheduledReportDependencies['executeRule'] }) - const unknownTask = await unknownService.createTask({ - name: '未知错误日报', - group: '研发群', - target: '研发群@chatroom', - scheduleTime: '09:00' + + await service.tick(AFTER_SLOT) + await service.settle() + + expect(sendNotification).not.toHaveBeenCalled() + expect(await service.listNotifications()).toHaveLength(0) + }) + + it('手动执行失败**不**推异常通知(与旧行为一致)', async () => { + const { service, storageDir, sendNotification } = await makeHarness({ + executeRule: vi.fn(async () => ({ + executed: true, + executionId: 'exec-manual', + status: 'failed', + reportGenerated: false, + errorSummary: '日报生成失败' + })) as unknown as ScheduledReportDependencies['executeRule'] }) - const unknownResult = await unknownService.runScheduledReportNow(unknownTask.data!.id) - expect(unknownResult.data).toMatchObject({ errorCode: 'UNKNOWN', failedStage: 'report' }) - expect(unknownResult.data?.error).not.toContain('wechat_not_ready') - }) - - it('keeps old execution records readable and claims one scheduled slot once', async () => { - const storageDir = await mkdtemp(join(tmpdir(), 'tracememo-scheduled-report-compat-')) - const oldExecution: ScheduledReportExecution = { - id: 'old-execution', - taskId: 'old-task', - startedAt: '2026-08-27T01:00:00.000Z', - finishedAt: '2026-08-27T01:01:00.000Z', - status: 'failed', - error: 'report_generation_failed:旧错误', - message: '旧错误' - } - await writeFile(join(storageDir, 'executions.json'), JSON.stringify([oldExecution])) - const generated = vi.fn(async () => ({ success: true, pngPath: '/tmp/generated.png' })) - const service = new ScheduledReportService({ + await writeFile(join(storageDir, 'settings.json'), JSON.stringify({ enabled: true })) + + await service.runScheduledReportNow('rule-1') + + expect(sendNotification).not.toHaveBeenCalled() + }) + + it('开启通知前会先做能力检测并试发一条', async () => { + const { service, sendNotification } = await makeHarness( + { getAgentHubStatus: () => ({ hub: 'offline', connector: 'disconnected', updatedAt: 0 }) }, + [] + ) + const result = await service.setNotificationEnabled(true) + expect(result.success).toBe(false) + expect(result.reason).toBe('agent_hub_offline') + expect(sendNotification).not.toHaveBeenCalled() + }) + + it('Agent Hub 在线且已绑定接收者 ⇒ 开启成功并落盘', async () => { + const { service, storageDir, sendNotification } = await makeHarness({}, []) + const result = await service.setNotificationEnabled(true) + expect(result.success).toBe(true) + expect(sendNotification).toHaveBeenCalledTimes(1) + expect(await service.getNotificationSettings()).toEqual({ enabled: true }) + // 设置**真的**落盘了(不是只改了内存)。 + const { readFile } = await import('fs/promises') + const raw = await readFile(join(storageDir, 'settings.json'), 'utf8') + expect(JSON.parse(raw)).toEqual({ enabled: true }) + }) +}) + +describe('定时日报 · 旧数据只读存档', () => { + it('旧执行记录原样可读,且按旧 taskId / 新 ruleId 双向匹配', async () => { + const { service, storageDir } = await makeHarness() + await writeFile( + join(storageDir, 'executions.json'), + JSON.stringify([ + { + id: 'old-exec-1', + taskId: 'scheduled_report_old', + startedAt: '2026-08-01T10:00:00.000Z', + status: 'success', + message: '日报生成成功,微信发送成功' + } + ]) + ) + // 重新建一个实例,确保从盘上读(而不是复用内存)。 + const fresh = new ScheduledReportService({ storageDir, - now: () => new Date('2026-08-27T01:00:00.000Z'), - ...makeDependencies({ generateReport: generated }) + listRules: () => [], + isDatabaseReady: () => true }) - expect(await service.listExecutions()).toEqual([ - expect.objectContaining({ triggerType: 'scheduled', retryCount: 0 }) + + const all = await fresh.listLegacyExecutions() + expect(all).toHaveLength(1) + expect(all[0].id).toBe('old-exec-1') + + const byLegacyId = await fresh.listLegacyExecutions('scheduled_report_old') + expect(byLegacyId).toHaveLength(1) + + // 迁移后 ruleId 可能形如 `scheduled-report:<旧 id>`(旧 id 缺失时的确定性映射)。 + const byMappedId = await fresh.listLegacyExecutions('scheduled-report:scheduled_report_old') + expect(byMappedId).toHaveLength(1) + + expect(await fresh.listLegacyExecutions('unrelated')).toHaveLength(0) + void service + }) + + it('项目只读投影只暴露「发回来源群」的规则(旧 HTTP 契约能如实表达的那一种)', async () => { + const { service } = await makeHarness({}, [ + makeRule({ + id: 'rule-source', + // 默认目标是 `file_transfer`,旧 HTTP 契约表达不了 ⇒ 这里显式选「发回来源群」。 + scheduledReport: normalizeScheduledReportConfig({ + schedule: { time: SCHEDULE_TIME }, + report: { sourceConversationId: 'g1@chatroom', messageTypes: ['text'] }, + target: { type: 'source_chat' } + }) + }), + makeRule({ + id: 'rule-file', + scheduledReport: normalizeScheduledReportConfig({ + schedule: { time: SCHEDULE_TIME }, + report: { sourceConversationId: 'g2@chatroom', messageTypes: ['text'] }, + target: { type: 'file_transfer' } + }) + }) ]) - const created = await service.createTask({ - name: '幂等日报', - group: '研发群', - target: '研发群@chatroom', - scheduleTime: '09:00' + + const tasks = await service.listTasks() + expect(tasks.map((task) => task.id)).toEqual(['rule-source']) + }) + + it('新执行日志按 ruleId 投影成旧执行形状(只读)', async () => { + const { service } = await makeHarness({ + listExecutions: (() => [ + { + executionId: 'exec-1', + ruleId: 'rule-1', + ruleName: '每日晚报', + triggerTime: Date.parse('2026-08-27T10:00:00.000Z'), + trigger: 'schedule', + sourceDisplayName: '每日晚报来源群', + status: 'failed' as const, + durationMs: 1200, + steps: [ + { key: 'report_generating' as const, label: '生成日报', status: 'failed' as const }, + { key: 'report_sent' as const, label: '发送日报', status: 'pending' as const } + ], + errorSummary: '日报生成失败' + } + ]) as unknown as ScheduledReportDependencies['listExecutions'] }) - const dueAt = new Date(created.data!.nextRunAt) - await service.tick(new Date(dueAt.getTime() + 1_000)) - await new Promise((resolve) => setTimeout(resolve, 50)) - await service.tick(new Date(dueAt.getTime() + 2_000)) - - expect(generated).toHaveBeenCalledOnce() - expect( - (await service.listExecutions()).filter((item) => item.taskId === created.data!.id) - ).toHaveLength(1) + + const executions = await service.listExecutions('rule-1') + expect(executions).toHaveLength(1) + expect(executions[0].taskId).toBe('rule-1') + expect(executions[0].triggerType).toBe('scheduled') + expect(executions[0].status).toBe('failed') + expect(executions[0].error).toBe('日报生成失败') + }) +}) + +describe('定时日报 · 启动与停止', () => { + beforeEach(() => { + vi.useRealTimers() + }) + + it('start 会立刻跑一次 tick,stop 后不再有计时器', async () => { + const { service, executeRule } = await makeHarness() + await service.start() + await service.settle() + service.stop() + + expect(executeRule).toHaveBeenCalledTimes(1) }) })