diff --git a/src/main/index.ts b/src/main/index.ts index 394a601..b3d2ece 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -112,6 +112,10 @@ import { agentHubService } from './services/agent-hub-service' import { WechatConnectorService } from './services/wechat-ilink' import { wechatSendGateway } from './services/wechat-send-gateway' import { groupExitMonitorService } from './services/group-exit-monitor-service' +import { MessageListenerService } from './services/message-listener-service' +import { automationRuleStore } from './services/automation-rule-store' +import { automationExecutionLogService } from './services/automation-execution-log-service' +import { initAutomationService, getAutomationService } from './services/automation-service' import { GroupStatsService } from './services/group-stats-service' import { wechatActionLogService } from './services/wechat-action-log-service' import { wechatActionGateway } from './services/wechat-action-gateway' @@ -1002,6 +1006,50 @@ app.whenReady().then(async () => { return { success: false, error: '应用正在退出,数据库连接已取消', monitoring: false } } const wcdb4Client = nextWechatDb.getWcdb4Client() + /** + * 正式 MessageListener(Observation Mode 接入)。 + * + * 本轮**只监听、回读、规范化、去重、统计**,不触发任何业务: + * 不匹配关键词、不判断 @我、不出日报、不回复、不调 AI / Agent、不发送。 + * 下一层的 Trigger 由后续任务接入。 + */ + const messageListener = new MessageListenerService(wcdb4Client) + /** + * Automation v1(@我生成日报)。 + * + * `isListening` 要等下面 `startMonitor` 有结果才知道,所以先用闭包变量占位。 + */ + let automationListening = false + initAutomationService(wcdb4Client, { isListening: () => automationListening }) + messageListener.onMessage((message) => { + // 日志只允许出现「类型 / 群或私聊 / 是否自己发 / @ 数量」这类不可逆标识, + // 不含 wxid、昵称、群名、正文与 source。 + console.log( + `[MessageListener] incoming messageType=${message.messageType}` + + ` group=${message.isGroup} self=${message.isSelf}` + + ` mentions=${message.mentionTargets.length}` + ) + // Automation 自带 isSelf / cooldown / 同消息幂等三重闸,不会形成回复循环。 + // 用 try 包住同步那一段:`getAutomationService()` 在未初始化时会抛, + // 而 `.catch()` 只能接住异步拒绝 —— 漏了这层就会把异常抛进 MessageListener 的投递循环。 + try { + void getAutomationService() + .handleMessage(message) + .catch((error) => { + console.warn( + `[Automation] handleMessage failed: ${ + error instanceof Error ? error.message : String(error) + }` + ) + }) + } catch (error) { + console.warn( + `[Automation] 未初始化,已跳过本次消息: ${ + error instanceof Error ? error.message : String(error) + }` + ) + } + }) const sessions = await wcdb4Client.getSessionsAsync({ hydrateDisplayNames: false }) configureRecallProtection(wcdb4Client, resolvedRoot, settings.recallProtectionEnabled) voiceService = new VoiceService(wcdb4Client, resolvedRoot) @@ -1010,12 +1058,15 @@ app.whenReady().then(async () => { videoAssetService = new VideoAssetService(wcdb4Client) const monitoring = await wcdb4Client.startMonitor((type, json) => { wcdb4Client.invalidateSessionCache() + // 正式 MessageListener:只做 coalesce + 有界回读 + dedup + 投递,不触发业务。 + messageListener.handleNativeChange() groupExitMonitorService.notifyDatabaseChanged(json) recallArchiveMonitor?.handleDatabaseChange(json) for (const window of BrowserWindow.getAllWindows()) { if (!window.isDestroyed()) window.webContents.send('wcdb-change', { type, json }) } }) + automationListening = monitoring === true void groupExitMonitorService.start(monitoring) const recentSession = sessions[0] if (recentSession?.username) { @@ -1540,6 +1591,61 @@ app.whenReady().then(async () => { ) ipcMain.handle('wechat-action-log:list', () => wechatActionLogService.list()) + /** + * Automation v1(@我生成日报)。 + * + * 规则与执行日志都是纯文件存储,**不依赖数据库**,所以这两组 handler 在数据库 + * 解锁前也可以安全调用;只有 `getStatus` / `listGroups` 需要会话数据,因此用 + * `tryAutomationService()` 兜底,避免渲染层在启动阶段拿到一个 rejected promise。 + */ + const tryAutomationService = (): ReturnType | null => { + try { + return getAutomationService() + } catch { + return null + } + } + ipcMain.handle('automation:listRules', () => automationRuleStore.listRules()) + ipcMain.handle('automation:createRule', (_, draft: unknown) => + automationRuleStore.createRule(draft) + ) + ipcMain.handle('automation:updateRule', (_, input: unknown) => { + const payload = (input || {}) as { id?: unknown; draft?: unknown } + return automationRuleStore.updateRule(String(payload.id || ''), payload.draft) ?? null + }) + ipcMain.handle('automation:deleteRule', (_, id: string) => automationRuleStore.deleteRule(id)) + ipcMain.handle('automation:setRuleEnabled', (_, input: unknown) => { + const payload = (input || {}) as { id?: unknown; enabled?: unknown } + return automationRuleStore.setRuleEnabled(String(payload.id || ''), payload.enabled === true) ?? null + }) + ipcMain.handle('automation:listExecutions', (_, query: unknown) => { + const input = (query || {}) as { limit?: unknown } + return automationExecutionLogService.list({ limit: Number(input.limit) }) + }) + ipcMain.handle('automation:clearExecutions', () => automationExecutionLogService.clear()) + ipcMain.handle('automation:listGroups', () => tryAutomationService()?.listGroups() ?? []) + ipcMain.handle('automation:getStatus', async () => { + const service = tryAutomationService() + if (service) return service.getStatus() + // 数据库尚未就绪:如实返回「未监听 + 能力未知」,而不是假装一切正常。 + const todayStart = new Date() + todayStart.setHours(0, 0, 0, 0) + const counts = automationExecutionLogService.countSince(todayStart.getTime()) + return { + listening: false, + listeningDegraded: true, + todayExecutions: counts.total, + todaySuccesses: counts.success, + sendCapability: { + supported: false, + ready: false, + canSendText: false, + canSendImage: false, + message: '数据库尚未就绪,暂时无法获知微信发送能力' + } + } + }) + ipcMain.handle('db:search', (_, keyword: string) => chat.searchMessages(keyword)) ipcMain.handle( 'knowledge:search', diff --git a/src/main/services/automation-action-runner.ts b/src/main/services/automation-action-runner.ts new file mode 100644 index 0000000..f01c1a4 --- /dev/null +++ b/src/main/services/automation-action-runner.ts @@ -0,0 +1,237 @@ +import { + AUTOMATION_STEP_LABELS, + AUTOMATION_SEND_ORIGIN, + AUTOMATION_SEND_PURPOSE, + DEFAULT_REPLY_TEXT, + automationIdempotencyKey, + type AutomationAction, + type AutomationRule, + type AutomationStep, + type AutomationStepKey +} from '../../shared/automation' +import type { WechatActionContent, WechatActionRequest, WechatActionResult } from '../../shared/wechat-action' +import { + generateAgentGroupReport, + type AgentGroupReportRequest, + type AgentGroupReportResult +} from './agent-group-report-service' +import { wechatActionGateway } from './wechat-action-gateway' + +/** + * AutomationActionRunner —— 把一次命中跑成**步骤序列**。 + * + * 两条硬要求: + * + * 1. **每一步都有独立的状态、起止时间与耗时。** 用户层日志的全部价值就在于此: + * 只告诉用户「失败了」等于没说,得告诉他是「生成日报」那步坏了。 + * 2. **失败必须切断后续。** 日报没生成出来就绝不能继续发图 —— 那会发出一条 + * 空的 / 过期的 / 上一条的消息,比不发更糟。所以失败后剩下的步骤一律 `skipped`。 + */ + +/** 一步执行完毕后,后续步骤的处置方式。 */ +const STEP_ORDER: AutomationStepKey[] = ['received', 'matched', 'reply', 'report', 'send'] + +export interface AutomationRunInput { + executionId: string + rule: AutomationRule + /** 会话标识:群为 `xxx@chatroom`,私聊为 wxid。同时也是发送对象。 */ + conversationId: string + isGroup: boolean + /** 用户可读的来源名(群名 / 昵称)。**不是** id。 */ + sourceDisplayName: string +} + +export interface AutomationRunResult { + steps: AutomationStep[] + status: 'success' | 'failed' + errorSummary?: string + pngPath?: string +} + +export interface AutomationActionRunnerDependencies { + generateReport?: (request: AgentGroupReportRequest) => Promise + executeAction?: (request: WechatActionRequest) => Promise + now?: () => number +} + +/** 策略层的错误码 → 用户可读短句。UI 直接展示这些文案,不做二次翻译。 */ +const ACTION_ERROR_MESSAGES: Record = { + INVALID_REQUEST: '发送请求不合法', + INVALID_RECIPIENT: '找不到有效的发送对象', + ACTION_NOT_ALLOWED: '该自动化动作未被允许执行', + RECIPIENT_SCOPE_VIOLATION: '发送对象与触发来源不一致', + SEND_CAPABILITY_UNAVAILABLE: '当前环境没有可用的微信发送能力', + SEND_NOT_READY: '微信发送能力尚未就绪,请先绑定个人微信', + SEND_FAILED: '微信发送失败', + POLICY_BLOCKED: '该发送动作未通过策略检查', + UNKNOWN: '发送失败(未知原因)' +} + +function createStep(key: AutomationStepKey): AutomationStep { + return { key, label: AUTOMATION_STEP_LABELS[key], status: 'pending' } +} + +function markSuccess(step: AutomationStep, at: number): void { + step.status = 'success' + step.startedAt = step.startedAt ?? at + step.finishedAt = at + step.durationMs = Math.max(0, at - step.startedAt) +} + +function markFailed(step: AutomationStep, at: number, error: string): void { + step.status = 'failed' + step.startedAt = step.startedAt ?? at + step.finishedAt = at + step.durationMs = Math.max(0, at - step.startedAt) + step.error = error +} + +function markSkipped(step: AutomationStep): void { + step.status = 'skipped' +} + +function actionErrorMessage(code: string | undefined, fallback: string | undefined): string { + if (fallback && fallback.trim()) { + // 策略层给的 reason 已是中文短句;直接用,避免二次包装丢信息。 + return fallback.trim() + } + return (code && ACTION_ERROR_MESSAGES[code]) || ACTION_ERROR_MESSAGES.UNKNOWN +} + +export class AutomationActionRunner { + private readonly generateReport: (request: AgentGroupReportRequest) => Promise + private readonly executeAction: (request: WechatActionRequest) => Promise + private readonly now: () => number + + constructor(dependencies: AutomationActionRunnerDependencies = {}) { + this.generateReport = dependencies.generateReport ?? generateAgentGroupReport + this.executeAction = dependencies.executeAction ?? ((request) => wechatActionGateway.execute(request)) + this.now = dependencies.now ?? (() => Date.now()) + } + + async run(input: AutomationRunInput): Promise { + const steps = STEP_ORDER.map((key) => createStep(key)) + const stepAt = (key: AutomationStepKey): AutomationStep => + steps.find((step) => step.key === key) as AutomationStep + + markSuccess(stepAt('received'), this.now()) + markSuccess(stepAt('matched'), this.now()) + + const replyAction = findAction(input.rule, 'replyText') + const reportAction = findAction(input.rule, 'generateReport') + const sendAction = findAction(input.rule, 'sendReportImage') + + // ---- 步骤 3:回复确认 ---- + if (!replyAction) { + markSkipped(stepAt('reply')) + } else { + const replyStep = stepAt('reply') + replyStep.status = 'running' + replyStep.startedAt = this.now() + const sent = await this.sendThroughGateway(input, 'reply', { + type: 'text', + text: replyAction.text?.trim() || DEFAULT_REPLY_TEXT + }) + if (!sent.ok) { + markFailed(replyStep, this.now(), sent.error || '回复确认失败') + markRemainingSkipped(steps, 'reply') + return { steps, status: 'failed', errorSummary: replyStep.error } + } + markSuccess(replyStep, this.now()) + } + + // ---- 步骤 4:生成日报 ---- + let pngPath: string | undefined + if (!reportAction) { + markSkipped(stepAt('report')) + } else { + const reportStep = stepAt('report') + reportStep.status = 'running' + reportStep.startedAt = this.now() + let result: AgentGroupReportResult + try { + result = await this.generateReport({ group: input.conversationId, range: 'today' }) + } catch (error) { + result = { + success: false, + error: error instanceof Error ? error.message : String(error) + } + } + if (!result.success || !result.pngPath) { + markFailed(reportStep, this.now(), result.error || '日报生成失败') + markRemainingSkipped(steps, 'report') + return { steps, status: 'failed', errorSummary: reportStep.error } + } + pngPath = result.pngPath + markSuccess(reportStep, this.now()) + } + + // ---- 步骤 5:发送日报图片 ---- + // 日报被跳过(或没产出图片)时,这一条也必须 skipped —— 不能凭空发图。 + if (!sendAction || !pngPath) { + markSkipped(stepAt('send')) + return { steps, status: 'success', ...(pngPath ? { pngPath } : {}) } + } + const sendStep = stepAt('send') + sendStep.status = 'running' + sendStep.startedAt = this.now() + const sent = await this.sendThroughGateway(input, 'report', { type: 'image', path: pngPath }) + if (!sent.ok) { + markFailed(sendStep, this.now(), sent.error || '发送日报图片失败') + return { steps, status: 'failed', errorSummary: sendStep.error, pngPath } + } + markSuccess(sendStep, this.now()) + return { steps, status: 'success', pngPath } + } + + /** + * 统一发送出口。 + * + * **刻意不直接调 `WechatSendGateway`,而是走 `WechatActionGateway`**:后者在 + * `WechatSendGateway` 之上多给了三样本功能必须的东西 —— + * 幂等(同一 executionId 不会重复发)、自动化发送节流(3s 间隔,防刷屏)、 + * 审计落盘。底层实际发送仍然经由 `WechatSendGateway.sendPersonal`, + * 所以「所有发送统一走 WechatSendGateway」这条约束依然成立。 + */ + private async sendThroughGateway( + input: AutomationRunInput, + kind: 'reply' | 'report', + content: WechatActionContent + ): Promise<{ ok: boolean; error?: string }> { + try { + const result = await this.executeAction({ + idempotencyKey: automationIdempotencyKey(kind, input.executionId), + origin: AUTOMATION_SEND_ORIGIN, + purpose: AUTOMATION_SEND_PURPOSE[kind], + triggerType: 'automation', + executionId: input.executionId, + recipient: { + type: input.isGroup ? 'group' : 'contact', + id: input.conversationId, + name: input.sourceDisplayName + }, + content + }) + if (result.status === 'sent') return { ok: true } + return { ok: false, error: actionErrorMessage(result.errorCode, result.reason) } + } catch (error) { + // execute() 本身刻意不抛,这里兜的是注入实现或意外异常。 + return { ok: false, error: error instanceof Error ? error.message : String(error) } + } + } +} + +function findAction(rule: AutomationRule, type: AutomationAction['type']): AutomationAction | undefined { + return rule.actions.find((action) => action.type === type && action.enabled) +} + +/** 把 `after` 之后的步骤全部标成 `skipped`(前一步挂了,后面的不许再动)。 */ +function markRemainingSkipped(steps: AutomationStep[], after: AutomationStepKey): void { + const from = STEP_ORDER.indexOf(after) + 1 + for (const key of STEP_ORDER.slice(from)) { + const step = steps.find((item) => item.key === key) + if (step) markSkipped(step) + } +} + +export const automationActionRunner = new AutomationActionRunner() diff --git a/src/main/services/automation-execution-log-service.ts b/src/main/services/automation-execution-log-service.ts new file mode 100644 index 0000000..37c904c --- /dev/null +++ b/src/main/services/automation-execution-log-service.ts @@ -0,0 +1,127 @@ +import path from 'node:path' +import { app } from 'electron' +import fs from 'fs-extra' +import type { AutomationExecution } from '../../shared/automation' + +/** + * AutomationExecutionLogService —— **用户层**执行日志。 + * + * 与 debug 日志的区别:这份记录是给用户看「哪一步坏了」的,所以 + * - 只保留用户可读字段(规则名、来源显示名、步骤状态、耗时、错误); + * - **不含** wxid / localId / serverId / source XML / raw payload / 图片绝对路径; + * - 必须落盘,重启后还能看到近期记录。 + * + * 容量有界(`MAX_RECORDS`),否则长期运行会把文件撑到几十兆。 + */ + +const STORAGE_DIR = 'automation' +const EXECUTIONS_FILE = 'executions.json' +const MAX_RECORDS = 200 + +export interface AutomationExecutionLogDependencies { + userDataPath?: () => string +} + +function normalizeExecution(value: unknown): AutomationExecution | null { + if (!value || typeof value !== 'object') return null + const record = value as Partial + const executionId = String(record.executionId || '').trim() + if (!executionId) return null + return { + executionId, + ruleId: String(record.ruleId || ''), + ruleName: String(record.ruleName || ''), + triggerTime: Number(record.triggerTime) || 0, + sourceDisplayName: String(record.sourceDisplayName || ''), + status: record.status === 'success' || record.status === 'failed' ? record.status : 'running', + durationMs: Number(record.durationMs) || 0, + steps: Array.isArray(record.steps) ? record.steps : [], + ...(record.errorSummary ? { errorSummary: String(record.errorSummary) } : {}) + } +} + +export class AutomationExecutionLogService { + private readonly userDataPath: () => string + private records: AutomationExecution[] = [] + private loaded = false + + constructor(dependencies: AutomationExecutionLogDependencies = {}) { + this.userDataPath = dependencies.userDataPath ?? (() => app.getPath('userData')) + } + + list(query: { limit?: number } = {}): AutomationExecution[] { + this.ensureLoaded() + const requested = Number(query?.limit) + const limit = Number.isFinite(requested) && requested > 0 ? Math.floor(requested) : MAX_RECORDS + return this.records.slice(0, Math.min(limit, MAX_RECORDS)).map((record) => structuredClone(record)) + } + + /** + * 写入(或按 `executionId` 覆盖)一条执行记录。 + * + * 覆盖语义是必需的:执行是「先建 running、跑完再回落终态」, + * 中间态与终态共用同一个 `executionId`。 + */ + record(execution: AutomationExecution): void { + this.ensureLoaded() + const normalized = normalizeExecution(execution) + if (!normalized) return + const withoutSame = this.records.filter((item) => item.executionId !== normalized.executionId) + this.records = [normalized, ...withoutSame].slice(0, MAX_RECORDS) + this.persist() + } + + clear(): boolean { + this.ensureLoaded() + this.records = [] + this.persist() + return true + } + + /** 统计 `sinceMs` 之后(含)的记录数与成功数。用于顶部「今日执行」。 */ + countSince(sinceMs: number): { total: number; success: number } { + this.ensureLoaded() + const from = Number(sinceMs) || 0 + let total = 0 + let success = 0 + for (const record of this.records) { + if (record.triggerTime < from) continue + total += 1 + if (record.status === 'success') success += 1 + } + return { total, success } + } + + private ensureLoaded(): void { + if (this.loaded) return + this.loaded = true + try { + const raw = fs.readJsonSync(this.executionsFilePath()) as unknown + const values = Array.isArray(raw) ? raw : [] + this.records = values + .map((value) => normalizeExecution(value)) + .filter((value): value is AutomationExecution => value !== null) + .slice(0, MAX_RECORDS) + } catch { + this.records = [] + } + } + + private executionsFilePath(): string { + return path.join(this.userDataPath(), STORAGE_DIR, EXECUTIONS_FILE) + } + + private persist(): void { + try { + const filePath = this.executionsFilePath() + fs.ensureDirSync(path.dirname(filePath)) + fs.writeJsonSync(filePath, this.records, { spaces: 2 }) + } catch (error) { + console.warn( + `[Automation] 保存执行日志失败: ${error instanceof Error ? error.message : String(error)}` + ) + } + } +} + +export const automationExecutionLogService = new AutomationExecutionLogService() diff --git a/src/main/services/automation-rule-store.ts b/src/main/services/automation-rule-store.ts new file mode 100644 index 0000000..58180c3 --- /dev/null +++ b/src/main/services/automation-rule-store.ts @@ -0,0 +1,166 @@ +import { randomUUID } from 'node:crypto' +import path from 'node:path' +import { app } from 'electron' +import fs from 'fs-extra' +import { + createDefaultDailyReportRule, + normalizeRuleDraft, + type AutomationRule +} from '../../shared/automation' + +/** + * AutomationRuleStore —— 自动化规则的持久化与增删改查。 + * + * 存储形态刻意做到最简:一个 JSON 文件(`{userData}/automation/rules.json`)。 + * 规则总量是个位数到几十条,引入数据库只会增加迁移负担。 + * + * **内置规则的播种标记(`builtinSeeded`)单独存**: + * 如果靠"文件里有没有那条内置规则"来判断是否播种,用户一旦删掉它, + * 下次启动就会被重新塞回来 —— 用户会认为删除功能坏了。 + */ + +const STORAGE_DIR = 'automation' +const RULES_FILE = 'rules.json' + +interface StoredRules { + version: number + /** 内置规则是否已经播种过。**即使随后被删除也保持 true**。 */ + builtinSeeded: boolean + rules: AutomationRule[] +} + +export interface AutomationRuleStoreDependencies { + userDataPath?: () => string + now?: () => number +} + +function emptyState(): StoredRules { + return { version: 1, builtinSeeded: false, rules: [] } +} + +/** 读盘容错:任何字段可疑都降级成安全值,绝不因为一个坏文件让功能整体不可用。 */ +function normalizeStored(value: unknown): StoredRules { + if (!value || typeof value !== 'object') return emptyState() + const input = value as Partial + const rules = Array.isArray(input.rules) + ? input.rules.filter( + (rule): rule is AutomationRule => + Boolean(rule && typeof rule === 'object' && String(rule.id || '').trim()) + ) + : [] + return { + version: Number(input.version) || 1, + builtinSeeded: input.builtinSeeded === true, + rules + } +} + +export class AutomationRuleStore { + private readonly userDataPath: () => string + private readonly now: () => number + private state: StoredRules = emptyState() + private loaded = false + + constructor(dependencies: AutomationRuleStoreDependencies = {}) { + this.userDataPath = dependencies.userDataPath ?? (() => app.getPath('userData')) + this.now = dependencies.now ?? (() => Date.now()) + } + + listRules(): AutomationRule[] { + this.ensureLoaded() + return this.state.rules.map((rule) => structuredClone(rule)) + } + + getRule(id: string): AutomationRule | undefined { + const key = String(id || '').trim() + if (!key) return undefined + const found = this.listRules().find((rule) => rule.id === key) + return found + } + + createRule(draft: unknown): AutomationRule { + this.ensureLoaded() + const timestamp = this.now() + const normalized = normalizeRuleDraft(draft) + const rule: AutomationRule = { + ...normalized, + id: randomUUID(), + createdAt: timestamp, + updatedAt: timestamp + } + this.state.rules = [...this.state.rules, rule] + this.persist() + return structuredClone(rule) + } + + updateRule(id: string, draft: unknown): 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] + // 名字留空时沿用原名,而不是变成「未命名自动化」—— 编辑页只改开关时不该改名。 + const normalized = normalizeRuleDraft(draft, current.name) + const rule: AutomationRule = { + ...current, + ...normalized, + id: current.id, + createdAt: current.createdAt, + updatedAt: this.now() + } + this.state.rules = this.state.rules.map((item, at) => (at === index ? rule : item)) + this.persist() + return structuredClone(rule) + } + + deleteRule(id: string): boolean { + this.ensureLoaded() + const key = String(id || '').trim() + const before = this.state.rules.length + this.state.rules = this.state.rules.filter((rule) => rule.id !== key) + if (this.state.rules.length === before) return false + this.persist() + return true + } + + setRuleEnabled(id: string, enabled: boolean): AutomationRule | undefined { + const rule = this.getRule(id) + if (!rule) return undefined + return this.updateRule(id, { ...rule, enabled: enabled === true }) + } + + private ensureLoaded(): void { + if (this.loaded) return + this.loaded = true + let stored: StoredRules + try { + stored = normalizeStored(fs.readJsonSync(this.rulesFilePath()) as unknown) + } catch { + stored = emptyState() + } + if (!stored.builtinSeeded) { + stored.builtinSeeded = true + stored.rules = [...stored.rules, createDefaultDailyReportRule(this.now())] + } + this.state = stored + this.persist() + } + + private rulesFilePath(): string { + return path.join(this.userDataPath(), STORAGE_DIR, RULES_FILE) + } + + private persist(): void { + try { + const filePath = this.rulesFilePath() + fs.ensureDirSync(path.dirname(filePath)) + fs.writeJsonSync(filePath, this.state, { spaces: 2 }) + } catch (error) { + console.warn( + `[Automation] 保存规则失败: ${error instanceof Error ? error.message : String(error)}` + ) + } + } +} + +export const automationRuleStore = new AutomationRuleStore() diff --git a/src/main/services/automation-service.ts b/src/main/services/automation-service.ts new file mode 100644 index 0000000..955a0cf --- /dev/null +++ b/src/main/services/automation-service.ts @@ -0,0 +1,422 @@ +import { randomUUID } from 'node:crypto' +import { + matchAutomationRule, + type AutomationMatchInput +} from '../../shared/automation-matcher' +import type { + AutomationExecution, + AutomationRule, + AutomationStatusSummary +} from '../../shared/automation' +import type { PersonalWechatSendCapability } from '../../shared/personal-wechat' +import type { Wcdb4Client } from '../wcdb4-client' +import type { NormalizedIncomingMessage } from './message-listener-service' +import { listContacts } from './chat-service' +import { getPersonalWechatSendCapability } from './personal-wechat-capability-service' +import { + automationRuleStore, + type AutomationRuleStore +} from './automation-rule-store' +import { + automationExecutionLogService, + type AutomationExecutionLogService +} from './automation-execution-log-service' +import { + automationActionRunner, + type AutomationActionRunner +} from './automation-action-runner' + +/** + * AutomationService —— Automation v1 的编排层。 + * + * ```text + * MessageListener + * ↓ NormalizedIncomingMessage + * AutomationService.handleMessage + * ↓ TriggerMatcher(shared,纯函数;规则编辑页的预览共用同一份) + * ↓ cooldown → 同消息幂等 → 发送能力预检 + * AutomationActionRunner + * ↓ + * Execution Log + * ``` + * + * 刻意不引入 EventBus / RxJS / MQ / 工作流引擎 —— 这条链路的每一步都是同步可读的。 + * + * **防循环 / 防刷屏四道闸**(缺一不可): + * 1. `isSelf` 防护:自己发的消息不触发(TriggerMatcher 内); + * 2. MessageListener dedup:同一条消息不会重复投递; + * 3. cooldown:同一规则 + 同一会话的最小间隔; + * 4. 同消息幂等:`ruleId:sessionId:localId` 只执行一次。 + * + * 第 1 条是关键:TraceMemo 回复的那句「收到,正在生成今日日报」本身含关键词「日报」, + * 少了它就会自己触发自己,形成死循环。 + */ + +/** 幂等登记表的存活时间与容量上限(与 MessageListener 的 dedup 同思路)。 */ +const CLAIM_TTL_MS = 10 * 60 * 1000 +const CLAIM_MAX_ENTRIES = 5_000 +/** 自己的 username 候选集缓存时长 —— 这个值几乎不变,没必要每条消息都查。 */ +const SELF_USERNAME_TTL_MS = 30_000 + +export interface AutomationServiceDependencies { + ruleStore?: AutomationRuleStore + executionLog?: AutomationExecutionLogService + runner?: AutomationActionRunner + getCapability?: () => Promise + /** 由主进程注入:MessageListener 是否在运行。 */ + isListening?: () => boolean + now?: () => number +} + +interface ClaimEntry { + at: number +} + +export class AutomationService { + private readonly ruleStore: AutomationRuleStore + private readonly executionLog: AutomationExecutionLogService + private readonly runner: AutomationActionRunner + private readonly getCapability: () => Promise + private readonly isListening: () => boolean + private readonly now: () => number + + /** `ruleId:sessionId:localId` → 登记时间。 */ + private readonly claims = new Map() + /** `${ruleId}:${conversationId}` → 上次真正开始执行的毫秒时间戳。 */ + private readonly cooldowns = new Map() + + private selfUsernames: string[] = [] + private selfUsernamesAt = 0 + + constructor( + private readonly client: Wcdb4Client, + dependencies: AutomationServiceDependencies = {} + ) { + this.ruleStore = dependencies.ruleStore ?? automationRuleStore + this.executionLog = dependencies.executionLog ?? automationExecutionLogService + this.runner = dependencies.runner ?? automationActionRunner + this.getCapability = dependencies.getCapability ?? getPersonalWechatSendCapability + this.isListening = dependencies.isListening ?? (() => true) + this.now = dependencies.now ?? (() => Date.now()) + } + + /** + * 消息入口。**绝不抛** —— 它挂在 MessageListener 的回调上, + * 抛出去会影响后续监听者。 + */ + async handleMessage(message: NormalizedIncomingMessage): Promise { + let rules: AutomationRule[] + try { + rules = this.ruleStore.listRules() + } catch (error) { + this.warn(`读取规则失败: ${errorText(error)}`) + return + } + if (!rules.length) return + + const selfUsernames = this.resolveSelfUsernames() + const input: AutomationMatchInput = { + ...(message.content !== undefined ? { content: message.content } : {}), + mentionTargets: message.mentionTargets, + isSelf: message.isSelf, + isGroup: message.isGroup, + // conversationId 与规则里的 conversationIds 同口径:群为 `xxx@chatroom`。 + conversationId: message.sessionId + } + + for (const rule of rules) { + let matched: boolean + try { + matched = matchAutomationRule(rule, input, selfUsernames).matched + } catch (error) { + this.warn(`规则匹配异常 ruleId=${rule.id}: ${errorText(error)}`) + continue + } + if (!matched) continue + + // 三道闸全部是**同步**的,必须在任何 await 之前完成登记, + // 否则同一批并发消息会同时穿过检查。 + if (this.isCoolingDown(rule, message.sessionId)) continue + if (!this.claim(rule, message)) continue + + const sourceDisplayName = this.resolveDisplayName(message) + await this.execute(rule, message, sourceDisplayName) + } + } + + /** 顶部状态条数据。全部来自真实能力,不做渲染层平台猜测。 */ + async getStatus(): Promise { + const now = this.now() + const startOfToday = new Date(now) + startOfToday.setHours(0, 0, 0, 0) + const counts = this.executionLog.countSince(startOfToday.getTime()) + let capability: PersonalWechatSendCapability | null = null + try { + capability = await this.getCapability() + } catch { + capability = null + } + return { + listening: this.isListening(), + // 底层只能回读「最近活跃会话」(见 message-listener-service.ts 的 BLOCKER 注释), + // UI 必须如实告知,不允许把它包装成「全局监听」。 + listeningDegraded: true, + todayExecutions: counts.total, + todaySuccesses: counts.success, + sendCapability: { + supported: Boolean(capability?.supported), + ready: Boolean(capability?.ready), + canSendText: Boolean(capability?.capabilities?.text), + canSendImage: Boolean(capability?.capabilities?.image), + message: capability?.message ?? '暂时无法获知微信发送能力' + } + } + } + + /** 「在哪些聊天生效」的可选项。`id` 必须是 `xxx@chatroom`,与 message.sessionId 对齐。 */ + listGroups(): Array<{ id: string; name: string }> { + try { + return listContacts() + .filter((contact) => contact.type === 'group' || contact.m_nsUsrName?.endsWith('@chatroom')) + .map((contact) => ({ + id: String(contact.m_nsUsrName || ''), + name: String(contact.m_nsNickName || contact.m_nsUsrName || '') + })) + .filter((group) => group.id) + .sort((a, b) => a.name.localeCompare(b.name, 'zh-Hans-CN')) + } catch (error) { + this.warn(`读取群列表失败: ${errorText(error)}`) + return [] + } + } + + // --------------------------------------------------------------------------- + // 内部 + // --------------------------------------------------------------------------- + + private async execute( + rule: AutomationRule, + message: NormalizedIncomingMessage, + sourceDisplayName: string + ): Promise { + const executionId = randomUUID() + const startedAt = this.now() + const isGroup = message.isGroup + + // 发送能力预检:环境发不出去时**不要执行**,直接留下一条说明清楚的失败记录。 + // 「假装规则正常运行」比直接报错更糟 —— 用户会以为发出去了。 + const missing = await this.checkSendCapability(rule, isGroup) + if (missing) { + this.executionLog.record( + this.buildExecution({ + executionId, + rule, + startedAt, + sourceDisplayName, + status: 'failed', + durationMs: this.now() - startedAt, + steps: [], + errorSummary: missing + }) + ) + this.warn(`发送能力不足,跳过执行 ruleId=${rule.id} code=capability_missing`) + return + } + + this.cooldowns.set(`${rule.id}:${message.sessionId}`, this.now()) + + let result: Awaited> + try { + result = await this.runner.run({ + executionId, + rule, + conversationId: message.sessionId, + isGroup, + sourceDisplayName + }) + } catch (error) { + result = { + steps: [], + status: 'failed', + errorSummary: `执行过程异常:${errorText(error)}` + } + } + + this.executionLog.record( + this.buildExecution({ + executionId, + rule, + startedAt, + sourceDisplayName, + status: result.status, + durationMs: this.now() - startedAt, + steps: result.steps, + ...(result.errorSummary ? { errorSummary: result.errorSummary } : {}) + }) + ) + + // 隐私红线:日志里只允许出现 ruleId / executionId / 状态 / 耗时 / 步骤状态。 + // 群名、昵称、wxid、正文、source、文件路径一律不进日志。 + const trail = result.steps.map((step) => `${step.key}=${step.status}`).join(' ') + this.info( + `executed ruleId=${rule.id} executionId=${executionId} status=${result.status}` + + ` durationMs=${this.now() - startedAt}${trail ? ` ${trail}` : ''}` + ) + } + + /** + * 检查当前环境是否具备规则所需的发送能力。 + * + * 返回「缺失的能力」说明;返回 `undefined` 表示可以执行。 + * 私聊场景下 `sendReportImage` 同样受限,所以不做特殊豁免。 + */ + private async checkSendCapability( + rule: AutomationRule, + isGroup: boolean + ): Promise { + const needsText = rule.actions.some((action) => action.type === 'replyText' && action.enabled) + const needsImage = rule.actions.some( + (action) => action.type === 'sendReportImage' && action.enabled + ) + if (!needsText && !needsImage) return undefined + + let capability: PersonalWechatSendCapability | null = null + try { + capability = await this.getCapability() + } catch { + return '暂时无法获知微信发送能力,已跳过本次执行' + } + if (!capability?.supported) { + return capability?.message || '当前系统不支持微信消息发送' + } + const target = isGroup ? '群聊' : '联系人' + if (needsText && !capability.capabilities?.text) { + return `当前环境无法发送文字,已跳过本次执行(无法回复${target})` + } + if (needsImage && !capability.capabilities?.image) { + return '当前环境无法发送图片,已跳过本次执行(无法发送日报)' + } + return undefined + } + + private buildExecution(input: { + executionId: string + rule: AutomationRule + startedAt: number + sourceDisplayName: string + status: AutomationExecution['status'] + durationMs: number + steps: AutomationExecution['steps'] + errorSummary?: string + }): AutomationExecution { + return { + executionId: input.executionId, + ruleId: input.rule.id, + ruleName: input.rule.name, + triggerTime: input.startedAt, + sourceDisplayName: input.sourceDisplayName, + status: input.status, + durationMs: input.durationMs, + steps: input.steps, + ...(input.errorSummary ? { errorSummary: input.errorSummary } : {}) + } + } + + private isCoolingDown(rule: AutomationRule, conversationId: string): boolean { + const seconds = Number(rule.cooldownSeconds) + if (!Number.isFinite(seconds) || seconds <= 0) return false + const last = this.cooldowns.get(`${rule.id}:${conversationId}`) + if (last === undefined) return false + return this.now() - last < seconds * 1000 + } + + /** 登记「这条消息已经被这条规则处理过」。返回 false 表示重复,应当跳过。 */ + private claim(rule: AutomationRule, message: NormalizedIncomingMessage): boolean { + const key = `${rule.id}:${message.sessionId}:${message.localId}` + const now = this.now() + if (this.claims.has(key)) return false + this.claims.set(key, { at: now }) + if (this.claims.size > CLAIM_MAX_ENTRIES) this.evictClaims(now) + return true + } + + /** 先按 TTL 清理;仍超限则按插入顺序丢最旧的一批。 */ + private evictClaims(now: number): void { + for (const [key, entry] of this.claims) { + if (now - entry.at > CLAIM_TTL_MS) this.claims.delete(key) + } + if (this.claims.size <= CLAIM_MAX_ENTRIES) return + const overflow = this.claims.size - CLAIM_MAX_ENTRIES + let removed = 0 + for (const key of this.claims.keys()) { + this.claims.delete(key) + removed += 1 + if (removed >= overflow) break + } + } + + private resolveSelfUsernames(): string[] { + const now = this.now() + if (this.selfUsernames.length && now - this.selfUsernamesAt < SELF_USERNAME_TTL_MS) { + return this.selfUsernames + } + try { + this.selfUsernames = (this.client.getMyUsernameCandidates?.() ?? []).filter(Boolean) + this.selfUsernamesAt = now + } catch (error) { + this.warn(`读取自身 username 失败: ${errorText(error)}`) + } + return this.selfUsernames + } + + /** + * 会话显示名。 + * + * 拿不到会话昵称时**降级成「群聊 / 联系人」**,绝不回落到 wxid —— + * 那个值一旦漏进执行日志就等于把隐私写进了用户可见的界面。 + */ + private resolveDisplayName(message: NormalizedIncomingMessage): string { + try { + const session = this.client.getSessions().find((item) => item.username === message.sessionId) + const nickname = String(session?.nickname || '').trim() + if (nickname) return nickname + } catch { + // 忽略:走下面的通用降级名 + } + return message.isGroup ? '群聊' : '联系人' + } + + /** 日志统一出口:只允许不可逆的运维信息,禁止任何身份信息。 */ + private info(message: string): void { + console.log(`[Automation] ${message}`) + } + + private warn(message: string): void { + console.warn(`[Automation] ${message}`) + } +} + +function errorText(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +let instance: AutomationService | null = null + +/** + * 主进程启动时调用一次。 + * + * 用惰性初始化而不是模块级单例,是因为它需要 `Wcdb4Client`, + * 而那个实例要等数据库解锁后才存在。 + */ +export function initAutomationService( + client: Wcdb4Client, + dependencies: AutomationServiceDependencies = {} +): AutomationService { + instance = new AutomationService(client, dependencies) + return instance +} + +export function getAutomationService(): AutomationService { + if (!instance) throw new Error('AutomationService 尚未初始化') + return instance +} diff --git a/src/main/services/message-listener-service.ts b/src/main/services/message-listener-service.ts new file mode 100644 index 0000000..7eeaa37 --- /dev/null +++ b/src/main/services/message-listener-service.ts @@ -0,0 +1,404 @@ +import { decompress as zstdDecompress } from 'fzstd' +import type { Wcdb4Client, Wcdb4Message } from '../wcdb4-client' + +/** + * MessageListener —— 实时消息回读底座。 + * + * **职责边界(刻意很窄)**: + * + * ``` + * WCDB native change + * ↓ + * coalesce + * ↓ + * bounded DB readback + * ↓ + * dedup + * ↓ + * NormalizedIncomingMessage → onMessage(callback) + * ``` + * + * **不负责**(这些属于下一层,本模块不许碰):关键词匹配、@我业务判断、日报、 + * 自动回复、AI 调用、Agent 调度、发送消息。 + * + * 设计约束全部来自 2026-09-20 的真实环境 Spike(见 + * `.ai-local/reports/2026-09-20-realtime-message-spike-result.md`): + * 一条真实消息平均触发 **≈17 个** native event,所以**绝不能**一个事件触发一次业务动作。 + */ + +/** zstd 帧魔数(微信 `source` 列是 zstd 压缩)。 */ +const ZSTD_MAGIC = [0x28, 0xb5, 0x2f, 0xfd] + +/** + * coalesce 窗口。 + * + * 实测同一批 native event 会**同时**到达(同一毫秒内十几个),所以只需要一个极短的 + * 合并窗口就能把它们收敛成一次回读。取 120ms: + * - 足够吃掉同一批事件(实测事件间隔 < 5ms); + * - 相对「event → 可读」本身就有 ~1.2s 的落库延迟,这点等待**不构成额外延迟**; + * - 与项目既有 `wcdb-change` 消费端的 350ms debounce 相比更短,不会叠加成明显卡顿。 + */ +const DEFAULT_COALESCE_MS = 120 + +/** 回读窗口:只看最近这么久,绝不扫历史。 */ +const DEFAULT_LOOKBACK_SEC = 30 +/** 未来容忍(时钟漂移 + 秒级取整)。 */ +const DEFAULT_LOOKAHEAD_SEC = 2 +/** 单次回读条数上限。 */ +const DEFAULT_READ_LIMIT = 50 + +/** dedup 条目存活时间:超过它就可以被淘汰(同一条消息不会在窗口外再被读到)。 */ +const DEFAULT_DEDUP_TTL_MS = 5 * 60 * 1000 +/** dedup 容量上限,防止长时间运行后无限增长。 */ +const DEFAULT_DEDUP_MAX_ENTRIES = 5_000 + +/** + * 规范化后的入站消息。 + * + * 刻意**克制**:只包含 Trigger 后续真正需要的字段,不做「万能 Message DTO」。 + * 原始行(`raw`)、未解压的 Buffer、头像等一律不往外传。 + */ +export interface NormalizedIncomingMessage { + sessionId: string + /** 会话内序号。**单独不保证跨 shard 唯一**,去重必须带上 sessionId。 */ + localId: string + /** 服务器侧全局标识。**可能缺失**,所以只作强标识、不作必需字段。 */ + serverId?: string + /** + * Unix **epoch 秒** —— 与 WCDB `create_time` / `getMessages` 参数同口径。 + * + * ⚠️ 本项目另有模块使用 epoch 毫秒。**换算只允许集中在本层**: + * 凡是需要毫秒的消费者,自己明确 `/1000` 的**唯一**位置就是这里之后的调用点, + * 不许在任意函数里散落 `* 1000` / `/ 1000`。 + */ + createTime: number + messageType: number + /** `mesDes === 0` 表示自己发送(字段语义与直觉相反)。 */ + isSelf: boolean + senderId?: string + senderNickname?: string + content?: string + /** **已解压**的 source XML;原始 zstd Buffer 不往外传。 */ + source?: string + isGroup: boolean + /** + * 从 `source` 解析出的 @ 目标(**微信 username,不是昵称**)。 + * + * 本轮只产出数据,**不做「是否 @ 我」的业务判断** —— 那需要与 + * `getMyUsernameCandidates()` 求交集,属于下一层。 + */ + mentionTargets: string[] +} + +/** 监听统计(不含任何身份信息,可安全记录)。 */ +export interface MessageListenerStats { + /** 收到的 native change event 数。 */ + nativeEvents: number + /** 被 coalesce 合并掉的事件数(未单独触发回读)。 */ + coalescedEvents: number + /** 实际执行的回读次数。 */ + readbacks: number + /** 通过 dedup 并投递出去的消息数。 */ + delivered: number + /** 因 dedup 被丢弃的重复投递数。 */ + deduped: number +} + +export interface MessageListenerOptions { + coalesceMs?: number + lookbackSec?: number + lookaheadSec?: number + readLimit?: number + dedupTtlMs?: number + dedupMaxEntries?: number +} + +/** + * 从 `source`(**已解压的 XML 文本**)提取 @ 目标。 + * + * 纯函数:不依赖任何运行时状态,便于单测。 + * + * 实测两种形式都真实存在,**都必须支持**: + * ```xml + * + * SELF_USERNAME + * ``` + * + * ⚠️ 三条硬规则: + * 1. **CDATA 外壳必须剥掉**,否则拿到的是 `` 字面量; + * 2. **值就是微信 username,不保证以 `wxid_` 开头** —— 绝不能用前缀做过滤/校验; + * 3. **不允许多人分隔符的猜测** —— 多人 @ 的格式尚未在真实样本中验证过, + * 这里按「单个值原样返回」处理;一旦拿到真实多人样本需补解析与测试 + * (当前形状已经预留为 `string[]`)。 + */ +export function extractMentionTargets(source: string | undefined): string[] { + const text = String(source ?? '') + if (!text) return [] + const match = /([\s\S]*?)<\/atuserlist>/i.exec(text) + if (!match) return [] + const inner = match[1].trim() + const unwrapped = inner.replace(/^$/, '').trim() + return unwrapped ? [unwrapped] : [] +} + +/** 把 WCDB 的 `source` 列(zstd 压缩的 Buffer)还原成 XML 文本。 */ +export function decodeSourcePayload(value: unknown): string | undefined { + let buffer: Buffer | null = null + if (Buffer.isBuffer(value)) { + buffer = value + } else { + const candidate = value as { type?: string; data?: unknown } | null + if (candidate?.type === 'Buffer' && Array.isArray(candidate.data)) { + buffer = Buffer.from(candidate.data as number[]) + } + } + if (!buffer || buffer.length === 0) return undefined + const isZstd = ZSTD_MAGIC.every((byte, index) => buffer![index] === byte) + if (!isZstd) return buffer.toString('utf8') + try { + return Buffer.from(zstdDecompress(buffer)).toString('utf8') + } catch { + return undefined + } +} + +/** + * 实时消息监听。 + * + * ⚠️ **已知 BLOCKER(必须如实标注)**: + * 本实现**无法从 native event 判断是哪个会话发生了变化** —— 事件 payload 只有 + * `{db, table, action}`(native 侧 `MonitorEvent` 结构就这两个字段)。 + * 因此当前策略是**回读「最近活跃会话」**(`sessions[0]`,按 `last_timestamp` 排序), + * 这在「刚刚收到消息的会话」这一场景下成立,但: + * + * - **不是**「全局监听所有微信会话」; + * - 若同一窗口内有**多个会话**同时来消息,只会回读到其中最近的那个; + * - 正式扩展需要解决「如何从 table event 推断需要回读哪些会话」。 + * + * 详见 `NEXT_STEPS` 注释与 Spike 报告。 + */ +export class MessageListenerService { + private readonly listeners = new Set<(message: NormalizedIncomingMessage) => void>() + /** + * dedup 缓存:`sessionId:localId` → 首次见到的时间戳。 + * + * 必须有界 —— Spike 里用的是无上限 `Set`,长时间运行会持续吃内存。 + */ + private readonly seen = new Map() + private coalesceTimer: ReturnType | null = null + private readbackInFlight = false + private disposed = false + + private readonly coalesceMs: number + private readonly lookbackSec: number + private readonly lookaheadSec: number + private readonly readLimit: number + private readonly dedupTtlMs: number + private readonly dedupMaxEntries: number + + private counters: MessageListenerStats = { + nativeEvents: 0, + coalescedEvents: 0, + readbacks: 0, + delivered: 0, + deduped: 0 + } + + constructor( + private readonly client: Wcdb4Client, + options: MessageListenerOptions = {} + ) { + this.coalesceMs = options.coalesceMs ?? DEFAULT_COALESCE_MS + this.lookbackSec = options.lookbackSec ?? DEFAULT_LOOKBACK_SEC + this.lookaheadSec = options.lookaheadSec ?? DEFAULT_LOOKAHEAD_SEC + this.readLimit = options.readLimit ?? DEFAULT_READ_LIMIT + this.dedupTtlMs = options.dedupTtlMs ?? DEFAULT_DEDUP_TTL_MS + this.dedupMaxEntries = options.dedupMaxEntries ?? DEFAULT_DEDUP_MAX_ENTRIES + } + + /** + * 订阅新消息。返回取消订阅函数。 + * + * 刻意**只提供回调**:当前规模不需要 EventEmitter / RxJS / 内部队列。 + */ + onMessage(listener: (message: NormalizedIncomingMessage) => void): () => void { + this.listeners.add(listener) + return () => this.listeners.delete(listener) + } + + stats(): MessageListenerStats { + return { ...this.counters } + } + + dispose(): void { + this.disposed = true + if (this.coalesceTimer) { + clearTimeout(this.coalesceTimer) + this.coalesceTimer = null + } + this.listeners.clear() + this.seen.clear() + } + + /** + * 接 native change event。 + * + * 只做计数与 coalesce —— **绝不**在这里回读,更不触发任何业务。 + */ + handleNativeChange(): void { + if (this.disposed) return + this.counters.nativeEvents += 1 + if (this.coalesceTimer) { + // 已经在同一个窗口里:直接合并掉,这就是 17:1 那个比值的收敛点。 + this.counters.coalescedEvents += 1 + return + } + this.coalesceTimer = setTimeout(() => { + this.coalesceTimer = null + void this.readback() + }, this.coalesceMs) + } + + /** + * 有界回读 → normalize → dedup → 投递。 + * + * 回读范围严格受限:**单会话** + **小时间窗口** + **条数上限**。 + * 不遍历会话、不扫全库。 + */ + private async readback(): Promise { + if (this.disposed || this.readbackInFlight) return + this.readbackInFlight = true + const startedAt = Date.now() + try { + const nowSec = Math.floor(startedAt / 1000) + const sessions = this.client.getSessions() + const session = sessions[0] + if (!session?.username) return + + this.counters.readbacks += 1 + const messages = await this.client.getMessagesAsync( + session.username, + nowSec - this.lookbackSec, + nowSec + this.lookaheadSec, + { limit: this.readLimit } + ) + if (this.disposed) return + + let delivered = 0 + for (const message of messages) { + const normalized = this.normalize(session.username, message) + if (!normalized) continue + // dedup:同一条消息可能在多个读回窗口中反复出现。 + if (!this.markSeen(normalized)) { + this.counters.deduped += 1 + continue + } + this.counters.delivered += 1 + delivered += 1 + this.deliver(normalized) + } + + if (delivered > 0) { + // 日志只记录数量与耗时,**不含** wxid / 群名 / 昵称 / 正文 / source。 + console.log( + `[MessageListener] delivered=${delivered} readbackMs=${Date.now() - startedAt}` + + ` sessionType=${session.username.endsWith('@chatroom') ? 'group' : 'direct'}` + + ` events=${this.counters.nativeEvents} coalesced=${this.counters.coalescedEvents}` + ) + } + } catch (error) { + console.warn( + `[MessageListener] readback failed: ${error instanceof Error ? error.message : String(error)}` + ) + } finally { + this.readbackInFlight = false + } + } + + private deliver(message: NormalizedIncomingMessage): void { + for (const listener of this.listeners) { + try { + listener(message) + } catch (error) { + console.warn( + `[MessageListener] listener threw: ${ + error instanceof Error ? error.message : String(error) + }` + ) + } + } + } + + /** 把 WCDB 行规范化为克制的 DTO。返回 null 表示这条行缺关键标识,无法投递。 */ + private normalize( + sessionUsername: string, + message: Wcdb4Message + ): NormalizedIncomingMessage | null { + const localId = message.mesLocalID === undefined ? '' : String(message.mesLocalID) + if (!localId) return null + + const createTime = Number(message.msgCreateTime) || 0 + if (!createTime) return null + + const raw = (message.raw ?? {}) as Record + const source = decodeSourcePayload(raw.source) + const senderId = message.sender ? String(message.sender) : undefined + + return { + sessionId: sessionUsername, + localId, + serverId: message.serverId ? String(message.serverId) : undefined, + createTime, + messageType: Number(message.messageType) || 0, + // `mesDes === 0` 是自己发送。不用昵称 / sender 文本 / content / username 前缀判断。 + isSelf: Number(message.mesDes) === 0, + senderId, + senderNickname: message.senderNickname ? String(message.senderNickname) : undefined, + content: message.msgContent ? String(message.msgContent) : undefined, + source, + isGroup: sessionUsername.endsWith('@chatroom'), + mentionTargets: extractMentionTargets(source) + } + } + + /** 登记并返回「是否是第一次见到」。同时做 TTL + 容量淘汰。 */ + private markSeen(message: NormalizedIncomingMessage): boolean { + const key = `${message.sessionId}:${message.localId}` + const now = Date.now() + if (this.seen.has(key)) return false + + this.seen.set(key, now) + if (this.seen.size > this.dedupMaxEntries) this.evict(now) + return true + } + + /** + * 淘汰策略:先按 TTL 清理过期条目;若仍然超限,再按插入顺序丢掉最旧的一批 + * (Map 保持插入顺序,所以从头删就是删最旧)。 + */ + private evict(now: number): void { + for (const [key, seenAt] of this.seen) { + if (now - seenAt > this.dedupTtlMs) this.seen.delete(key) + } + if (this.seen.size <= this.dedupMaxEntries) return + const overflow = this.seen.size - this.dedupMaxEntries + let removed = 0 + for (const key of this.seen.keys()) { + this.seen.delete(key) + removed += 1 + if (removed >= overflow) break + } + } +} + +/** + * NEXT STEPS(不在本轮范围) + * + * 1. **会话定位(BLOCKER)**:目前只能回读「最近活跃会话」。要真正监听全部会话, + * 需要解决「从 table event 推断变更会话」。可能的路线: + * - 观察 native 侧能否在事件里补上 table 所属的会话标识(需改 dll); + * - 或改成按 `Session` 表的 `last_timestamp` 变化做「有界的多会话回读」。 + * 2. **多人 @**:`extractMentionTargets` 目前按单值返回,多人格式**未验证**,不得猜分隔符。 + * 3. **isMentionedMe**:`mentionTargets` ∩ `getMyUsernameCandidates()`,再接 Trigger。 + * 4. 本轮**不做**:关键词 / @触发 / 自动回复 / 日报 / AI / Agent / 发送。 + */ diff --git a/src/main/services/wechat-action-gateway.ts b/src/main/services/wechat-action-gateway.ts index b3f5ea6..bff8161 100644 --- a/src/main/services/wechat-action-gateway.ts +++ b/src/main/services/wechat-action-gateway.ts @@ -22,7 +22,15 @@ import { wechatSendGateway } from './wechat-send-gateway' const MAX_AUDIT_RECORDS = 500 const MAX_CONTENT_PREVIEW_LENGTH = 240 export const AUTOMATION_SEND_INTERVAL_MS = 3_000 -const AUTOMATION_PURPOSE_ALLOWLIST = new Set(['scheduled_report', 'member_left_notification']) +const AUTOMATION_PURPOSE_ALLOWLIST = new Set([ + 'scheduled_report', + 'member_left_notification', + // Automation v1(@我生成日报)。必须与 `src/shared/automation.ts` 的 + // `AUTOMATION_SEND_PURPOSE` 保持一致,否则会被下面 evaluateWechatActionPolicy + // 以 ACTION_NOT_ALLOWED 拦下 —— 那是有意的闸门,不是 bug。 + 'automation_reply', + 'automation_report' +]) export interface WechatActionGatewayDependencies { getCapability?: () => Promise diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 9a69925..4583653 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -103,6 +103,12 @@ import type { } from '../shared/personal-wechat-voice-runtime' import type { AppLogEntry } from '../shared/app-log' import type { GroupExitMonitorEvent, GroupExitMonitorState } from '../shared/group-exit-monitor' +import type { + AutomationExecution, + AutomationRule, + AutomationRuleDraft, + AutomationStatusSummary +} from '../shared/automation' import type { ActionLogEntry } from '../shared/action-log' import type { AppUpdateCheckResult, @@ -313,6 +319,15 @@ declare global { resendGroupExitMonitorEvent: (eventId: string) => Promise markGroupExitMonitorRead: (readAt?: number) => Promise listWechatActionLogs: () => Promise + getAutomationStatus: () => Promise + listAutomationRules: () => Promise + createAutomationRule: (draft: AutomationRuleDraft) => Promise + updateAutomationRule: (id: string, draft: AutomationRuleDraft) => Promise + deleteAutomationRule: (id: string) => Promise + setAutomationRuleEnabled: (id: string, enabled: boolean) => Promise + listAutomationExecutions: (query?: { limit?: number }) => Promise + clearAutomationExecutions: () => Promise + listAutomationGroups: () => Promise> onGroupExitMonitorState: (callback: (state: GroupExitMonitorState) => void) => () => void search: (keyword: string) => Promise searchKnowledge: (request: KnowledgeSearchIpcRequest) => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index cbf4627..f44bb41 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -72,6 +72,12 @@ import type { import type { AppLogEntry } from '../shared/app-log' import type { AppUpdateState } from '../shared/app-update' import type { GroupExitMonitorEvent, GroupExitMonitorState } from '../shared/group-exit-monitor' +import type { + AutomationExecution, + AutomationRule, + AutomationRuleDraft, + AutomationStatusSummary +} from '../shared/automation' import type { GroupMemberStatsQuery, GroupMemberStatsResult } from '../shared/group-stats' import type { ActionLogEntry } from '../shared/action-log' import type { CacheClearScope, CacheSummary } from '../shared/cache' @@ -200,6 +206,25 @@ const api = { ipcRenderer.invoke('group-exit-monitor:markRead', readAt), listWechatActionLogs: (): Promise => ipcRenderer.invoke('wechat-action-log:list'), + // ---- Automation v1(@我生成日报)。命名与既有扁平风格一致。 ---- + getAutomationStatus: (): Promise => + ipcRenderer.invoke('automation:getStatus'), + listAutomationRules: (): Promise => ipcRenderer.invoke('automation:listRules'), + createAutomationRule: (draft: AutomationRuleDraft): Promise => + ipcRenderer.invoke('automation:createRule', draft), + updateAutomationRule: (id: string, draft: AutomationRuleDraft): Promise => + ipcRenderer.invoke('automation:updateRule', { id, draft }), + deleteAutomationRule: (id: string): Promise => + ipcRenderer.invoke('automation:deleteRule', id), + setAutomationRuleEnabled: (id: string, enabled: boolean): Promise => + ipcRenderer.invoke('automation:setRuleEnabled', { id, enabled }), + listAutomationExecutions: (query?: { limit?: number }): Promise => + ipcRenderer.invoke('automation:listExecutions', query), + clearAutomationExecutions: (): Promise => + ipcRenderer.invoke('automation:clearExecutions'), + /** 「在哪些聊天生效」的可选项。`id` 为 `xxx@chatroom`,与规则内 conversationIds 同口径。 */ + listAutomationGroups: (): Promise> => + ipcRenderer.invoke('automation:listGroups'), onGroupExitMonitorState: (callback: (state: GroupExitMonitorState) => void) => { const listener = (_event: Electron.IpcRendererEvent, state: GroupExitMonitorState): void => callback(state) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 74f7d34..40e5f8f 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -46,6 +46,7 @@ import { runtimePlatform, supportsPersonalWechatSend } from './utils/runtime-env import { useToast } from './components/ui' import { AppUpdatePrompt } from './features/app-update/AppUpdatePrompt' import { GroupExitMonitorWorkspace } from './features/group-exit-monitor/GroupExitMonitorWorkspace' +import { AutomationWorkspace } from './features/automation/AutomationWorkspace' import { selectContactAvatarRefreshUsernames } from './utils/contact-avatar' import { buildContactSearchIndex, @@ -2098,6 +2099,13 @@ function App(): React.ReactElement { onOpenSendSettings={openWechatSendSettings} /> ) + case 'automation': + return ( + + ) case 'agent-hub': return case 'api': diff --git a/src/renderer/src/components/layout/PrimaryNavigation.tsx b/src/renderer/src/components/layout/PrimaryNavigation.tsx index 7aa9cac..f25c1a2 100644 --- a/src/renderer/src/components/layout/PrimaryNavigation.tsx +++ b/src/renderer/src/components/layout/PrimaryNavigation.tsx @@ -54,6 +54,13 @@ function NavIcon({ page }: NavIconProps): React.ReactElement { ) + case 'automation': + // 闪电:强调「命中即自动执行」,与退群监控的箭头区分开。 + return ( + + ) case 'agent-hub': return (
+
+
+

自动化

+

当群里出现符合条件的消息时,TraceMemo 会自动执行你配置的动作。

+
+ {tab === 'rules' && !showEditor ? ( + + ) : null} +
+ + {!apiAvailable ? ( +

+ 自动化接口尚未就绪(可能正在启动或版本不匹配)。下面的数据可能不是最新的。 +

+ ) : null} + {!dbReady ? ( +

+ 微信数据库尚未连接,规则可以编辑,但无法读取群列表,也不会触发。 +

+ ) : null} + +
+
+ 消息监听 + + {status.listening ? '正在监听新消息' : '未在监听'} + + {status.listeningDegraded ? ( + + + + 当前仅能捕获最近活跃的会话 + + + + 微信底层只会上报「有表发生了变化」,不带是哪条会话。TraceMemo + 目前据此回读最近活跃的会话,因此同一时刻多个群同时来消息时,只会处理其中的一个。 + + + ) : null} +
+ +
+ 发送能力 + + {capabilityReady ? '可以发送文字和图片' : capability.ready ? '发送能力不完整' : '尚未就绪'} + + {capability.message} + {!capabilityReady && onOpenSendSettings ? ( + + ) : null} +
+ +
+ 今日执行 + + {status.todaySuccesses} / {status.todayExecutions} + + 成功 / 总计 +
+
+ + {!showEditor ? ( + setTab(value as AutomationTab)} + aria-label="自动化视图" + > + 规则 + 执行日志 + + ) : null} + + {showEditor && editor ? ( + setEditor(null)} + onSave={(draft) => void handleSave(draft)} + /> + ) : tab === 'rules' ? ( + void handleToggle(rule, enabled)} + onEdit={(rule) => setEditor({ mode: 'edit', rule })} + onDelete={(rule) => setPendingDelete(rule)} + onCreate={() => setEditor({ mode: 'create', rule: null })} + /> + ) : ( + void handleClearExecutions()} + /> + )} + + setSelectedExecution(null)} + /> + + {pendingDelete ? ( +
setPendingDelete(null)}> +
event.stopPropagation()} + > +

删除「{pendingDelete.name}」?

+

删除后不会再触发,已有的执行记录会保留。

+
+ + +
+
+
+ ) : null} +
+ ) +} diff --git a/src/renderer/src/features/automation/EffectPreview.tsx b/src/renderer/src/features/automation/EffectPreview.tsx new file mode 100644 index 0000000..d52523c --- /dev/null +++ b/src/renderer/src/features/automation/EffectPreview.tsx @@ -0,0 +1,139 @@ +import * as React from 'react' +import type { AutomationRuleDraft } from '../../../../shared/automation' + +/** + * EffectPreview —— 微信式效果模拟预览。 + * + * **纯前端模拟**:不调用真实微信发送、不调日报生成、不调 AI。 + * 它的全部作用是在用户按下保存之前,把「这条规则到底会做什么」演一遍。 + * + * 与表单**逐项联动**(这是验收点): + * - 改关键词 → 左侧模拟消息里的内容跟着变; + * - 改回复文案 → 右侧气泡跟着变; + * - 关闭「回复确认」→ 右侧气泡消失; + * - 关闭「必须真正 @我」→ 左侧不再显示 `@你`; + * - 关闭「生成日报」→ 不再出图片,并说明原因; + * - 关闭「发送图片」→ 日报仍会生成但不会进群,说明区分开。 + */ + +const SAMPLE_SENDER_NAME = '张三' + +function isActionEnabled(draft: AutomationRuleDraft, type: string): boolean { + return draft.actions.some((action) => action.type === type && action.enabled) +} + +function replyText(draft: AutomationRuleDraft): string { + const action = draft.actions.find((item) => item.type === 'replyText' && item.enabled) + return action?.text?.trim() || '(未填写回复内容)' +} + +/** 左侧模拟消息的正文:`@你 今日<关键词>`。关键词为空时给一句通用占位,而不是伪造关键词。 */ +function sampleIncomingText(draft: AutomationRuleDraft): string { + const keyword = draft.conditions.keyword.trim() + const body = keyword ? `今日${keyword}` : '今天群里有什么新消息' + return draft.conditions.requireMentionMe ? `@你 ${body}` : body +} + +export function EffectPreview({ draft }: { draft: AutomationRuleDraft }): React.ReactElement { + const mentionMe = draft.conditions.requireMentionMe + const hasReply = isActionEnabled(draft, 'replyText') + const hasReport = isActionEnabled(draft, 'generateReport') + const hasSend = isActionEnabled(draft, 'sendReportImage') + const showReportImage = hasReport && hasSend + + return ( +
+
+

效果预览

+ 仅预览,不会真实发送 +
+ +
+
+ {/* 左侧:触发消息 */} +
+ +
+ {SAMPLE_SENDER_NAME} + {sampleIncomingText(draft)} +
+
+ + {mentionMe ? ( +

+ 只有当对方真正 @ 你时才会触发;正文里手打的「@昵称」不算。 +

+ ) : ( +

+ 当前不要求 @你,该会话内任何含关键词的消息都会触发。 +

+ )} + + {/* 右侧:自动回复 */} + {hasReply ? ( +
+
+ 我 + {replyText(draft)} +
+ +
+ ) : ( +

未启用「回复确认」,命中后不会先回一句。

+ )} + + {/* 右侧:日报图片 */} + {showReportImage ? ( +
+
+ 我 +
+ +
日报图片预览
+
+
+ +
+ ) : ( +

+ {!hasReport + ? '未启用「生成日报」,命中后不会产出日报。' + : '日报仍会生成,但未启用发送,不会发到群里。'} +

+ )} +
+
+ +
+
+
生效范围
+
+ {draft.conditions.conversationIds.length + ? `已选 ${draft.conditions.conversationIds.length} 个群` + : '所有群聊'} +
+
+
+
触发间隔
+
{draft.cooldownSeconds > 0 ? `${draft.cooldownSeconds} 秒` : '不限制'}
+
+
+
自己发的消息
+
{draft.conditions.ignoreSelf ? '不触发' : '也会触发'}
+
+
+
+ ) +} diff --git a/src/renderer/src/features/automation/ExecutionDetailDrawer.tsx b/src/renderer/src/features/automation/ExecutionDetailDrawer.tsx new file mode 100644 index 0000000..2585ef7 --- /dev/null +++ b/src/renderer/src/features/automation/ExecutionDetailDrawer.tsx @@ -0,0 +1,140 @@ +import * as React from 'react' +import type { AutomationExecution } from '../../../../shared/automation' +import { Button } from '../../components/ui' +import { + STEP_STATUS_LABELS, + describeSkippedStep, + formatClockTime, + formatDuration, + formatTriggerTime, + stepStatusTone +} from './model/format' + +/** + * ExecutionDetailDrawer —— 执行详情抽屉(对应 Stitch 设计稿 3 的右半部分)。 + * + * 用户来这里只想知道一件事:**到底坏在哪一步**。 + * 所以步骤列表是主体,每个失败步骤都要把原因写在脸上; + * 「已跳过」的步骤还要说明是「上一步挂了」还是「规则本来就没配」。 + * + * 抽屉而不是居中弹窗:详情是「从列表里钻进去看」,右侧抽屉保留了列表的位置感。 + */ + +export interface ExecutionDetailDrawerProps { + execution: AutomationExecution | null + onClose: () => void +} + +export function ExecutionDetailDrawer({ + execution, + onClose +}: ExecutionDetailDrawerProps): React.ReactElement | null { + const open = execution !== null + + React.useEffect(() => { + if (!open) return + const handleKeyDown = (event: KeyboardEvent): void => { + if (event.key === 'Escape') onClose() + } + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [open, onClose]) + + if (!execution) return null + + return ( +
+ +
+ ) +} diff --git a/src/renderer/src/features/automation/ExecutionLogPanel.tsx b/src/renderer/src/features/automation/ExecutionLogPanel.tsx new file mode 100644 index 0000000..2cd5bf5 --- /dev/null +++ b/src/renderer/src/features/automation/ExecutionLogPanel.tsx @@ -0,0 +1,123 @@ +import * as React from 'react' +import type { AutomationExecution } from '../../../../shared/automation' +import { Button, Spinner } from '../../components/ui' +import { STEP_STATUS_LABELS, formatDuration, formatTriggerTime, stepStatusTone } from './model/format' + +/** + * ExecutionLogPanel —— 执行日志列表(对应 Stitch 设计稿 3 的左半部分)。 + * + * 每行是一条**用户层**记录:时间 / 规则名 / 来源 / 结果 / 耗时 / 步骤轨迹。 + * + * 刻意不展示 wxid、localId、serverId、source XML、原始 payload 与图片路径 —— + * `AutomationExecution` 里本来就不带这些字段,这里也不允许从别处补。 + */ + +export interface ExecutionLogPanelProps { + executions: AutomationExecution[] + loading: boolean + clearing: boolean + onSelect: (execution: AutomationExecution) => void + onClear: () => void +} + +/** 把 5 个步骤压成一串小圆点,一眼能看出「卡在哪一步」。 */ +function StepTrail({ execution }: { execution: AutomationExecution }): React.ReactElement { + if (!execution.steps.length) { + return — + } + return ( + + {execution.steps.map((step, index) => ( + + {index > 0 ? + ))} + + ) +} + +export function ExecutionLogPanel({ + executions, + loading, + clearing, + onSelect, + onClear +}: ExecutionLogPanelProps): React.ReactElement { + return ( +
+
+

执行日志

+
+ 最近 {executions.length} 条 + +
+
+ + {loading ? ( +
+ + 正在读取执行记录… +
+ ) : executions.length === 0 ? ( +
+

暂无执行记录。

+ 规则命中并开始执行后,这里会留下完整的步骤轨迹。 +
+ ) : ( +
+
+ 时间 + 规则 + 来源 + 结果 + 耗时 + 步骤轨迹 + +
+ {executions.map((execution) => ( +
+ + {formatTriggerTime(execution.triggerTime)} + + + {execution.ruleName} + + + {execution.sourceDisplayName} + + + + {execution.status === 'success' ? '成功' : '失败'} + + + + {formatDuration(execution.durationMs)} + + + + + + + +
+ ))} +
+ )} +
+ ) +} diff --git a/src/renderer/src/features/automation/RuleEditorPanel.tsx b/src/renderer/src/features/automation/RuleEditorPanel.tsx new file mode 100644 index 0000000..5e66126 --- /dev/null +++ b/src/renderer/src/features/automation/RuleEditorPanel.tsx @@ -0,0 +1,346 @@ +import * as React from 'react' +import { + DEFAULT_REPLY_TEXT, + KEYWORD_MATCH_MODE_LABELS, + createDefaultDailyReportRule, + normalizeRuleDraft, + type AutomationActionType, + type AutomationRule, + type AutomationRuleDraft, + type KeywordMatchMode +} from '../../../../shared/automation' +import { Button, Input, SegmentedControl, SegmentedControlItem, Switch } from '../../components/ui' +import { EffectPreview } from './EffectPreview' +import type { AutomationGroupOption } from './model/api' + +/** + * RuleEditorPanel —— 规则编辑页(对应 Stitch 设计稿 1)。 + * + * 结构固定五段:什么时候触发 → 在哪些聊天生效 → 触发后执行 → 高级设置 → 效果预览。 + * + * 刻意**不出现**工程词:没有 dedup / idempotency / watermark / regex, + * 用户看到的只有「同一消息只执行一次」这类人话。 + */ + +const KEYWORD_MODES: KeywordMatchMode[] = ['contains', 'exact', 'prefix'] + +const ACTION_META: Array<{ + type: AutomationActionType + title: string + description: string +}> = [ + { + type: 'replyText', + title: '回复确认', + description: '先在群里回一句,告诉对方已经收到' + }, + { + type: 'generateReport', + title: '生成日报', + description: '用当前群的今日消息生成日报' + }, + { + type: 'sendReportImage', + title: '发送日报图片', + description: '把生成的日报图片发回触发消息所在的群' + } +] + +function draftFromRule(rule: AutomationRule | null): AutomationRuleDraft { + const base = rule ?? createDefaultDailyReportRule(Date.now()) + return normalizeRuleDraft({ + name: base.name, + enabled: base.enabled, + scope: base.scope, + conditions: base.conditions, + actions: base.actions, + cooldownSeconds: base.cooldownSeconds + }) +} + +export interface RuleEditorPanelProps { + mode: 'create' | 'edit' + rule: AutomationRule | null + groups: AutomationGroupOption[] + saving: boolean + onCancel: () => void + onSave: (draft: AutomationRuleDraft) => void +} + +export function RuleEditorPanel({ + mode, + rule, + groups, + saving, + onCancel, + onSave +}: RuleEditorPanelProps): React.ReactElement { + const [draft, setDraft] = React.useState(() => draftFromRule(rule)) + const [groupFilter, setGroupFilter] = React.useState('') + const [validationError, setValidationError] = React.useState('') + + // 切换编辑对象时重建草稿,避免把上一条规则的修改带过去。 + React.useEffect(() => { + setDraft(draftFromRule(rule)) + setValidationError('') + }, [rule]) + + const patchConditions = (patch: Partial): void => { + setDraft((current) => ({ ...current, conditions: { ...current.conditions, ...patch } })) + } + + const patchAction = (type: AutomationActionType, patch: { enabled?: boolean; text?: string }): void => { + setDraft((current) => ({ + ...current, + actions: current.actions.map((action) => + action.type === type ? { ...action, ...patch } : action + ) + })) + } + + const actionOf = (type: AutomationActionType): { enabled: boolean; text?: string } => + draft.actions.find((action) => action.type === type) ?? { enabled: false } + + const toggleGroup = (id: string): void => { + setDraft((current) => { + const selected = new Set(current.conditions.conversationIds) + if (selected.has(id)) selected.delete(id) + else selected.add(id) + return { + ...current, + conditions: { ...current.conditions, conversationIds: Array.from(selected) } + } + }) + } + + const visibleGroups = React.useMemo(() => { + const needle = groupFilter.trim().toLowerCase() + if (!needle) return groups + return groups.filter( + (group) => + group.name.toLowerCase().includes(needle) || group.id.toLowerCase().includes(needle) + ) + }, [groups, groupFilter]) + + const handleSave = (): void => { + if (!draft.name.trim()) { + setValidationError('请填写自动化名称') + return + } + if (!draft.actions.some((action) => action.enabled)) { + setValidationError('至少要启用一个执行动作,否则规则命中后什么也不会发生') + return + } + setValidationError('') + onSave(draft) + } + + return ( +
+
+
+

{mode === 'create' ? '新建自动化' : '编辑自动化'}

+

命中条件后,TraceMemo 会按下面的顺序依次执行。

+
+
+ + +
+
+ + {validationError ?

{validationError}

: null} + +
+
+
+
+

1 · 什么时候触发

+
+
+ + + setDraft((current) => ({ ...current, name: event.target.value })) + } + placeholder="给这条自动化起个名字" + /> +
+
+ 触发方式 + 收到消息 +
+
+
+ 必须真正 @我 + 根据微信底层 @ 元数据判断,不依赖普通昵称纯文本 +
+ patchConditions({ requireMentionMe: checked })} + /> +
+
+
+ 启用这条自动化 + 关闭后规则会保留,但不会触发 +
+ + setDraft((current) => ({ ...current, enabled: checked })) + } + /> +
+
+ + patchConditions({ keyword: event.target.value })} + placeholder="例如:日报(留空表示不限关键词)" + /> +
+
+ 匹配方式 + + patchConditions({ keywordMatchMode: value as KeywordMatchMode }) + } + aria-label="关键词匹配方式" + > + {KEYWORD_MODES.map((mode) => ( + + {KEYWORD_MATCH_MODE_LABELS[mode]} + + ))} + +
+
+ +
+
+

2 · 在哪些聊天生效

+ + {draft.conditions.conversationIds.length + ? `已选 ${draft.conditions.conversationIds.length} 个群` + : '未选择时对所有群聊生效'} + +
+ setGroupFilter(event.target.value)} + placeholder="搜索群聊" + aria-label="搜索群聊" + /> +
+ {groups.length === 0 ? ( +

+ 还没有读取到群聊。请先在「档案」中连接并解锁微信数据库。 +

+ ) : visibleGroups.length === 0 ? ( +

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

+ ) : ( + visibleGroups.map((group) => { + const checked = draft.conditions.conversationIds.includes(group.id) + return ( + + ) + }) + )} +
+
+ +
+
+

3 · 触发后执行

+ 按下列顺序执行 +
+ {ACTION_META.map((meta) => { + const action = actionOf(meta.type) + return ( +
+
+
+
+ {meta.title} + {meta.description} +
+ patchAction(meta.type, { enabled: checked })} + /> +
+ {meta.type === 'replyText' && action.enabled ? ( + patchAction(meta.type, { text: event.target.value })} + placeholder={DEFAULT_REPLY_TEXT} + aria-label="回复内容" + /> + ) : null} +
+
+ ) + })} +
+ +
+
+

4 · 高级设置

+
+
+
+ 触发间隔(秒) + 同一个群里,两次触发之间至少间隔这么久,避免刷屏 +
+ { + const next = Number(event.target.value) + setDraft((current) => ({ + ...current, + cooldownSeconds: Number.isFinite(next) ? Math.max(0, Math.floor(next)) : 0 + })) + }} + className="automation-number-input" + aria-label="触发间隔秒数" + /> +
+
+
+ 忽略自己发送的消息 + + {draft.conditions.ignoreSelf + ? '推荐保持开启,可避免自动回复被再次触发' + : '关闭后可能形成自动回复循环,请确认你知道后果'} + +
+ patchConditions({ ignoreSelf: checked })} + /> +
+

同一消息只执行一次。

+
+
+ + +
+
+ ) +} diff --git a/src/renderer/src/features/automation/RuleListPanel.tsx b/src/renderer/src/features/automation/RuleListPanel.tsx new file mode 100644 index 0000000..37e6192 --- /dev/null +++ b/src/renderer/src/features/automation/RuleListPanel.tsx @@ -0,0 +1,146 @@ +import * as React from 'react' +import { + AUTOMATION_RULE_TEMPLATES, + describeRuleActions, + describeRuleTrigger, + type AutomationRule +} from '../../../../shared/automation' +import { Button, Spinner, Switch } from '../../components/ui' + +/** + * RuleListPanel —— 规则列表(对应 Stitch 设计稿 2 的「规则」tab)。 + * + * 两个区块: + * 1. **已配置的规则** —— 真实可运行,带启停开关; + * 2. **模板入口** —— 尚未接通的能力必须**如实标注**为「即将支持」。 + * 这里最容易犯的错是把设计稿里的卡片全渲染成"已启用"的样子, + * 用户点进去发现是空的 —— 那比拼不出这个功能更糟。 + */ + +export interface RuleListPanelProps { + rules: AutomationRule[] + groups: Array<{ id: string; name: string }> + loading: boolean + busyRuleId: string | null + onToggle: (rule: AutomationRule, enabled: boolean) => void + onEdit: (rule: AutomationRule) => void + onDelete: (rule: AutomationRule) => void + onCreate: () => void +} + +/** 把规则里的 conversationIds 翻成群名;找不到时显示占位而不是裸 id。 */ +function scopeLabel(rule: AutomationRule, groups: Array<{ id: string; name: string }>): string { + const selected = rule.conditions.conversationIds + if (!selected.length) return '所有群聊' + const names = selected + .map((id) => groups.find((group) => group.id === id)?.name) + .filter((name): name is string => Boolean(name)) + if (!names.length) return `已选 ${selected.length} 个群` + if (names.length <= 2) return names.join('、') + return `${names.slice(0, 2).join('、')} 等 ${names.length} 个群` +} + +export function RuleListPanel({ + rules, + groups, + loading, + busyRuleId, + onToggle, + onEdit, + onDelete, + onCreate +}: RuleListPanelProps): React.ReactElement { + return ( +
+
+
+

已配置的自动化

+ {rules.length} 条 +
+ + {loading ? ( +
+ + 正在读取规则… +
+ ) : rules.length === 0 ? ( +
+

还没有任何自动化。

+ +
+ ) : ( + rules.map((rule) => ( +
+
+
+

{rule.name}

+ + {rule.enabled ? '运行中' : '已停用'} + +
+
+
+
触发
+
{describeRuleTrigger(rule)}
+
+
+
范围
+
{scopeLabel(rule, groups)}
+
+
+
动作
+
{describeRuleActions(rule)}
+
+
+
间隔
+
{rule.cooldownSeconds > 0 ? `${rule.cooldownSeconds} 秒` : '不限制'}
+
+
+
+
+ onToggle(rule, checked)} + aria-label={`${rule.name} 启停`} + /> + + +
+
+ )) + )} +
+ +
+
+

更多模板

+ 后续版本开放 +
+
+ {AUTOMATION_RULE_TEMPLATES.map((template) => ( +
+
+

{template.name}

+ 即将支持 +
+

{template.description}

+ {template.unavailableReason ? ( + {template.unavailableReason} + ) : null} +
+ ))} +
+
+
+ ) +} diff --git a/src/renderer/src/features/automation/model/api.ts b/src/renderer/src/features/automation/model/api.ts new file mode 100644 index 0000000..2fe2910 --- /dev/null +++ b/src/renderer/src/features/automation/model/api.ts @@ -0,0 +1,95 @@ +import type { + AutomationExecution, + AutomationRule, + AutomationRuleDraft, + AutomationStatusSummary +} from '../../../../../shared/automation' + +/** + * `window.api` 的薄封装。 + * + * 存在的理由:preload 暴露的方法在「数据库还没解锁」「preload 与渲染层版本不一致」 + * 等情况下可能缺失。直接 `window.api.getAutomationStatus()` 会抛 + * `not a function`,整个页面白屏 —— 用户看到的就是一个坏掉的菜单。 + * 这里统一降级,让页面始终能渲染出一个**如实说明自己不可用**的状态。 + * + * 只读查询失败 → 返回安全默认值;写操作失败 → 返回 `null` / `false`, + * 由调用方决定提示文案(**绝不**假装成功)。 + */ + +export interface AutomationGroupOption { + id: string + name: string +} + +export const UNAVAILABLE_STATUS: AutomationStatusSummary = { + listening: false, + listeningDegraded: true, + todayExecutions: 0, + todaySuccesses: 0, + sendCapability: { + supported: false, + ready: false, + canSendText: false, + canSendImage: false, + message: '自动化能力尚未就绪' + } +} + +function apiTarget(): Record | undefined { + if (typeof window === 'undefined') return undefined + const target = window.api as unknown as Record | undefined + return target ?? undefined +} + +/** + * 调用一个可能不存在的 preload 方法。 + * + * - 方法缺失 / 抛异常 / reject / 返回空值 → 一律返回 `fallback`。 + * - 刻意不区分「方法不存在」与「调用失败」:对界面而言,两者都只是「暂时拿不到」。 + */ +function invoke(name: string, fallback: T, ...args: unknown[]): Promise { + const target = apiTarget() + const fn = target?.[name] + if (typeof fn !== 'function') return Promise.resolve(fallback) + try { + const result = (fn as (...input: unknown[]) => Promise).apply(target, args) + if (!result || typeof result.then !== 'function') return Promise.resolve(fallback) + return result.then( + (value) => (value === undefined || value === null ? fallback : value), + () => fallback + ) + } catch { + return Promise.resolve(fallback) + } +} + +/** 自动化接口是否可用(preload 未更新时为 false)。 */ +export function isAutomationApiAvailable(): boolean { + return typeof apiTarget()?.getAutomationStatus === 'function' +} + +export const automationApi = { + getStatus: (): Promise => + invoke('getAutomationStatus', UNAVAILABLE_STATUS), + + listRules: (): Promise => invoke('listAutomationRules', []), + + createRule: (draft: AutomationRuleDraft): Promise => + invoke('createAutomationRule', null, draft), + + updateRule: (id: string, draft: AutomationRuleDraft): Promise => + invoke('updateAutomationRule', null, id, draft), + + deleteRule: (id: string): Promise => invoke('deleteAutomationRule', false, id), + + setRuleEnabled: (id: string, enabled: boolean): Promise => + invoke('setAutomationRuleEnabled', null, id, enabled), + + listExecutions: (limit = 100): Promise => + invoke('listAutomationExecutions', [], { limit }), + + clearExecutions: (): Promise => invoke('clearAutomationExecutions', false), + + listGroups: (): Promise => invoke('listAutomationGroups', []) +} diff --git a/src/renderer/src/features/automation/model/format.ts b/src/renderer/src/features/automation/model/format.ts new file mode 100644 index 0000000..8375312 --- /dev/null +++ b/src/renderer/src/features/automation/model/format.ts @@ -0,0 +1,65 @@ +import type { AutomationStep, AutomationStepStatus } from '../../../../../shared/automation' + +/** 时间 / 耗时的展示格式化。全部是纯函数,便于单测。 */ + +const pad = (value: number): string => String(value).padStart(2, '0') + +/** `2026-09-20 21:03` —— 执行日志列表用,够精确又不啰嗦。 */ +export function formatTriggerTime(timestamp: number): string { + if (!timestamp) return '—' + const date = new Date(timestamp) + return ( + `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ` + + `${pad(date.getHours())}:${pad(date.getMinutes())}` + ) +} + +/** `21:03:47` —— 详情抽屉用,需要到秒才能看清步骤先后。 */ +export function formatClockTime(timestamp?: number): string { + if (!timestamp) return '—' + const date = new Date(timestamp) + return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}` +} + +/** 耗时:低于 1 秒显示毫秒,否则显示秒,避免「1200ms」这种要心算的写法。 */ +export function formatDuration(durationMs?: number): string { + if (!Number.isFinite(durationMs) || (durationMs ?? 0) < 0) return '—' + const value = durationMs as number + if (value < 1000) return `${Math.round(value)} ms` + if (value < 60_000) return `${(value / 1000).toFixed(1)} 秒` + return `${Math.floor(value / 60_000)} 分 ${Math.round((value % 60_000) / 1000)} 秒` +} + +export const STEP_STATUS_LABELS: Record = { + pending: '等待中', + running: '进行中', + success: '已完成', + failed: '失败', + skipped: '已跳过' +} + +/** 步骤圆点的语义色,供 SCSS 选择器使用。 */ +export function stepStatusTone(status: AutomationStepStatus): string { + if (status === 'success') return 'success' + if (status === 'failed') return 'failed' + if (status === 'skipped') return 'skipped' + return 'pending' +} + +/** + * `skipped` 步骤的说明。 + * + * 用户最容易困惑的就是「为什么这一步没跑」—— 必须区分 + * 「规则本来就没配这个动作」和「上一步挂了所以跳过」。 + */ +export function describeSkippedStep(step: AutomationStep, steps: AutomationStep[]): string { + const index = steps.findIndex((item) => item.key === step.key) + const blockedByFailure = steps + .slice(0, index < 0 ? 0 : index) + .some((item) => item.status === 'failed') + if (blockedByFailure) return '上一步失败,已跳过' + if (step.key === 'reply') return '规则未启用「回复确认」' + if (step.key === 'report') return '规则未启用「生成日报」' + if (step.key === 'send') return '没有可发送的日报图片' + return '未执行' +} diff --git a/src/renderer/src/styles/automation.scss b/src/renderer/src/styles/automation.scss new file mode 100644 index 0000000..6cfc450 --- /dev/null +++ b/src/renderer/src/styles/automation.scss @@ -0,0 +1,1161 @@ +// 自动化一级菜单。 +// +// 颜色一律走 --wxex-* 语义 token(它们已经映射到 --tm-*,会随主题切换)。 +// 刻意**不**把设计稿里的 #247a63 写死:那个值在深色主题下对比度不够, +// 而且会和项目既有的 brand token 打架。 + +.automation-page { + box-sizing: border-box; + display: flex; + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; + flex-direction: column; + gap: 22px; + overflow: auto; + padding: 34px clamp(22px, 4vw, 64px) 48px; + background: var(--wxex-bg-main); + color: var(--wxex-text-primary); +} + +.automation-page-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 24px; + flex-wrap: wrap; + + h1 { + margin: 0; + font-size: 26px; + line-height: 34px; + font-weight: 700; + } + + p { + margin: 6px 0 0; + max-width: 62ch; + color: var(--wxex-text-secondary); + font-size: 13px; + line-height: 20px; + } +} + +.automation-notice { + margin: 0; + padding: 10px 14px; + border-radius: var(--wxex-radius-md); + border: 1px solid var(--wxex-border); + background: var(--wxex-bg-elevated); + color: var(--wxex-text-secondary); + font-size: 12px; + line-height: 18px; + + &.warning { + border-color: color-mix(in srgb, var(--wxex-warning) 45%, transparent); + color: var(--wxex-text-primary); + } +} + +// --------------------------------------------------------------------------- +// 顶部状态条 +// --------------------------------------------------------------------------- + +.automation-status-bar { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 14px; +} + +.automation-status-card { + box-sizing: border-box; + display: flex; + min-width: 0; + flex-direction: column; + gap: 6px; + padding: 16px 18px; + border: 1px solid var(--wxex-border); + border-radius: var(--wxex-radius-md); + background: var(--wxex-bg-elevated); +} + +.automation-status-label { + color: var(--wxex-text-muted); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.automation-status-value { + color: var(--wxex-text-primary); + font-size: 16px; + font-weight: 700; + line-height: 22px; + overflow-wrap: anywhere; + + &.ok { + color: var(--wxex-success); + } + + &.warn { + color: var(--wxex-warning); + } + + &.off { + color: var(--wxex-text-secondary); + } +} + +.automation-status-note { + color: var(--wxex-text-muted); + font-size: 12px; + line-height: 17px; + overflow-wrap: anywhere; + text-align: left; +} + +// --------------------------------------------------------------------------- +// 分区通用 +// --------------------------------------------------------------------------- + +.automation-section-heading { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; + + h2 { + margin: 0; + font-size: 18px; + line-height: 26px; + font-weight: 700; + } + + h3 { + margin: 0; + font-size: 15px; + line-height: 22px; + font-weight: 700; + } +} + +.automation-section-note { + color: var(--wxex-text-muted); + font-size: 12px; +} + +.automation-loading { + display: flex; + align-items: center; + gap: 10px; + padding: 32px 0; + color: var(--wxex-text-secondary); + font-size: 13px; +} + +.automation-empty-card { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + padding: 40px 24px; + border: 1px dashed var(--wxex-border); + border-radius: var(--wxex-radius-md); + color: var(--wxex-text-secondary); + text-align: center; + + p { + margin: 0; + font-size: 13px; + } + + small { + color: var(--wxex-text-muted); + font-size: 12px; + } +} + +// --------------------------------------------------------------------------- +// 规则列表 +// --------------------------------------------------------------------------- + +.automation-rule-list { + display: flex; + min-width: 0; + flex-direction: column; + gap: 28px; +} + +.automation-rule-group { + display: flex; + min-width: 0; + flex-direction: column; + gap: 14px; +} + +.automation-rule-card { + box-sizing: border-box; + display: flex; + min-width: 0; + align-items: flex-start; + justify-content: space-between; + gap: 20px; + padding: 18px 20px; + border: 1px solid var(--wxex-border); + border-radius: var(--wxex-radius-md); + background: var(--wxex-bg-elevated); + + &.disabled { + background: var(--wxex-bg-app); + opacity: 0.72; + } +} + +.automation-rule-card-main { + display: flex; + min-width: 0; + flex: 1 1 auto; + flex-direction: column; + gap: 12px; +} + +.automation-rule-card-title { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + + h3 { + margin: 0; + font-size: 15px; + line-height: 22px; + font-weight: 700; + overflow-wrap: anywhere; + } +} + +.automation-rule-state { + padding: 2px 8px; + border-radius: 999px; + font-size: 11px; + font-weight: 600; + + &.on { + background: color-mix(in srgb, var(--wxex-success) 16%, transparent); + color: var(--wxex-success); + } + + &.off { + background: color-mix(in srgb, var(--wxex-text-muted) 18%, transparent); + color: var(--wxex-text-secondary); + } +} + +.automation-rule-meta { + display: grid; + min-width: 0; + gap: 8px 22px; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + margin: 0; + + div { + min-width: 0; + } + + dt { + color: var(--wxex-text-muted); + font-size: 11px; + font-weight: 600; + } + + dd { + margin: 2px 0 0; + color: var(--wxex-text-primary); + font-size: 13px; + overflow-wrap: anywhere; + } +} + +.automation-rule-card-side { + display: flex; + flex: 0 0 auto; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} + +.automation-template-grid { + display: grid; + gap: 14px; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); +} + +.automation-template-card { + box-sizing: border-box; + min-width: 0; + padding: 16px 18px; + border: 1px dashed var(--wxex-border); + border-radius: var(--wxex-radius-md); + background: var(--wxex-bg-app); + + h3 { + margin: 0; + font-size: 14px; + font-weight: 700; + } + + p { + margin: 6px 0 0; + color: var(--wxex-text-secondary); + font-size: 12px; + line-height: 18px; + overflow-wrap: anywhere; + } + + small { + display: block; + margin-top: 8px; + color: var(--wxex-text-muted); + font-size: 11px; + line-height: 16px; + overflow-wrap: anywhere; + } +} + +.automation-template-title { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + flex-wrap: wrap; +} + +.automation-template-flag { + padding: 2px 8px; + border-radius: 999px; + background: color-mix(in srgb, var(--wxex-text-muted) 18%, transparent); + color: var(--wxex-text-secondary); + font-size: 11px; + font-weight: 600; +} + +// --------------------------------------------------------------------------- +// 规则编辑 +// --------------------------------------------------------------------------- + +.automation-editor { + display: flex; + min-width: 0; + flex-direction: column; + gap: 20px; +} + +.automation-editor-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 20px; + flex-wrap: wrap; + + h2 { + margin: 0; + font-size: 20px; + line-height: 28px; + font-weight: 700; + } + + p { + margin: 4px 0 0; + color: var(--wxex-text-secondary); + font-size: 13px; + } +} + +.automation-editor-actions { + display: flex; + gap: 8px; +} + +.automation-form-error { + margin: 0; + padding: 10px 14px; + border-radius: var(--wxex-radius-md); + border: 1px solid color-mix(in srgb, var(--wxex-danger) 45%, transparent); + color: var(--wxex-danger); + font-size: 12px; +} + +.automation-editor-body { + display: grid; + min-width: 0; + gap: 22px; + // 表单为主体,预览固定宽度;窄屏时自动堆叠,绝不出横向滚动条。 + grid-template-columns: minmax(0, 1fr) minmax(260px, 340px); + align-items: start; + + @media (max-width: 1080px) { + grid-template-columns: minmax(0, 1fr); + } +} + +.automation-editor-form { + display: flex; + min-width: 0; + flex-direction: column; + gap: 18px; +} + +.automation-section { + display: flex; + min-width: 0; + flex-direction: column; + gap: 12px; + padding: 18px 20px; + border: 1px solid var(--wxex-border); + border-radius: var(--wxex-radius-md); + background: var(--wxex-bg-elevated); +} + +.automation-field { + display: flex; + min-width: 0; + flex-direction: column; + gap: 6px; + + label, + .automation-field-label { + color: var(--wxex-text-secondary); + font-size: 12px; + font-weight: 600; + } +} + +.automation-switch-row { + display: flex; + min-width: 0; + align-items: center; + justify-content: space-between; + gap: 18px; + + > div { + display: flex; + min-width: 0; + flex-direction: column; + gap: 2px; + } + + small { + color: var(--wxex-text-muted); + font-size: 12px; + line-height: 17px; + overflow-wrap: anywhere; + } + + &.compact { + gap: 14px; + } +} + +.automation-inline-row { + display: flex; + min-width: 0; + align-items: center; + justify-content: space-between; + gap: 16px; + + > div { + display: flex; + min-width: 0; + flex-direction: column; + gap: 2px; + } + + small { + color: var(--wxex-text-muted); + font-size: 12px; + line-height: 17px; + overflow-wrap: anywhere; + } +} + +.automation-static-value { + color: var(--wxex-text-primary); + font-size: 13px; + font-weight: 600; +} + +.automation-static-note { + margin: 0; + color: var(--wxex-text-muted); + font-size: 12px; +} + +.automation-number-input { + width: 110px; + flex: 0 0 auto; +} + +.automation-group-list { + display: flex; + max-height: 240px; + flex-direction: column; + gap: 4px; + overflow-y: auto; + padding: 6px; + border: 1px solid var(--wxex-border); + border-radius: var(--wxex-radius-sm); + background: var(--wxex-bg-app); +} + +.automation-group-option { + display: flex; + min-width: 0; + align-items: center; + gap: 10px; + padding: 7px 10px; + border-radius: var(--wxex-radius-sm); + color: var(--wxex-text-primary); + font-size: 13px; + cursor: pointer; + + span { + min-width: 0; + overflow-wrap: anywhere; + } + + &:hover { + background: var(--wxex-bg-elevated); + } + + &.selected { + background: var(--wxex-brand-soft); + font-weight: 600; + } +} + +.automation-group-empty { + margin: 0; + padding: 14px 10px; + color: var(--wxex-text-muted); + font-size: 12px; + line-height: 18px; + text-align: center; +} + +.automation-action-row { + display: flex; + min-width: 0; + padding: 14px 16px; + border: 1px solid var(--wxex-border); + border-radius: var(--wxex-radius-sm); + background: var(--wxex-bg-app); +} + +.automation-action-main { + display: flex; + min-width: 0; + flex: 1 1 auto; + flex-direction: column; + gap: 10px; +} + +// --------------------------------------------------------------------------- +// 效果模拟预览 +// --------------------------------------------------------------------------- + +.automation-preview { + display: flex; + min-width: 0; + flex-direction: column; + gap: 12px; + padding: 18px 20px; + border: 1px solid var(--wxex-border); + border-radius: var(--wxex-radius-md); + background: var(--wxex-bg-elevated); + + @media (min-width: 1081px) { + position: sticky; + top: 0; + } +} + +.automation-preview-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; + + h3 { + margin: 0; + font-size: 15px; + font-weight: 700; + } +} + +.automation-preview-badge { + padding: 2px 8px; + border-radius: 999px; + background: color-mix(in srgb, var(--wxex-ai) 16%, transparent); + color: var(--wxex-ai); + font-size: 11px; + font-weight: 600; +} + +.automation-preview-phone { + padding: 14px; + border-radius: var(--wxex-radius-md); + background: var(--wxex-bg-app); +} + +.automation-preview-chat { + display: flex; + min-width: 0; + flex-direction: column; + gap: 12px; +} + +.automation-chat-row { + display: flex; + min-width: 0; + align-items: flex-start; + gap: 10px; + + &.incoming { + justify-content: flex-start; + } + + &.outgoing { + justify-content: flex-end; + } +} + +.automation-chat-avatar { + display: flex; + width: 30px; + height: 30px; + flex: 0 0 auto; + align-items: center; + justify-content: center; + border-radius: var(--wxex-radius-sm); + background: var(--wxex-brand-soft); + color: var(--wxex-brand); + font-size: 12px; + font-weight: 700; + + &.self { + background: var(--wxex-brand); + color: #fff; + } +} + +.automation-chat-body { + display: flex; + min-width: 0; + max-width: 78%; + flex-direction: column; + gap: 4px; +} + +.automation-chat-name { + color: var(--wxex-text-muted); + font-size: 11px; +} + +.automation-chat-bubble { + padding: 8px 12px; + border-radius: var(--wxex-radius-md); + background: var(--wxex-bg-elevated); + border: 1px solid var(--wxex-border); + color: var(--wxex-text-primary); + font-size: 13px; + line-height: 19px; + overflow-wrap: anywhere; +} + +.automation-chat-row.outgoing .automation-chat-bubble { + background: var(--wxex-brand); + border-color: transparent; + color: #fff; +} + +.automation-chat-image { + display: flex; + margin: 0; + flex-direction: column; + gap: 6px; + padding: 10px; + border: 1px solid var(--wxex-border); + border-radius: var(--wxex-radius-md); + background: var(--wxex-bg-elevated); + + figcaption { + color: var(--wxex-text-muted); + font-size: 11px; + text-align: center; + } +} + +.automation-chat-image-canvas { + display: flex; + width: 180px; + max-width: 100%; + flex-direction: column; + gap: 6px; + padding: 12px; + border-radius: var(--wxex-radius-sm); + background: linear-gradient( + 135deg, + var(--wxex-bg-app), + color-mix(in srgb, var(--wxex-brand-soft) 70%, var(--wxex-bg-app)) + ); +} + +.automation-chat-image-title { + color: var(--wxex-text-primary); + font-size: 12px; + font-weight: 700; +} + +.automation-chat-image-line { + height: 6px; + border-radius: 999px; + background: color-mix(in srgb, var(--wxex-text-muted) 25%, transparent); + + &.short { + width: 60%; + } +} + +.automation-preview-hint { + margin: 0; + color: var(--wxex-text-secondary); + font-size: 11px; + line-height: 16px; + overflow-wrap: anywhere; + + &.muted { + color: var(--wxex-text-muted); + } +} + +.automation-preview-facts { + display: grid; + gap: 10px; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + margin: 0; + padding-top: 12px; + border-top: 1px solid var(--wxex-border); + + dt { + color: var(--wxex-text-muted); + font-size: 11px; + font-weight: 600; + } + + dd { + margin: 2px 0 0; + color: var(--wxex-text-primary); + font-size: 12px; + overflow-wrap: anywhere; + } +} + +// --------------------------------------------------------------------------- +// 执行日志 +// --------------------------------------------------------------------------- + +.automation-log-panel { + display: flex; + min-width: 0; + flex-direction: column; + gap: 14px; +} + +.automation-log-heading-actions { + display: flex; + align-items: center; + gap: 12px; +} + +.automation-log-table { + display: flex; + min-width: 0; + flex-direction: column; + border: 1px solid var(--wxex-border); + border-radius: var(--wxex-radius-md); + background: var(--wxex-bg-elevated); + overflow: hidden; +} + +.automation-log-row { + display: grid; + min-width: 0; + // 所有列都用 minmax(0, …):默认的 min-content 下限会让长群名把表格撑出横向滚动条。 + grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr) minmax(0, 0.9fr) minmax(0, 0.6fr) minmax(0, 0.6fr) minmax(0, 1.3fr) auto; + align-items: center; + gap: 12px; + padding: 11px 16px; + border-top: 1px solid var(--wxex-border); + color: var(--wxex-text-primary); + font-size: 13px; + + > span { + min-width: 0; + overflow-wrap: anywhere; + } + + &.head { + border-top: 0; + background: var(--wxex-bg-app); + color: var(--wxex-text-muted); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.03em; + text-transform: uppercase; + } + + &:not(.head):hover { + background: var(--wxex-bg-app); + } + + @media (max-width: 1080px) { + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + row-gap: 8px; + + &.head { + display: none; + } + } +} + +.automation-log-time, +.automation-log-duration { + color: var(--wxex-text-secondary); + font-variant-numeric: tabular-nums; +} + +.automation-log-action { + justify-self: end; +} + +.automation-status-chip { + display: inline-block; + padding: 2px 9px; + border-radius: 999px; + font-size: 11px; + font-weight: 600; + white-space: nowrap; + + &.success { + background: color-mix(in srgb, var(--wxex-success) 16%, transparent); + color: var(--wxex-success); + } + + &.failed, + &.running { + background: color-mix(in srgb, var(--wxex-danger) 16%, transparent); + color: var(--wxex-danger); + } +} + +.automation-trail { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.automation-trail-link { + width: 8px; + height: 1px; + background: var(--wxex-border); +} + +.automation-trail-dot { + width: 9px; + height: 9px; + border-radius: 999px; + background: var(--wxex-text-muted); + + &.success { + background: var(--wxex-success); + } + + &.failed { + background: var(--wxex-danger); + } + + &.skipped { + background: transparent; + box-shadow: inset 0 0 0 1.5px var(--wxex-border); + } + + &.pending { + background: color-mix(in srgb, var(--wxex-text-muted) 50%, transparent); + } +} + +.automation-trail-empty { + color: var(--wxex-text-muted); +} + +// --------------------------------------------------------------------------- +// 执行详情抽屉 +// --------------------------------------------------------------------------- + +.automation-drawer-layer { + position: fixed; + inset: 0; + z-index: 60; + display: flex; + justify-content: flex-end; + background: color-mix(in srgb, #0f172a 45%, transparent); +} + +.automation-drawer { + box-sizing: border-box; + display: flex; + width: min(480px, 92vw); + height: 100%; + min-width: 0; + flex-direction: column; + gap: 18px; + padding: 24px 24px 28px; + border-left: 1px solid var(--wxex-border); + background: var(--wxex-bg-elevated); + color: var(--wxex-text-primary); + box-shadow: var(--wxex-shadow-popover); +} + +.automation-drawer-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + + h2 { + margin: 2px 0 0; + font-size: 18px; + line-height: 26px; + font-weight: 700; + overflow-wrap: anywhere; + } +} + +.automation-drawer-eyebrow { + color: var(--wxex-text-muted); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.automation-drawer-body { + display: flex; + min-width: 0; + flex-direction: column; + gap: 18px; + overflow-y: auto; +} + +.automation-detail-facts { + display: grid; + gap: 12px; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + margin: 0; + + dt { + color: var(--wxex-text-muted); + font-size: 11px; + font-weight: 600; + } + + dd { + margin: 2px 0 0; + color: var(--wxex-text-primary); + font-size: 13px; + overflow-wrap: anywhere; + } +} + +.automation-detail-error { + margin: 0; + padding: 12px 14px; + border-radius: var(--wxex-radius-md); + border: 1px solid color-mix(in srgb, var(--wxex-danger) 45%, transparent); + background: color-mix(in srgb, var(--wxex-danger) 8%, transparent); + color: var(--wxex-text-primary); + font-size: 12px; + line-height: 18px; + overflow-wrap: anywhere; +} + +.automation-detail-steps { + display: flex; + min-width: 0; + flex-direction: column; + gap: 12px; + + h3 { + margin: 0; + font-size: 14px; + font-weight: 700; + } +} + +.automation-detail-empty { + margin: 0; + color: var(--wxex-text-muted); + font-size: 12px; + line-height: 18px; +} + +.automation-step-list { + display: flex; + margin: 0; + padding: 0; + flex-direction: column; + gap: 14px; + list-style: none; +} + +.automation-step { + display: grid; + min-width: 0; + gap: 10px; + grid-template-columns: 14px minmax(0, 1fr); + align-items: start; +} + +.automation-step-marker { + width: 10px; + height: 10px; + margin-top: 4px; + border-radius: 999px; + background: var(--wxex-text-muted); +} + +.automation-step.success .automation-step-marker { + background: var(--wxex-success); +} + +.automation-step.failed .automation-step-marker { + background: var(--wxex-danger); +} + +.automation-step.skipped .automation-step-marker { + background: transparent; + box-shadow: inset 0 0 0 1.5px var(--wxex-border); +} + +.automation-step-body { + display: flex; + min-width: 0; + flex-direction: column; + gap: 4px; +} + +.automation-step-title { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; +} + +.automation-step-label { + color: var(--wxex-text-primary); + font-size: 13px; + font-weight: 600; +} + +.automation-step-status { + color: var(--wxex-text-muted); + font-size: 11px; + white-space: nowrap; +} + +.automation-step.failed .automation-step-status { + color: var(--wxex-danger); +} + +.automation-step-meta { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + color: var(--wxex-text-secondary); + font-size: 11px; + font-variant-numeric: tabular-nums; +} + +.automation-step-sep { + color: var(--wxex-text-muted); +} + +.automation-step-duration { + padding: 1px 7px; + border-radius: 999px; + background: var(--wxex-bg-app); + color: var(--wxex-text-secondary); +} + +.automation-step-error { + margin: 2px 0 0; + color: var(--wxex-danger); + font-size: 12px; + line-height: 18px; + overflow-wrap: anywhere; +} + +// --------------------------------------------------------------------------- +// 删除确认 +// --------------------------------------------------------------------------- + +.automation-confirm-layer { + position: fixed; + inset: 0; + z-index: 61; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + background: color-mix(in srgb, #0f172a 45%, transparent); +} + +.automation-confirm { + box-sizing: border-box; + display: flex; + width: min(400px, 100%); + flex-direction: column; + gap: 10px; + padding: 22px 24px; + border: 1px solid var(--wxex-border); + border-radius: var(--wxex-radius-lg); + background: var(--wxex-bg-elevated); + color: var(--wxex-text-primary); + box-shadow: var(--wxex-shadow-popover); + + h2 { + margin: 0; + font-size: 16px; + font-weight: 700; + overflow-wrap: anywhere; + } + + p { + margin: 0; + color: var(--wxex-text-secondary); + font-size: 13px; + line-height: 19px; + } +} + +.automation-confirm-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 6px; +} diff --git a/src/renderer/src/styles/index.scss b/src/renderer/src/styles/index.scss index 0927641..47b2967 100644 --- a/src/renderer/src/styles/index.scss +++ b/src/renderer/src/styles/index.scss @@ -20,4 +20,5 @@ @use './group-exit-event'; @use './group-member-stats'; @use './local-index'; +@use './automation'; @use './theme'; diff --git a/src/shared/automation-matcher.ts b/src/shared/automation-matcher.ts new file mode 100644 index 0000000..d4385bd --- /dev/null +++ b/src/shared/automation-matcher.ts @@ -0,0 +1,92 @@ +import { + matchKeyword, + type AutomationMessageScope, + type AutomationRule +} from './automation' + +/** + * TriggerMatcher —— 判断「这条消息是否命中这条规则」。 + * + * **纯函数**,放在 shared 而不是 main,理由是它有两个消费方: + * 1. 真实执行链路(AutomationService); + * 2. 规则编辑页的「效果模拟预览」。 + * + * 如果预览自己写一套判断,界面演示和真实行为必然逐渐分叉 —— + * 那种分叉用户看不出来,但会导致"预览能过、实际不触发"。 + */ + +/** + * 判定所需的**最小**消息输入。 + * + * 刻意不直接依赖 main 侧的 `NormalizedIncomingMessage`:shared 不允许反向依赖 main。 + * 结构化的类型让 main 侧可以直接把那个 DTO 传进来。 + */ +export interface AutomationMatchInput { + content?: string + /** **真 @ 目标**(由 `source → ` 解析,不是正文里的 `@昵称`)。 */ + mentionTargets: string[] + isSelf: boolean + isGroup: boolean + /** 会话标识(群 md5)。 */ + conversationId: string +} + +export type AutomationMatchFailure = + | 'disabled' + | 'self' + | 'scope' + | 'conversation' + | 'mention' + | 'keyword' + +export interface AutomationMatchResult { + matched: boolean + /** 未命中的原因。仅用于内部日志与调优,不展示给用户。 */ + reason?: AutomationMatchFailure +} + +/** 判定顺序:越靠前的越便宜,且都是「一票否决」。 */ +export function matchAutomationRule( + rule: AutomationRule, + input: AutomationMatchInput, + /** 自己的 username 候选集(`getMyUsernameCandidates()`)。 */ + selfUsernames: readonly string[] +): AutomationMatchResult { + if (!rule.enabled) return { matched: false, reason: 'disabled' } + + // 防死循环第一道闸:自己发的消息默认不触发。 + // TraceMemo 回复的那句「收到,正在生成今日日报」会被 MessageListener 再次读到, + // 没有这道闸就会自己触发自己。 + if (rule.conditions.ignoreSelf !== false && input.isSelf) { + return { matched: false, reason: 'self' } + } + + const scope: AutomationMessageScope = rule.scope + if (scope === 'group' && !input.isGroup) return { matched: false, reason: 'scope' } + if (scope === 'direct' && input.isGroup) return { matched: false, reason: 'scope' } + + // 生效范围:**空数组 = 不限会话**。 + const conversationIds = rule.conditions.conversationIds ?? [] + if (conversationIds.length && !conversationIds.includes(input.conversationId)) { + return { matched: false, reason: 'conversation' } + } + + // 真 @ 判定:只认 `source → atuserlist` 解析出的 username, + // 与自己的候选 username 做**精确匹配**。 + // + // 三条禁止的做法(Spike 已实证): + // - `content.includes('@我的昵称')` —— 正文里的 @ 是纯文本,与真 @ 无关; + // - 昵称匹配 —— 昵称会改; + // - `target.startsWith('wxid_')` —— 自己的 username 可能压根不是这个前缀。 + if (rule.conditions.requireMentionMe) { + const self = new Set(selfUsernames.filter(Boolean)) + const mentioned = input.mentionTargets.some((target) => self.has(target)) + if (!mentioned) return { matched: false, reason: 'mention' } + } + + if (!matchKeyword(input.content, rule.conditions.keyword, rule.conditions.keywordMatchMode)) { + return { matched: false, reason: 'keyword' } + } + + return { matched: true } +} diff --git a/src/shared/automation.ts b/src/shared/automation.ts new file mode 100644 index 0000000..dbcf5cc --- /dev/null +++ b/src/shared/automation.ts @@ -0,0 +1,328 @@ +/** + * Automation v1 的跨层契约。 + * + * 设计原则:**只包含当前真正需要的字段**。 + * 第一版不做:regex、IF/ELSE 嵌套、可视化编排、Webhook、通用 AI 机器人。 + */ + +/** 触发事件类型。第一版只支持「收到消息」。 */ +export type AutomationTriggerType = 'message' + +/** + * 关键词匹配方式。 + * + * **刻意不做 regex** —— regex 会立刻把「规则配置」变成需要调试的编程界面, + * 而且用户写错正则时既没有报错也没有预览。三种字面匹配覆盖绝大多数场景。 + */ +export type KeywordMatchMode = 'contains' | 'exact' | 'prefix' + +/** 消息来源过滤。 */ +export type AutomationMessageScope = 'group' | 'direct' | 'all' + +export interface AutomationConditions { + /** 必须**真正** @我(走 source → atuserlist,不是正文里的 @昵称)。 */ + requireMentionMe: boolean + keyword: string + keywordMatchMode: KeywordMatchMode + /** 生效的会话(群 md5)。为空表示「不限会话」。 */ + conversationIds: string[] + /** 忽略自己发送的消息。默认 true —— 这是防死循环的第一道闸。 */ + ignoreSelf: boolean +} + +/** 执行动作类型。顺序由 `actions` 数组顺序决定。 */ +export type AutomationActionType = 'replyText' | 'generateReport' | 'sendReportImage' + +export interface AutomationAction { + type: AutomationActionType + enabled: boolean + /** `replyText` 的文案。 */ + text?: string +} + +export interface AutomationRule { + id: string + name: string + enabled: boolean + trigger: AutomationTriggerType + scope: AutomationMessageScope + conditions: AutomationConditions + actions: AutomationAction[] + /** 同一规则在同一会话内的最小触发间隔(秒)。防刷屏。 */ + cooldownSeconds: number + createdAt: number + updatedAt: number +} + +/** 执行步骤状态。`skipped` 用于「上一步失败所以这一步不做」。 */ +export type AutomationStepStatus = 'pending' | 'running' | 'success' | 'failed' | 'skipped' + +export type AutomationStepKey = 'received' | 'matched' | 'reply' | 'report' | 'send' + +export interface AutomationStep { + key: AutomationStepKey + /** 用户可读的步骤名(中文)。 */ + label: string + status: AutomationStepStatus + startedAt?: number + finishedAt?: number + durationMs?: number + /** 用户可读的错误信息。**禁止**含 wxid / 文件路径 / 原始 payload。 */ + error?: string +} + +export type AutomationExecutionStatus = 'running' | 'success' | 'failed' + +export interface AutomationExecution { + executionId: string + ruleId: string + ruleName: string + triggerTime: number + /** + * 来源的**显示名**(群名 / 联系人昵称)。 + * + * ⚠️ 展示层契约:**不含** wxid / localId / serverId / source XML / raw payload。 + */ + sourceDisplayName: string + status: AutomationExecutionStatus + durationMs: number + steps: AutomationStep[] + /** 失败时的一句话摘要。 */ + errorSummary?: string +} + +/** + * 发送用途标识(对应 `WechatActionPurpose`)。 + * + * 这两个值必须同步登记进 `wechat-action-gateway.ts` 的 + * `AUTOMATION_PURPOSE_ALLOWLIST`,否则会被策略层以 `ACTION_NOT_ALLOWED` 拦下 —— + * 那是有意的闸门,不是 bug。 + */ +export const AUTOMATION_SEND_PURPOSE = { + /** 触发确认文字回复。 */ + reply: 'automation_reply', + /** 日报图片。 */ + report: 'automation_report' +} as const + +/** 审计日志里的来源标识(`WechatActionOrigin`)。 */ +export const AUTOMATION_SEND_ORIGIN = 'automation' + +/** 所有自动化行为的 `sourceId` / `executionId` 口径:一次执行一个 id。 */ +export function automationIdempotencyKey( + kind: keyof typeof AUTOMATION_SEND_PURPOSE, + executionId: string +): string { + return `${AUTOMATION_SEND_PURPOSE[kind]}:${executionId}` +} + +/** 规则编辑页提交的草稿:不含 id / 时间戳,由 main 侧补齐。 */ +export interface AutomationRuleDraft { + name: string + enabled: boolean + trigger: AutomationTriggerType + scope: AutomationMessageScope + conditions: AutomationConditions + actions: AutomationAction[] + cooldownSeconds: number +} + +/** 顶部状态条数据(全部来自 main 侧真实能力,UI 不做平台判断)。 */ +export interface AutomationStatusSummary { + /** 消息监听是否在运行。 */ + listening: boolean + /** 监听是否降级(只能看到最近活跃会话,见 MessageListener BLOCKER)。 */ + listeningDegraded: boolean + /** 今日执行次数 / 成功次数。 */ + todayExecutions: number + todaySuccesses: number + /** 真实发送能力。 */ + sendCapability: { + supported: boolean + ready: boolean + canSendText: boolean + canSendImage: boolean + message: string + } +} + +/** + * 规则列表里的**模板入口**。 + * + * `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: 'member-exit-notice', + name: '退群通知', + description: '群成员退出时自动发送通知', + available: false, + unavailableReason: '现有「退群监控」已具备该能力,本轮不迁移' + }, + { + id: 'keyword-reply', + name: '关键词自动回复', + description: '消息命中关键词时自动回复指定内容', + available: false, + unavailableReason: '本轮验收对象为「@我生成日报」,关键词模板下一轮开放' + } +] + +/** 内置规则 id。固定值,便于升级时不重复创建。 */ +export const BUILTIN_DAILY_REPORT_RULE_ID = 'builtin-mention-me-daily-report' + +export const AUTOMATION_STEP_LABELS: Record = { + received: '收到消息', + matched: '规则匹配', + reply: '回复确认', + report: '生成日报', + send: '发送日报图片' +} + +export const KEYWORD_MATCH_MODE_LABELS: Record = { + contains: '包含', + exact: '完全匹配', + prefix: '以…开头' +} + +export const SCOPE_LABELS: Record = { + group: '群聊', + direct: '私聊', + all: '全部' +} + +/** + * 关键词匹配(纯函数)。 + * + * 大小写不敏感 + 去首尾空白;空关键词视为「不限制」。 + */ +export function matchKeyword( + content: string | undefined, + keyword: string, + mode: KeywordMatchMode +): boolean { + const needle = keyword.trim().toLowerCase() + if (!needle) return true + const haystack = String(content ?? '').toLowerCase() + if (!haystack) return false + if (mode === 'exact') return haystack.trim() === needle + if (mode === 'prefix') return haystack.trim().startsWith(needle) + return haystack.includes(needle) +} + +/** 触发条件的一句话摘要(UI 展示用,不含任何内部 id)。 */ +export function describeRuleTrigger(rule: AutomationRule): string { + const parts: string[] = [] + if (rule.conditions.requireMentionMe) parts.push('@我') + if (rule.conditions.keyword.trim()) { + parts.push(`关键词「${rule.conditions.keyword.trim()}」`) + } + return parts.length ? parts.join(' + ') : '任意消息' +} + +/** 动作链的一句话摘要。 */ +export function describeRuleActions(rule: AutomationRule): string { + const labels: Record = { + replyText: '回复确认', + generateReport: '生成日报', + sendReportImage: '发送图片' + } + const enabled = rule.actions.filter((action) => action.enabled) + return enabled.length ? enabled.map((action) => labels[action.type]).join(' → ') : '无动作' +} + +/** 内置「@我生成日报」的默认回复文案。 */ +export const DEFAULT_REPLY_TEXT = '收到,正在生成今日日报' + +/** + * 构造内置规则「@我生成日报」。 + * + * 纯函数,方便 main(首次启动时落盘)与 UI(模板预览)共用同一份默认值, + * 避免两处默认值漂移。 + * + * `conversationIds` 刻意留空 = **不限会话**:首次启动时用户还没选群, + * 强行要求选群会让内置规则「看起来存在但永不触发」,比留空更糟。 + */ +export function createDefaultDailyReportRule(now: number): AutomationRule { + return { + id: BUILTIN_DAILY_REPORT_RULE_ID, + name: '@我生成日报', + enabled: true, + trigger: 'message', + scope: 'group', + conditions: { + requireMentionMe: true, + keyword: '日报', + keywordMatchMode: 'contains', + conversationIds: [], + ignoreSelf: true + }, + actions: [ + { type: 'replyText', enabled: true, text: DEFAULT_REPLY_TEXT }, + { type: 'generateReport', enabled: true }, + { type: 'sendReportImage', enabled: true } + ], + cooldownSeconds: 60, + createdAt: now, + updatedAt: now + } +} + +/** 归一化用户提交的草稿:把未知值收敛成合法值,避免脏数据落盘。 */ +export function normalizeRuleDraft(input: unknown, fallbackName = '未命名自动化'): AutomationRuleDraft { + const raw = (input ?? {}) as Partial + const conditions = (raw.conditions ?? {}) as Partial + const keywordMatchMode: KeywordMatchMode = + conditions.keywordMatchMode === 'exact' || conditions.keywordMatchMode === 'prefix' + ? conditions.keywordMatchMode + : 'contains' + const scope: AutomationMessageScope = + raw.scope === 'direct' || raw.scope === 'all' ? raw.scope : 'group' + const actions = Array.isArray(raw.actions) ? raw.actions : [] + const normalizedActions: AutomationAction[] = actions + .filter((action): action is AutomationAction => Boolean(action && typeof action === 'object')) + .map((action) => ({ + type: action.type, + enabled: action.enabled !== false, + ...(typeof action.text === 'string' ? { text: action.text } : {}) + })) + .filter((action) => + action.type === 'replyText' || action.type === 'generateReport' || action.type === 'sendReportImage' + ) + const cooldown = Number(raw.cooldownSeconds) + return { + name: String(raw.name ?? '').trim() || fallbackName, + enabled: raw.enabled !== false, + trigger: 'message', + scope, + conditions: { + requireMentionMe: conditions.requireMentionMe !== false, + keyword: String(conditions.keyword ?? '').trim(), + keywordMatchMode, + conversationIds: Array.isArray(conditions.conversationIds) + ? conditions.conversationIds.map((id) => String(id)).filter(Boolean) + : [], + ignoreSelf: conditions.ignoreSelf !== false + }, + actions: normalizedActions, + cooldownSeconds: Number.isFinite(cooldown) ? Math.max(0, Math.floor(cooldown)) : 60 + } +} diff --git a/src/shared/wechat-action.ts b/src/shared/wechat-action.ts index f7984ea..423ba0b 100644 --- a/src/shared/wechat-action.ts +++ b/src/shared/wechat-action.ts @@ -2,6 +2,8 @@ export type WechatActionOrigin = | 'member_monitor' | 'scheduled_report' | 'user_tts' + /** Automation v1(@我生成日报)。与 `shared/automation.ts` 的 AUTOMATION_SEND_ORIGIN 一致。 */ + | 'automation' | 'unknown' | (string & {}) @@ -9,6 +11,15 @@ export type WechatActionPurpose = | 'member_left_notification' | 'scheduled_report' | 'tts_voice' + /** + * Automation v1 的两个用途。 + * + * ⚠️ 这两个值必须同步登记进 `wechat-action-gateway.ts` 的 + * `AUTOMATION_PURPOSE_ALLOWLIST`,否则会被策略层以 `ACTION_NOT_ALLOWED` 拦下。 + * 与 `shared/automation.ts` 的 `AUTOMATION_SEND_PURPOSE` 保持一致。 + */ + | 'automation_reply' + | 'automation_report' | (string & {}) export type WechatActionTriggerType = 'automation' | 'user' diff --git a/tests/component/automation-effect-preview.test.tsx b/tests/component/automation-effect-preview.test.tsx new file mode 100644 index 0000000..3e0b39a --- /dev/null +++ b/tests/component/automation-effect-preview.test.tsx @@ -0,0 +1,183 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { EffectPreview } from '../../src/renderer/src/features/automation/EffectPreview' +import { + createDefaultDailyReportRule, + normalizeRuleDraft, + type AutomationRuleDraft +} from '../../src/shared/automation' + +/** + * 效果模拟预览必须**跟着表单走**。 + * + * 这些用例锁的是「改一个开关,预览立刻变」这条产品硬要求 —— + * 预览一旦和真实规则脱钩,用户就会按一个错误的预期去保存规则。 + */ + +function draft(mutate?: (value: AutomationRuleDraft) => void): AutomationRuleDraft { + const value = normalizeRuleDraft(createDefaultDailyReportRule(0)) + mutate?.(value) + return value +} + +/** 模拟消息气泡的内容(左侧对方那条)。 */ +const incomingBubble = (): string => + document.querySelector('.automation-chat-row.incoming .automation-chat-bubble')?.textContent ?? '' + +const hasReportImage = (): boolean => document.querySelector('.automation-chat-image') !== null + +const hints = (): string[] => + Array.from(document.querySelectorAll('.automation-preview-hint')).map( + (node) => node.textContent ?? '' + ) + +describe('EffectPreview', () => { + it('默认状态下展示 @你 + 关键词、回复气泡与日报图片预览', () => { + render() + + expect(incomingBubble()).toBe('@你 今日日报') + expect(screen.getByText('收到,正在生成今日日报')).toBeInTheDocument() + expect(hasReportImage()).toBe(true) + expect(screen.getByText('仅预览,不会真实发送')).toBeInTheDocument() + }) + + it('改关键词后左侧模拟消息同步变化', () => { + const { rerender } = render() + expect(incomingBubble()).toBe('@你 今日日报') + + rerender( + { + value.conditions.keyword = '周报' + })} + /> + ) + expect(incomingBubble()).toBe('@你 今日周报') + }) + + it('改回复文案后右侧气泡同步变化', () => { + const { rerender } = render() + + rerender( + { + value.actions = value.actions.map((action) => + action.type === 'replyText' ? { ...action, text: '稍等,马上给你' } : action + ) + })} + /> + ) + expect(screen.getByText('稍等,马上给你')).toBeInTheDocument() + expect(screen.queryByText('收到,正在生成今日日报')).not.toBeInTheDocument() + }) + + it('关闭「回复确认」后右侧回复气泡消失', () => { + const { rerender } = render() + expect(screen.getByText('收到,正在生成今日日报')).toBeInTheDocument() + + rerender( + { + value.actions = value.actions.map((action) => + action.type === 'replyText' ? { ...action, enabled: false } : action + ) + })} + /> + ) + expect(screen.queryByText('收到,正在生成今日日报')).not.toBeInTheDocument() + // 真正被停用的动作不能只是"视觉上没了",要给一句说明。 + expect(hints().some((text) => text.includes('未启用「回复确认」'))).toBe(true) + }) + + it('关闭「必须真正 @我」后左侧不再显示 @你', () => { + const { rerender } = render() + expect(incomingBubble().startsWith('@你')).toBe(true) + + rerender( + { + value.conditions.requireMentionMe = false + })} + /> + ) + expect(incomingBubble().startsWith('@你')).toBe(false) + expect(incomingBubble()).toBe('今日日报') + }) + + it('关闭「生成日报」后不再出图片,并说明原因', () => { + const { rerender } = render() + expect(hasReportImage()).toBe(true) + + rerender( + { + value.actions = value.actions.map((action) => + action.type === 'generateReport' ? { ...action, enabled: false } : action + ) + })} + /> + ) + expect(hasReportImage()).toBe(false) + expect(hints().some((text) => text.includes('未启用「生成日报」'))).toBe(true) + }) + + it('只关闭「发送图片」时,说明日报仍会生成但不会进群', () => { + const { rerender } = render() + expect(hasReportImage()).toBe(true) + + rerender( + { + value.actions = value.actions.map((action) => + action.type === 'sendReportImage' ? { ...action, enabled: false } : action + ) + })} + /> + ) + expect(hasReportImage()).toBe(false) + // 这里必须和「没生成日报」区分开,否则用户不知道日报到底出没出来。 + expect(hints().some((text) => text.includes('日报仍会生成'))).toBe(true) + }) + + it('关键词留空时不伪造一个关键词出来', () => { + render( + { + value.conditions.keyword = ' ' + })} + /> + ) + expect(incomingBubble()).toBe('@你 今天群里有什么新消息') + }) + + it('生效范围与触发间隔随时间反映在摘要里', () => { + const { rerender } = render() + expect(screen.getByText('所有群聊')).toBeInTheDocument() + expect(screen.getByText('60 秒')).toBeInTheDocument() + + rerender( + { + value.conditions.conversationIds = ['a@chatroom', 'b@chatroom'] + value.cooldownSeconds = 0 + })} + /> + ) + expect(screen.getByText('已选 2 个群')).toBeInTheDocument() + expect(screen.getByText('不限制')).toBeInTheDocument() + }) + + it('关闭「忽略自己发送」时摘要如实标注会触发', () => { + const { rerender } = render() + expect(screen.getByText('不触发')).toBeInTheDocument() + + rerender( + { + value.conditions.ignoreSelf = false + })} + /> + ) + expect(screen.getByText('也会触发')).toBeInTheDocument() + }) +}) diff --git a/tests/integration/automation-chain.test.ts b/tests/integration/automation-chain.test.ts new file mode 100644 index 0000000..4af976c --- /dev/null +++ b/tests/integration/automation-chain.test.ts @@ -0,0 +1,272 @@ +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-automation-chain-')) +}) + +vi.mock('electron', () => ({ + app: { getPath: () => root }, + safeStorage: { + isEncryptionAvailable: () => false, + encryptString: (value: string) => Buffer.from(value, 'utf8'), + decryptString: (value: Buffer) => value.toString('utf8') + }, + BrowserWindow: { getAllWindows: () => [] } +})) + +import { AUTOMATION_SEND_PURPOSE } from '../../src/shared/automation' +import type { + PersonalWechatSendCapability, + PersonalWechatSendRequest +} from '../../src/shared/personal-wechat' +import type { Wcdb4Client } from '../../src/main/wcdb4-client' +import type { NormalizedIncomingMessage } from '../../src/main/services/message-listener-service' +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' + +/** + * 端到端:真实链路跑一遍。 + * + * 这里除了「微信怎么把消息发出去」这一步,其余**全部是生产实现**: + * AutomationService → TriggerMatcher → AutomationActionRunner → + * WechatActionGateway(真实策略 + 幂等 + 节流)→ 被替换掉的传输层。 + * + * 存在的理由:单元测试各自 mock 掉邻居,无法证明「这些部件真的能拼起来」。 + * 尤其 `AUTOMATION_PURPOSE_ALLOWLIST` 这道闸 —— 它一旦漏登记,整条链会在策略层 + * 被静默拦下,而每个单元的测试都还是绿的。 + */ + +const SELF = 'my_account_9527' +const GROUP_ID = '12345678@chatroom' + +function message(overrides: Partial = {}): NormalizedIncomingMessage { + return { + sessionId: GROUP_ID, + localId: '100', + createTime: 1_789_900_000, + messageType: 1, + isSelf: false, + senderId: 'wxid_sender', + senderNickname: '张三', + content: '今日日报', + isGroup: true, + mentionTargets: [SELF], + ...overrides + } +} + +/** + * 完整可用的发送能力替身。 + * + * 不写 `as never` 之类的强转 —— 那样一旦 `PersonalWechatSendCapability` 加字段, + * 这里会静默失真,而测试仍然是绿的。 + */ +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: '个人微信已准备好发送日报' + } + } +} + +interface Chain { + service: AutomationService + log: AutomationExecutionLogService + store: AutomationRuleStore + gateway: WechatActionGateway + sends: PersonalWechatSendRequest[] + reports: string[] +} + +function buildChain(): Chain { + const sends: PersonalWechatSendRequest[] = [] + const reports: string[] = [] + + const gateway = new WechatActionGateway({ + getUserDataPath: () => root, + getCapability: async () => readyCapability(), + // 传输层是唯一被替换的部分:其余(策略、幂等、审计、节流)都是真的。 + send: async (request) => { + sends.push(request) + return { success: true, status: readyCapability().senderStatus } + }, + // 节流本身要保留,但不想让测试真的等 3 秒。 + wait: async () => undefined + }) + + const store = new AutomationRuleStore({ userDataPath: () => root }) + const log = new AutomationExecutionLogService({ userDataPath: () => root }) + const runner = new AutomationActionRunner({ + executeAction: (request) => gateway.execute(request), + generateReport: async (request) => { + reports.push(request.group) + return { success: true, pngPath: path.join(root, 'report.png') } + } + }) + + const client = { + getMyUsernameCandidates: () => [SELF], + getSessions: () => [{ username: GROUP_ID, nickname: '测试群' }] + } as unknown as Wcdb4Client + + const service = new AutomationService(client, { + ruleStore: store, + executionLog: log, + runner, + getCapability: async () => readyCapability() + }) + + return { service, log, store, gateway, sends, reports } +} + +describe('automation end-to-end chain', () => { + beforeEach(() => { + fs.removeSync(path.join(root, 'automation')) + fs.removeSync(path.join(root, 'actions')) + }) + + afterAll(() => fs.removeSync(root)) + + it('真 @我 + 「日报」→ 回复确认 + 生成日报 + 发送图片,全程走 WechatActionGateway', async () => { + const chain = buildChain() + await chain.service.handleMessage(message()) + + // 两次发送:先文字后图片,且都真的穿过了策略层(没有被 ACTION_NOT_ALLOWED 拦下)。 + expect(chain.sends).toHaveLength(2) + expect(chain.sends[0]).toMatchObject({ + type: 'text', + to: GROUP_ID, + isGroup: true, + text: '收到,正在生成今日日报' + }) + expect(chain.sends[1]).toMatchObject({ + type: 'image', + to: GROUP_ID, + isGroup: true, + filePath: path.join(root, 'report.png') + }) + expect(chain.reports).toEqual([GROUP_ID]) + + const record = chain.log.list()[0] + expect(record.status).toBe('success') + expect(record.steps.map((step) => `${step.key}:${step.status}`)).toEqual([ + 'received:success', + 'matched:success', + 'reply:success', + 'report:success', + 'send:success' + ]) + }) + + it('两个 purpose 都通过了策略 allowlist,并留下审计记录', () => { + const chain = buildChain() + return chain.service.handleMessage(message()).then(() => { + const audit = chain.gateway.listAuditRecords() + const purposes = audit.map((item) => item.purpose) + expect(purposes).toContain(AUTOMATION_SEND_PURPOSE.reply) + expect(purposes).toContain(AUTOMATION_SEND_PURPOSE.report) + // 被拦下的话 decision 会是 block —— 这里必须都是 allow,否则就是漏登记 allowlist。 + expect(audit.every((item) => item.decision === 'allow')).toBe(true) + expect(audit.every((item) => item.sendStatus === 'sent')).toBe(true) + expect(audit.every((item) => item.origin === 'automation')).toBe(true) + }) + }) + + it('同一 executionId 不会重复发送(网关幂等挡住第二次)', async () => { + const chain = buildChain() + const first = await chain.gateway.execute({ + idempotencyKey: `${AUTOMATION_SEND_PURPOSE.reply}:exec-fixed`, + origin: 'automation', + purpose: AUTOMATION_SEND_PURPOSE.reply, + triggerType: 'automation', + executionId: 'exec-fixed', + recipient: { type: 'group', id: GROUP_ID }, + content: { type: 'text', text: '收到,正在生成今日日报' } + }) + expect(first.status).toBe('sent') + expect(chain.sends).toHaveLength(1) + + const second = await chain.gateway.execute({ + idempotencyKey: `${AUTOMATION_SEND_PURPOSE.reply}:exec-fixed`, + origin: 'automation', + purpose: AUTOMATION_SEND_PURPOSE.reply, + triggerType: 'automation', + executionId: 'exec-fixed', + recipient: { type: 'group', id: GROUP_ID }, + content: { type: 'text', text: '收到,正在生成今日日报' } + }) + expect(second.status).toBe('sent') + // 关键:没有第二次真实发送。 + expect(chain.sends).toHaveLength(1) + }) + + it('自己发送的回复被 MessageListener 再读到时不触发(防死循环)', async () => { + const chain = buildChain() + await chain.service.handleMessage(message()) + const sendsAfterFirst = chain.sends.length + + // TraceMemo 回复的那句话本身就含关键词「日报」,会被回读为一条 isSelf 消息。 + await chain.service.handleMessage( + message({ + localId: '101', + isSelf: true, + content: '收到,正在生成今日日报', + mentionTargets: [] + }) + ) + + expect(chain.sends).toHaveLength(sendsAfterFirst) + expect(chain.log.list()).toHaveLength(1) + }) + + it('规则被停用后整条链不再产生任何发送', async () => { + const chain = buildChain() + const builtin = chain.store.listRules()[0] + chain.store.setRuleEnabled(builtin.id, false) + await chain.service.handleMessage(message()) + + expect(chain.sends).toHaveLength(0) + expect(chain.log.list()).toHaveLength(0) + }) + + it('假的 @(正文里有 @昵称但无底层 @ 元数据)整条链不动作', async () => { + const chain = buildChain() + await chain.service.handleMessage(message({ content: '@我 今日日报', mentionTargets: [] })) + expect(chain.sends).toHaveLength(0) + }) +}) diff --git a/tests/unit/automation-action-runner.test.ts b/tests/unit/automation-action-runner.test.ts new file mode 100644 index 0000000..cbfb4c7 --- /dev/null +++ b/tests/unit/automation-action-runner.test.ts @@ -0,0 +1,307 @@ +import { describe, expect, it, vi } from 'vitest' + +/** + * runner 本身不碰 electron,但它的模块图会加载 `agent-group-report-service`, + * 那条链上的 `report-template-service` / `settings-store` 在**模块加载时**就会调 + * `app.getPath()`。所以这里必须给 electron 一个替身,否则 import 阶段就炸。 + * + * 用一个固定字符串即可 —— 这些服务只把它当路径前缀拼,不会真的读写。 + */ +vi.mock('electron', () => ({ + app: { getPath: () => '/tmp/wxe-automation-runner-fixture' }, + safeStorage: { + isEncryptionAvailable: () => false, + encryptString: (value: string) => Buffer.from(value, 'utf8'), + decryptString: (value: Buffer) => value.toString('utf8') + }, + BrowserWindow: { getAllWindows: () => [] } +})) + +import { + AUTOMATION_SEND_PURPOSE, + createDefaultDailyReportRule, + type AutomationRule, + type AutomationStepKey +} from '../../src/shared/automation' +import type { WechatActionRequest, WechatActionResult } from '../../src/shared/wechat-action' +import { AutomationActionRunner } from '../../src/main/services/automation-action-runner' +import type { AgentGroupReportResult } from '../../src/main/services/agent-group-report-service' + +/** + * ActionRunner 的步骤语义。 + * + * 这一层最重要的性质只有两条: + * 1. 顺序是 reply → report → send; + * 2. **失败必须切断后续** —— 日报没出来就绝不能继续发图。 + */ + +const EXECUTION_ID = 'exec-fixture-1' +const GROUP_ID = '12345678@chatroom' + +function makeRule(mutate?: (rule: AutomationRule) => void): AutomationRule { + const rule = createDefaultDailyReportRule(0) + mutate?.(rule) + return rule +} + +/** 单调递增的假时钟,让 durationMs 可预测。 */ +function fakeClock(step = 5): () => number { + let value = 1_700_000_000_000 + return () => { + value += step + return value + } +} + +function sentResult(): WechatActionResult { + return { + actionId: 'action-1', + status: 'sent', + decision: 'allow', + startedAt: '2026-09-20T10:00:00.000Z', + finishedAt: '2026-09-20T10:00:01.000Z' + } +} + +function failedResult(code: string, reason: string): WechatActionResult { + return { + actionId: 'action-1', + status: 'failed', + decision: 'allow', + errorCode: code, + reason, + startedAt: '2026-09-20T10:00:00.000Z', + finishedAt: '2026-09-20T10:00:01.000Z' + } +} + +function buildRunner(options: { + executeAction?: (request: WechatActionRequest) => Promise + generateReport?: () => Promise +}): { + runner: AutomationActionRunner + calls: WechatActionRequest[] + reports: Array<{ group: string; range?: string }> +} { + const calls: WechatActionRequest[] = [] + const reports: Array<{ group: string; range?: string }> = [] + const runner = new AutomationActionRunner({ + now: fakeClock(), + executeAction: async (request) => { + calls.push(request) + return options.executeAction ? options.executeAction(request) : sentResult() + }, + generateReport: async (request) => { + reports.push({ group: request.group, ...(request.range ? { range: request.range } : {}) }) + return options.generateReport ? options.generateReport() : { success: true, pngPath: '/tmp/report.png' } + } + }) + return { runner, calls, reports } +} + +const run = ( + runner: AutomationActionRunner, + rule: AutomationRule +): Promise>> => + runner.run({ + executionId: EXECUTION_ID, + rule, + conversationId: GROUP_ID, + isGroup: true, + sourceDisplayName: '测试群' + }) + +const statusOf = ( + result: Awaited>, + key: AutomationStepKey +): string => result.steps.find((step) => step.key === key)?.status ?? 'missing' + +describe('AutomationActionRunner', () => { + it('按 收到消息 → 规则匹配 → 回复确认 → 生成日报 → 发送图片 的顺序执行', async () => { + const { runner, calls, reports } = buildRunner({}) + const result = await run(runner, makeRule()) + + expect(result.status).toBe('success') + expect(result.pngPath).toBe('/tmp/report.png') + expect(result.steps.map((step) => step.key)).toEqual([ + 'received', + 'matched', + 'reply', + 'report', + 'send' + ]) + expect(result.steps.map((step) => step.status)).toEqual([ + 'success', + 'success', + 'success', + 'success', + 'success' + ]) + + // 先回文字,再发图片 —— 顺序反了用户会先收到图再收到"正在生成"。 + expect(calls).toHaveLength(2) + expect(calls[0].content.type).toBe('text') + expect(calls[1].content.type).toBe('image') + expect(reports).toEqual([{ group: GROUP_ID, range: 'today' }]) + }) + + it('每一步都带独立的起止时间与耗时', async () => { + const { runner } = buildRunner({}) + const result = await run(runner, makeRule()) + for (const step of result.steps) { + expect(step.startedAt).toBeTypeOf('number') + expect(step.finishedAt).toBeTypeOf('number') + expect(step.durationMs).toBeGreaterThanOrEqual(0) + expect(step.finishedAt! >= step.startedAt!).toBe(true) + } + // matched 比 received 晚开始,说明确实是顺序执行而非一次性补写。 + expect(result.steps[1].startedAt!).toBeGreaterThan(result.steps[0].startedAt!) + }) + + it('发送请求带上正确的 purpose / 幂等键 / 收件人', async () => { + const { runner, calls } = buildRunner({}) + await run(runner, makeRule()) + + expect(calls[0].purpose).toBe(AUTOMATION_SEND_PURPOSE.reply) + expect(calls[0].idempotencyKey).toBe(`${AUTOMATION_SEND_PURPOSE.reply}:${EXECUTION_ID}`) + expect(calls[1].purpose).toBe(AUTOMATION_SEND_PURPOSE.report) + expect(calls[1].idempotencyKey).toBe(`${AUTOMATION_SEND_PURPOSE.report}:${EXECUTION_ID}`) + for (const call of calls) { + expect(call.origin).toBe('automation') + expect(call.triggerType).toBe('automation') + expect(call.recipient).toMatchObject({ type: 'group', id: GROUP_ID, name: '测试群' }) + } + }) + + it('生成日报失败时,发送图片被跳过而不是继续乱发', async () => { + const { runner, calls } = buildRunner({ + generateReport: async () => ({ success: false, error: '所选时间范围没有可总结的消息' }) + }) + const result = await run(runner, makeRule()) + + expect(result.status).toBe('failed') + expect(statusOf(result, 'reply')).toBe('success') + expect(statusOf(result, 'report')).toBe('failed') + expect(statusOf(result, 'send')).toBe('skipped') + expect(result.errorSummary).toBe('所选时间范围没有可总结的消息') + // 只发过那条文字回复,图片一次都没发。 + expect(calls).toHaveLength(1) + expect(calls[0].content.type).toBe('text') + }) + + it('生成日报抛异常时同样记 failed 并跳过发送', async () => { + const { runner } = buildRunner({ + generateReport: async () => { + throw new Error('AI 服务不可达') + } + }) + const result = await run(runner, makeRule()) + expect(statusOf(result, 'report')).toBe('failed') + expect(statusOf(result, 'send')).toBe('skipped') + expect(result.steps.find((step) => step.key === 'report')?.error).toBe('AI 服务不可达') + }) + + it('发送图片失败时整次执行判为失败并保留原因', async () => { + let callIndex = 0 + const { runner } = buildRunner({ + executeAction: async () => { + callIndex += 1 + return callIndex === 1 + ? sentResult() + : failedResult('SEND_NOT_READY', '') + } + }) + const result = await run(runner, makeRule()) + expect(result.status).toBe('failed') + expect(statusOf(result, 'send')).toBe('failed') + expect(result.errorSummary).toBe('微信发送能力尚未就绪,请先绑定个人微信') + }) + + it('回复确认失败时,生成日报与发送都被跳过', async () => { + const { runner, calls, reports } = buildRunner({ + executeAction: async () => failedResult('SEND_CAPABILITY_UNAVAILABLE', '当前环境没有可用的微信发送能力') + }) + const result = await run(runner, makeRule()) + + expect(statusOf(result, 'reply')).toBe('failed') + expect(statusOf(result, 'report')).toBe('skipped') + expect(statusOf(result, 'send')).toBe('skipped') + expect(calls).toHaveLength(1) + expect(reports).toHaveLength(0) + }) + + it('未启用回复确认时该步 skipped,但不阻断后面的日报与发送', async () => { + const { runner, calls } = buildRunner({}) + const rule = makeRule((item) => { + item.actions = item.actions.filter((action) => action.type !== 'replyText') + }) + const result = await run(runner, rule) + + expect(result.status).toBe('success') + expect(statusOf(result, 'reply')).toBe('skipped') + expect(statusOf(result, 'report')).toBe('success') + expect(statusOf(result, 'send')).toBe('success') + expect(calls).toHaveLength(1) + expect(calls[0].content.type).toBe('image') + }) + + it('未启用生成日报时,发送图片也必须 skipped(不能凭空发图)', async () => { + const { runner, calls, reports } = buildRunner({}) + const rule = makeRule((item) => { + item.actions = item.actions.filter((action) => action.type !== 'generateReport') + }) + const result = await run(runner, rule) + + expect(statusOf(result, 'report')).toBe('skipped') + expect(statusOf(result, 'send')).toBe('skipped') + expect(reports).toHaveLength(0) + expect(calls).toHaveLength(1) + expect(calls[0].content.type).toBe('text') + }) + + it('只生成不发送时,日报步骤仍记成功', async () => { + const { runner, reports } = buildRunner({}) + const rule = makeRule((item) => { + item.actions = item.actions.filter((action) => action.type !== 'sendReportImage') + }) + const result = await run(runner, rule) + + expect(result.status).toBe('success') + expect(statusOf(result, 'report')).toBe('success') + expect(statusOf(result, 'send')).toBe('skipped') + expect(reports).toHaveLength(1) + }) + + it('回复文案为空时回落到内置默认文案', async () => { + const { runner, calls } = buildRunner({}) + const rule = makeRule((item) => { + item.actions = item.actions.map((action) => + action.type === 'replyText' ? { ...action, text: ' ' } : action + ) + }) + await run(runner, rule) + expect(calls[0].content).toEqual({ type: 'text', text: '收到,正在生成今日日报' }) + }) + + it('私聊场景下收件人类型是联系人', async () => { + const executeAction = vi.fn(async (_request: WechatActionRequest) => sentResult()) + const runner = new AutomationActionRunner({ + now: fakeClock(), + executeAction, + generateReport: async () => ({ success: true, pngPath: '/tmp/report.png' }) + }) + await runner.run({ + executionId: EXECUTION_ID, + rule: makeRule((item) => { + item.scope = 'direct' + }), + conversationId: 'wxid_friend', + isGroup: false, + sourceDisplayName: '李四' + }) + expect(executeAction.mock.calls[0][0].recipient).toMatchObject({ + type: 'contact', + id: 'wxid_friend' + }) + }) +}) diff --git a/tests/unit/automation-matcher.test.ts b/tests/unit/automation-matcher.test.ts new file mode 100644 index 0000000..ed70a76 --- /dev/null +++ b/tests/unit/automation-matcher.test.ts @@ -0,0 +1,195 @@ +import { describe, expect, it } from 'vitest' +import { + BUILTIN_DAILY_REPORT_RULE_ID, + createDefaultDailyReportRule, + matchKeyword, + normalizeRuleDraft, + type AutomationRule +} from '../../src/shared/automation' +import { + matchAutomationRule, + type AutomationMatchInput +} from '../../src/shared/automation-matcher' + +/** + * TriggerMatcher 的判定语义。 + * + * 这些用例锁死的是 2026-09-20 真实环境 Spike 得出的**底层事实**, + * 不是我们想要的语义 —— 尤其「正文里的 @昵称 不算真 @」这一条。 + */ + +/** 自己的微信 username。**刻意不带 `wxid_` 前缀** —— 前缀判断是 Spike 明确禁止的。 */ +const SELF = 'my_account_9527' +const OTHER = 'zhangsan_1234' +const GROUP_ID = '12345678@chatroom' + +function baseRule(overrides: Partial = {}): AutomationRule { + return { ...createDefaultDailyReportRule(0), ...overrides } +} + +function input(overrides: Partial = {}): AutomationMatchInput { + return { + content: '今日日报', + mentionTargets: [SELF], + isSelf: false, + isGroup: true, + conversationId: GROUP_ID, + ...overrides + } +} + +const match = (rule: AutomationRule, value: AutomationMatchInput): boolean => + matchAutomationRule(rule, value, [SELF]).matched + +const reasonOf = (rule: AutomationRule, value: AutomationMatchInput): string | undefined => + matchAutomationRule(rule, value, [SELF]).reason + +describe('TriggerMatcher', () => { + it('内置规则命中:真正 @我 + 含关键词「日报」', () => { + const rule = baseRule() + expect(rule.id).toBe(BUILTIN_DAILY_REPORT_RULE_ID) + expect(match(rule, input())).toBe(true) + }) + + it('假 @:正文里手打「@昵称」但底层没有 @ 元数据时不命中', () => { + // 这正是 Spike 验证的核心事实:content 里的 @ 是纯文本,与真 @ 无关。 + expect(match(baseRule(), input({ content: '@我 今日日报', mentionTargets: [] }))).toBe(false) + expect(reasonOf(baseRule(), input({ mentionTargets: [] }))).toBe('mention') + }) + + it('没有 @ 任何人时不命中', () => { + expect(match(baseRule(), input({ mentionTargets: [] }))).toBe(false) + }) + + it('@ 的是别人(不是自己)时不命中', () => { + expect(match(baseRule(), input({ mentionTargets: [OTHER] }))).toBe(false) + }) + + it('自己发送的消息不触发(防死循环第一道闸)', () => { + // TraceMemo 回复的「收到,正在生成今日日报」含关键词「日报」, + // 少了这道闸 MessageListener 再读到它就会自己触发自己。 + expect(match(baseRule(), input({ isSelf: true }))).toBe(false) + expect(reasonOf(baseRule(), input({ isSelf: true }))).toBe('self') + }) + + it('ignoreSelf 被显式关掉时,自己的消息才会被判定为可触发', () => { + const rule = baseRule() + rule.conditions.ignoreSelf = false + expect(match(rule, input({ isSelf: true }))).toBe(true) + }) + + it('停用的规则不命中', () => { + expect(match(baseRule({ enabled: false }), input())).toBe(false) + expect(reasonOf(baseRule({ enabled: false }), input())).toBe('disabled') + }) + + it('群范围规则收到私聊消息时不命中', () => { + expect(match(baseRule(), input({ isGroup: false }))).toBe(false) + expect(reasonOf(baseRule(), input({ isGroup: false }))).toBe('scope') + }) + + it('指定了生效群时,其它群不命中', () => { + const rule = baseRule() + rule.conditions.conversationIds = ['other@chatroom'] + expect(match(rule, input())).toBe(false) + expect(reasonOf(rule, input())).toBe('conversation') + }) + + it('指定了生效群且命中该群时正常触发', () => { + const rule = baseRule() + rule.conditions.conversationIds = [GROUP_ID] + expect(match(rule, input())).toBe(true) + }) + + it('空 conversationIds 表示不限会话', () => { + const rule = baseRule() + rule.conditions.conversationIds = [] + expect(match(rule, input({ conversationId: 'anyone@chatroom' }))).toBe(true) + }) + + it('关键词不匹配时不命中', () => { + expect(match(baseRule(), input({ content: '今天的会议纪要' }))).toBe(false) + expect(reasonOf(baseRule(), input({ content: '今天的会议纪要' }))).toBe('keyword') + }) + + it('关键词为空表示不限关键词', () => { + const rule = baseRule() + rule.conditions.keyword = '' + expect(match(rule, input({ content: '随便说点什么' }))).toBe(true) + }) + + it('exact 模式要求整条消息等于关键词', () => { + const rule = baseRule() + rule.conditions.keywordMatchMode = 'exact' + expect(match(rule, input({ content: '日报' }))).toBe(true) + expect(match(rule, input({ content: '今天的日报' }))).toBe(false) + }) + + it('prefix 模式要求消息以关键词开头', () => { + const rule = baseRule() + rule.conditions.keywordMatchMode = 'prefix' + expect(match(rule, input({ content: '日报 今天' }))).toBe(true) + expect(match(rule, input({ content: '今天的日报' }))).toBe(false) + }) + + it('不假设自己的 username 以 wxid_ 开头', () => { + // 把 selfUsernames 换成一个完全不符合 wxid_ 形态的值,依然要能精确匹配。 + const rule = baseRule() + expect( + matchAutomationRule(rule, input({ mentionTargets: ['plain_name'] }), ['plain_name']).matched + ).toBe(true) + }) + + it('selfUsernames 为空时,任何 @ 都不算 @我', () => { + expect(matchAutomationRule(baseRule(), input(), []).matched).toBe(false) + }) + + it('不需要 @我时,未 @ 也能因关键词命中', () => { + const rule = baseRule() + rule.conditions.requireMentionMe = false + expect(match(rule, input({ mentionTargets: [], content: '今天的日报呢' }))).toBe(true) + }) +}) + +describe('matchKeyword', () => { + it('大小写不敏感并忽略首尾空白', () => { + expect(matchKeyword(' Today Daily Report ', 'daily report', 'contains')).toBe(true) + }) + + it('空关键词视为不限制', () => { + expect(matchKeyword(undefined, ' ', 'contains')).toBe(true) + }) + + it('内容为空且关键词非空时不命中', () => { + expect(matchKeyword(undefined, '日报', 'contains')).toBe(false) + }) +}) + +describe('normalizeRuleDraft', () => { + it('把非法的匹配方式与范围收敛成安全默认值', () => { + const draft = normalizeRuleDraft({ + name: ' ', + scope: 'nonsense', + conditions: { keywordMatchMode: 'regex', keyword: 'x', conversationIds: ['a', '', 'b'] }, + actions: [{ type: 'replyText', enabled: true }], + cooldownSeconds: -5 + }) + expect(draft.name).toBe('未命名自动化') + expect(draft.scope).toBe('group') + expect(draft.conditions.keywordMatchMode).toBe('contains') + expect(draft.conditions.conversationIds).toEqual(['a', 'b']) + expect(draft.cooldownSeconds).toBe(0) + // 默认必须是「忽略自己」+「必须 @我」—— 这两条是防循环的底线。 + expect(draft.conditions.ignoreSelf).toBe(true) + expect(draft.conditions.requireMentionMe).toBe(true) + }) + + it('丢弃未知的 action 类型', () => { + const draft = normalizeRuleDraft({ + name: 'x', + conditions: {}, + actions: [{ type: 'generateReport', enabled: true }, { type: 'webhook', enabled: true }] + }) + expect(draft.actions.map((action) => action.type)).toEqual(['generateReport']) + }) +}) diff --git a/tests/unit/automation-rule-store.test.ts b/tests/unit/automation-rule-store.test.ts new file mode 100644 index 0000000..c9431bb --- /dev/null +++ b/tests/unit/automation-rule-store.test.ts @@ -0,0 +1,255 @@ +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-automation-rule-store-')) +}) + +vi.mock('electron', () => ({ app: { getPath: () => root } })) + +import { BUILTIN_DAILY_REPORT_RULE_ID } from '../../src/shared/automation' +import { + AutomationRuleStore, + type AutomationRuleStoreDependencies +} from '../../src/main/services/automation-rule-store' +import { AutomationExecutionLogService } from '../../src/main/services/automation-execution-log-service' +import type { AutomationExecution } from '../../src/shared/automation' + +const rulesFile = (): string => path.join(root, 'automation', 'rules.json') +const executionsFile = (): string => path.join(root, 'automation', 'executions.json') + +/** 只重定向目录,其余用真实实现 —— 目的是验证真实的读写与归一化。 */ +const deps = (dir: string, now?: () => number): AutomationRuleStoreDependencies => ({ + userDataPath: () => dir, + ...(now ? { now } : {}) +}) + +describe('AutomationRuleStore', () => { + beforeEach(() => { + fs.removeSync(path.join(root, 'automation')) + }) + + afterAll(() => fs.removeSync(root)) + + it('首次加载会播种内置规则「@我生成日报」并落盘', () => { + const store = new AutomationRuleStore(deps(root)) + const rules = store.listRules() + + expect(rules).toHaveLength(1) + const builtin = rules[0] + expect(builtin.id).toBe(BUILTIN_DAILY_REPORT_RULE_ID) + expect(builtin.name).toBe('@我生成日报') + expect(builtin.enabled).toBe(true) + expect(builtin.scope).toBe('group') + expect(builtin.conditions.requireMentionMe).toBe(true) + expect(builtin.conditions.keyword).toBe('日报') + expect(builtin.conditions.ignoreSelf).toBe(true) + expect(builtin.cooldownSeconds).toBe(60) + expect(builtin.actions.map((action) => action.type)).toEqual([ + 'replyText', + 'generateReport', + 'sendReportImage' + ]) + expect(fs.pathExistsSync(rulesFile())).toBe(true) + }) + + it('删掉内置规则后不会在下次启动时被重新塞回来', () => { + const store = new AutomationRuleStore(deps(root)) + expect(store.deleteRule(BUILTIN_DAILY_REPORT_RULE_ID)).toBe(true) + expect(store.listRules()).toHaveLength(0) + + // 关键:新实例(等于重启)不应该重新播种。 + const reopened = new AutomationRuleStore(deps(root)) + expect(reopened.listRules()).toHaveLength(0) + }) + + it('createRule 归一化脏输入并补上 id 与时间戳', () => { + const now = 1_700_000_000_000 + const store = new AutomationRuleStore(deps(root, () => now)) + const created = store.createRule({ + name: ' 关键词回复 ', + scope: 'bogus', + conditions: { keyword: '周报', keywordMatchMode: 'regex', conversationIds: ['a', ''] }, + actions: [{ type: 'replyText', enabled: true, text: '好的' }], + cooldownSeconds: 30 + }) + + expect(created.id).not.toBe(BUILTIN_DAILY_REPORT_RULE_ID) + expect(created.name).toBe('关键词回复') + expect(created.scope).toBe('group') + expect(created.conditions.keywordMatchMode).toBe('contains') + expect(created.conditions.conversationIds).toEqual(['a']) + expect(created.createdAt).toBe(now) + expect(created.updatedAt).toBe(now) + expect(store.listRules()).toHaveLength(2) + }) + + it('updateRule 保留 id 与 createdAt,刷新 updatedAt', () => { + let now = 1_000 + const store = new AutomationRuleStore(deps(root, () => now)) + const created = store.createRule({ name: 'A', conditions: {}, actions: [] }) + now = 2_000 + const updated = store.updateRule(created.id, { + name: 'B', + conditions: { keyword: 'x' }, + actions: [{ type: 'generateReport', enabled: true }] + }) + + expect(updated?.id).toBe(created.id) + expect(updated?.createdAt).toBe(1_000) + expect(updated?.updatedAt).toBe(2_000) + expect(updated?.name).toBe('B') + expect(updated?.conditions.keyword).toBe('x') + }) + + it('updateRule 名称为空时沿用原名,而不是变成「未命名自动化」', () => { + const store = new AutomationRuleStore(deps(root)) + const builtin = store.listRules()[0] + const updated = store.updateRule(builtin.id, { ...builtin, name: ' ' }) + expect(updated?.name).toBe('@我生成日报') + }) + + it('setRuleEnabled 只改开关', () => { + const store = new AutomationRuleStore(deps(root)) + const builtin = store.listRules()[0] + const disabled = store.setRuleEnabled(builtin.id, false) + expect(disabled?.enabled).toBe(false) + expect(disabled?.conditions.keyword).toBe('日报') + expect(store.listRules()[0].enabled).toBe(false) + }) + + it('对不存在的 id 做更新/启停返回 undefined,删除返回 false', () => { + const store = new AutomationRuleStore(deps(root)) + expect(store.updateRule('nope', { name: 'x' })).toBeUndefined() + expect(store.setRuleEnabled('nope', true)).toBeUndefined() + expect(store.deleteRule('nope')).toBe(false) + }) + + it('规则文件损坏时降级为空并重新播种,而不是让功能整体不可用', () => { + fs.ensureDirSync(path.dirname(rulesFile())) + fs.writeFileSync(rulesFile(), '{ 这不是 JSON', 'utf8') + + const store = new AutomationRuleStore(deps(root)) + expect(store.listRules()).toHaveLength(1) + expect(store.listRules()[0].id).toBe(BUILTIN_DAILY_REPORT_RULE_ID) + }) + + it('已播种但规则列表为空的存档不会被再次播种', () => { + fs.ensureDirSync(path.dirname(rulesFile())) + fs.writeJsonSync(rulesFile(), { version: 1, builtinSeeded: true, rules: [] }) + + const store = new AutomationRuleStore(deps(root)) + expect(store.listRules()).toHaveLength(0) + }) + + it('写入的是真实文件,重新打开能读到', () => { + const store = new AutomationRuleStore(deps(root)) + store.createRule({ name: '持久化检查', conditions: {}, actions: [] }) + + const reopened = new AutomationRuleStore(deps(root)) + expect(reopened.listRules().map((rule) => rule.name)).toContain('持久化检查') + }) +}) + +describe('AutomationExecutionLogService', () => { + beforeEach(() => { + fs.removeSync(path.join(root, 'automation')) + }) + + const execution = (overrides: Partial = {}): AutomationExecution => ({ + executionId: 'exec-1', + ruleId: 'rule-1', + ruleName: '测试规则', + triggerTime: 1_700_000_000_000, + sourceDisplayName: '测试群', + status: 'success', + durationMs: 1_234, + steps: [{ key: 'received', label: '收到消息', status: 'success' }], + ...overrides + }) + + it('记录并返回,新的在前', () => { + const log = new AutomationExecutionLogService({ userDataPath: () => root }) + log.record(execution({ executionId: 'a', ruleName: '规则A' })) + log.record(execution({ executionId: 'b', ruleName: '规则B' })) + + expect(log.list().map((item) => item.ruleName)).toEqual(['规则B', '规则A']) + }) + + it('相同 executionId 会覆盖而不是追加(先 running 后终态)', () => { + const log = new AutomationExecutionLogService({ userDataPath: () => root }) + log.record(execution({ status: 'running', steps: [] })) + log.record(execution({ status: 'failed', errorSummary: '生成日报失败' })) + + const records = log.list() + expect(records).toHaveLength(1) + expect(records[0].status).toBe('failed') + expect(records[0].errorSummary).toBe('生成日报失败') + }) + + it('容量有界,超出后丢弃最旧的', () => { + const log = new AutomationExecutionLogService({ userDataPath: () => root }) + for (let index = 0; index < 250; index += 1) { + log.record(execution({ executionId: `exec-${index}`, ruleName: `规则${index}` })) + } + const records = log.list({ limit: 1000 }) + expect(records).toHaveLength(200) + expect(records[0].ruleName).toBe('规则249') + expect(records.some((item) => item.ruleName === '规则49')).toBe(false) + }) + + it('countSince 只统计时间窗口内的记录', () => { + const log = new AutomationExecutionLogService({ userDataPath: () => root }) + log.record(execution({ executionId: 'old', triggerTime: 1_000, status: 'success' })) + log.record(execution({ executionId: 'new-ok', triggerTime: 5_000, status: 'success' })) + log.record(execution({ executionId: 'new-bad', triggerTime: 6_000, status: 'failed' })) + + expect(log.countSince(4_000)).toEqual({ total: 2, success: 1 }) + expect(log.countSince(0)).toEqual({ total: 3, success: 2 }) + }) + + it('clear 清空并落盘', () => { + const log = new AutomationExecutionLogService({ userDataPath: () => root }) + log.record(execution()) + expect(log.clear()).toBe(true) + expect(log.list()).toHaveLength(0) + expect(new AutomationExecutionLogService({ userDataPath: () => root }).list()).toHaveLength(0) + }) + + it('落盘后新实例能读到(重启后仍能看到近期执行记录)', () => { + const log = new AutomationExecutionLogService({ userDataPath: () => root }) + log.record(execution({ ruleName: '重启检查' })) + expect(fs.pathExistsSync(executionsFile())).toBe(true) + + const reloaded = new AutomationExecutionLogService({ userDataPath: () => root }) + expect(reloaded.list()[0].ruleName).toBe('重启检查') + }) + + it('文件损坏时降级为空数组而不是抛异常', () => { + fs.ensureDirSync(path.dirname(executionsFile())) + fs.writeFileSync(executionsFile(), 'not json at all', 'utf8') + const log = new AutomationExecutionLogService({ userDataPath: () => root }) + expect(log.list()).toEqual([]) + }) + + it('丢弃缺少 executionId 的脏记录', () => { + const log = new AutomationExecutionLogService({ userDataPath: () => root }) + log.record({ ...execution(), executionId: ' ' }) + expect(log.list()).toHaveLength(0) + }) + + it('list 的 limit 参数生效', () => { + const log = new AutomationExecutionLogService({ userDataPath: () => root }) + for (let index = 0; index < 10; index += 1) { + log.record(execution({ executionId: `exec-${index}` })) + } + expect(log.list({ limit: 3 })).toHaveLength(3) + }) +}) diff --git a/tests/unit/automation-service.test.ts b/tests/unit/automation-service.test.ts new file mode 100644 index 0000000..1dbb27c --- /dev/null +++ b/tests/unit/automation-service.test.ts @@ -0,0 +1,387 @@ +// `vi.mock` 的工厂会被提升到 import 之前执行,所以根目录必须用 `vi.hoisted` 先备好, +// 否则工厂里引用的 `root` 还在 TDZ 里。 +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-automation-service-')) +}) + +vi.mock('electron', () => ({ + app: { getPath: () => root }, + safeStorage: { + isEncryptionAvailable: () => false, + encryptString: (value: string) => Buffer.from(value, 'utf8'), + decryptString: (value: Buffer) => value.toString('utf8') + }, + BrowserWindow: { getAllWindows: () => [] } +})) + +import fs from 'fs-extra' +import path from 'path' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +import type { PersonalWechatSendCapability } from '../../src/shared/personal-wechat' +import type { Wcdb4Client } from '../../src/main/wcdb4-client' +import { AutomationService } from '../../src/main/services/automation-service' +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 type { NormalizedIncomingMessage } from '../../src/main/services/message-listener-service' +import type { AutomationRunInput, AutomationRunResult } from '../../src/main/services/automation-action-runner' + +/** + * AutomationService 的编排语义:四道闸 + 执行日志落盘 + 发送能力预检。 + */ + +const SELF = 'my_account_9527' +const GROUP_ID = '12345678@chatroom' +const OTHER_GROUP = '87654321@chatroom' + +/** 完整可用的发送能力替身(不用强转,避免类型加字段后静默失真)。 */ +function capability(overrides: Partial = {}): 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: '个人微信已准备好发送日报' + }, + ...overrides + } +} + +function message(overrides: Partial = {}): NormalizedIncomingMessage { + return { + sessionId: GROUP_ID, + localId: '100', + createTime: 1_789_900_000, + messageType: 1, + isSelf: false, + senderId: 'wxid_sender', + senderNickname: '张三', + content: '今日日报', + isGroup: true, + mentionTargets: [SELF], + ...overrides + } +} + +interface Harness { + service: AutomationService + store: AutomationRuleStore + log: AutomationExecutionLogService + runs: AutomationRunInput[] + setCapability: (value: PersonalWechatSendCapability) => void + setRunResult: (value: AutomationRunResult) => void +} + +function buildHarness(options: { now?: () => number; capability?: PersonalWechatSendCapability } = {}): Harness { + const store = new AutomationRuleStore() + const log = new AutomationExecutionLogService() + const runs: AutomationRunInput[] = [] + let currentCapability = options.capability ?? capability() + let runResult: AutomationRunResult = { + steps: [ + { key: 'received', label: '收到消息', status: 'success' }, + { key: 'matched', label: '规则匹配', status: 'success' }, + { key: 'reply', label: '回复确认', status: 'success' }, + { key: 'report', label: '生成日报', status: 'success' }, + { key: 'send', label: '发送日报图片', status: 'success' } + ], + status: 'success' + } + + // 只观察 Runner 被怎么调用,不在这里验证它的内部步骤(那是 runner 测试的职责)。 + const spyRunner = { + run: async (input: AutomationRunInput): Promise => { + runs.push(input) + return runResult + } + } as unknown as AutomationActionRunner + + const client = { + getMyUsernameCandidates: () => [SELF], + getSessions: () => [{ username: GROUP_ID, nickname: '测试群' }] + } as unknown as Wcdb4Client + + const service = new AutomationService(client, { + ruleStore: store, + executionLog: log, + runner: spyRunner, + getCapability: async () => currentCapability, + isListening: () => true, + ...(options.now ? { now: options.now } : {}) + }) + + return { + service, + store, + log, + runs, + setCapability: (value) => { + currentCapability = value + }, + setRunResult: (value) => { + runResult = value + } + } +} + +describe('AutomationService', () => { + beforeEach(() => { + fs.removeSync(path.join(root, 'automation')) + }) + + afterAll(() => fs.removeSync(root)) + + it('真 @ + 关键词命中时执行一次,并留下完整的执行记录', async () => { + const harness = buildHarness() + await harness.service.handleMessage(message()) + + expect(harness.runs).toHaveLength(1) + expect(harness.runs[0]).toMatchObject({ + conversationId: GROUP_ID, + isGroup: true, + sourceDisplayName: '测试群' + }) + + const records = harness.log.list() + expect(records).toHaveLength(1) + expect(records[0]).toMatchObject({ + ruleName: '@我生成日报', + sourceDisplayName: '测试群', + status: 'success' + }) + expect(records[0].steps.map((step) => step.key)).toEqual([ + 'received', + 'matched', + 'reply', + 'report', + 'send' + ]) + expect(records[0].executionId).toBe(harness.runs[0].executionId) + }) + + it('同一条消息被重复投递时只执行一次', async () => { + const harness = buildHarness() + await harness.service.handleMessage(message()) + await harness.service.handleMessage(message()) + await harness.service.handleMessage(message({ localId: '100' })) + + expect(harness.runs).toHaveLength(1) + expect(harness.log.list()).toHaveLength(1) + }) + + it('同一条消息在不同群里各算一次(localId 单独跨群不唯一)', async () => { + const harness = buildHarness() + await harness.service.handleMessage(message()) + await harness.service.handleMessage(message({ sessionId: OTHER_GROUP })) + + expect(harness.runs).toHaveLength(2) + }) + + it('cooldown 内不重复触发', async () => { + let now = 1_700_000_000_000 + const harness = buildHarness({ now: () => now }) + + await harness.service.handleMessage(message({ localId: '1' })) + now += 30_000 + await harness.service.handleMessage(message({ localId: '2' })) + + expect(harness.runs).toHaveLength(1) + }) + + it('cooldown 过后可以再次触发', async () => { + let now = 1_700_000_000_000 + const harness = buildHarness({ now: () => now }) + + await harness.service.handleMessage(message({ localId: '1' })) + now += 61_000 + await harness.service.handleMessage(message({ localId: '2' })) + + expect(harness.runs).toHaveLength(2) + }) + + it('自己发送的消息不触发(否则「收到,正在生成今日日报」会形成死循环)', async () => { + const harness = buildHarness() + await harness.service.handleMessage(message({ isSelf: true, content: '收到,正在生成今日日报' })) + + expect(harness.runs).toHaveLength(0) + expect(harness.log.list()).toHaveLength(0) + }) + + it('正文里手打 @昵称 但没有真 @ 元数据时不触发', async () => { + const harness = buildHarness() + await harness.service.handleMessage(message({ content: '@我 今日日报', mentionTargets: [] })) + expect(harness.runs).toHaveLength(0) + }) + + it('停用的规则不触发', async () => { + const harness = buildHarness() + const rule = harness.store.listRules()[0] + harness.store.setRuleEnabled(rule.id, false) + await harness.service.handleMessage(message()) + expect(harness.runs).toHaveLength(0) + }) + + it('不在生效范围内的群不触发', async () => { + const harness = buildHarness() + const rule = harness.store.listRules()[0] + harness.store.updateRule(rule.id, { ...rule, conditions: { ...rule.conditions, conversationIds: [OTHER_GROUP] } }) + await harness.service.handleMessage(message()) + expect(harness.runs).toHaveLength(0) + }) + + it('发送能力不足时不执行,并记下说明清楚的失败记录', async () => { + const harness = buildHarness({ + capability: capability({ + ready: false, + capabilities: { text: false, image: false, voice: false }, + message: '请先绑定个人微信' + }) + }) + await harness.service.handleMessage(message()) + + expect(harness.runs).toHaveLength(0) + const records = harness.log.list() + expect(records).toHaveLength(1) + expect(records[0].status).toBe('failed') + expect(records[0].errorSummary).toContain('当前环境无法发送文字') + }) + + it('只能发文字、不能发图片时不执行(不能假装日报发得出去)', async () => { + const harness = buildHarness({ + capability: capability({ capabilities: { text: true, image: false, voice: false } }) + }) + await harness.service.handleMessage(message()) + + expect(harness.runs).toHaveLength(0) + expect(harness.log.list()[0].errorSummary).toContain('当前环境无法发送图片') + }) + + it('拿不到会话昵称时也不把 wxid 写进执行记录', async () => { + const store = new AutomationRuleStore() + const log = new AutomationExecutionLogService() + const client = { + getMyUsernameCandidates: () => [SELF], + getSessions: () => [] + } as unknown as Wcdb4Client + const service = new AutomationService(client, { + ruleStore: store, + executionLog: log, + runner: { + run: async (input: AutomationRunInput) => ({ + steps: [], + status: 'success' as const, + ...(input ? {} : {}) + }) + } as unknown as AutomationActionRunner, + getCapability: async () => capability() + }) + + await service.handleMessage(message()) + const record = log.list()[0] + expect(record.sourceDisplayName).toBe('群聊') + expect(JSON.stringify(record)).not.toContain('@chatroom') + expect(JSON.stringify(record)).not.toContain('wxid') + }) + + it('执行记录里不出现 localId / serverId / source 等内部字段', async () => { + const harness = buildHarness() + await harness.service.handleMessage( + message({ serverId: '7000000000000000001', localId: '424242' }) + ) + const serialized = JSON.stringify(harness.log.list()) + expect(serialized).not.toContain('424242') + expect(serialized).not.toContain('7000000000000000001') + expect(serialized).not.toContain('localId') + expect(serialized).not.toContain('serverId') + }) + + it('执行失败时把失败摘要带进执行记录', async () => { + const harness = buildHarness() + harness.setRunResult({ + steps: [ + { key: 'received', label: '收到消息', status: 'success' }, + { key: 'matched', label: '规则匹配', status: 'success' }, + { key: 'reply', label: '回复确认', status: 'success' }, + { key: 'report', label: '生成日报', status: 'failed', error: 'AI 服务不可达' }, + { key: 'send', label: '发送日报图片', status: 'skipped' } + ], + status: 'failed', + errorSummary: 'AI 服务不可达' + }) + await harness.service.handleMessage(message()) + + const record = harness.log.list()[0] + expect(record.status).toBe('failed') + expect(record.errorSummary).toBe('AI 服务不可达') + expect(record.steps.find((step) => step.key === 'send')?.status).toBe('skipped') + }) + + it('执行日志落盘后能被新的实例读到(重启不丢)', async () => { + const harness = buildHarness() + await harness.service.handleMessage(message()) + const reloaded = new AutomationExecutionLogService() + expect(reloaded.list()).toHaveLength(1) + expect(reloaded.list()[0].ruleName).toBe('@我生成日报') + }) + + it('getStatus 如实反映监听状态、发送能力与今日执行数', async () => { + const harness = buildHarness() + await harness.service.handleMessage(message()) + const status = await harness.service.getStatus() + + expect(status.listening).toBe(true) + // 底层只能回读最近活跃会话,这个降级标记必须一直是 true。 + expect(status.listeningDegraded).toBe(true) + expect(status.todayExecutions).toBe(1) + expect(status.todaySuccesses).toBe(1) + expect(status.sendCapability).toMatchObject({ + supported: true, + ready: true, + canSendText: true, + canSendImage: true + }) + }) + + it('handleMessage 内部异常不会向外抛', async () => { + const harness = buildHarness() + const brokenStore = { + listRules: () => { + throw new Error('磁盘炸了') + } + } as unknown as AutomationRuleStore + const service = new AutomationService( + { getMyUsernameCandidates: () => [SELF], getSessions: () => [] } as unknown as Wcdb4Client, + { ruleStore: brokenStore } + ) + await expect(service.handleMessage(message())).resolves.toBeUndefined() + void harness + }) +}) diff --git a/tests/unit/message-listener-service.test.ts b/tests/unit/message-listener-service.test.ts new file mode 100644 index 0000000..bfd71ed --- /dev/null +++ b/tests/unit/message-listener-service.test.ts @@ -0,0 +1,346 @@ +import { zstdCompressSync } from 'node:zlib' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + MessageListenerService, + decodeSourcePayload, + extractMentionTargets, + type NormalizedIncomingMessage +} from '../../src/main/services/message-listener-service' +import type { Wcdb4Client, Wcdb4Message } from '../../src/main/wcdb4-client' + +/** + * MessageListener 底座测试。 + * + * 覆盖 Spike 里用真实环境验证过的每一条行为,确保「正式实现 ≈ Spike 等价」。 + * 所有身份标识都用 `SELF_USER` / `OTHER_USER` 占位 —— 测试里**不得**出现真实 wxid。 + */ + +const SESSION_GROUP = 'group_A@chatroom' +const SESSION_DIRECT = 'user_A' + +const makeMessage = (overrides: Partial = {}): Wcdb4Message => ({ + mesLocalID: '100', + serverId: '7000000000000000001', + mesDes: 1, + // ⚠️ WCDB 层这几个字段是 **string**(见 Wcdb4Message 定义), + // 归一化时才转 number。mock 必须跟随真实类型,否则类型检查会红。 + messageType: '1', + msgCreateTime: '1789900000', + msgContent: 'hello', + sender: 'OTHER_USER', + senderNickname: '对方昵称', + senderAvatar: '', + raw: {}, + ...overrides +}) + +/** + * zstd 压缩一段文本,用来构造真实的 `source` 列形态。 + * + * 用 **Node 内置 `zlib`** 而不是被测代码依赖的 `fzstd`: + * 这样测试侧的「压缩」与实现侧的「解压」是**两条独立的实现路径**, + * 不会因为同一个库的同一个缺陷而互相掩盖。 + * (`fzstd` 本身只提供解压,没有压缩接口。) + */ +async function zstdBuffer(text: string): Promise { + return zstdCompressSync(Buffer.from(text, 'utf8')) +} + +function makeClient(messages: Wcdb4Message[], username = SESSION_GROUP) { + return { + getSessions: vi.fn(() => [{ username }]), + getMessagesAsync: vi.fn(async () => messages) + } as unknown as Wcdb4Client +} + +let received: NormalizedIncomingMessage[] = [] + +const collect = (listener: MessageListenerService): void => { + listener.onMessage((message) => received.push(message)) +} + +/** 推进 coalesce 窗口并等待回读完成。 */ +const flush = async (ms = 200): Promise => { + await vi.advanceTimersByTimeAsync(ms) +} + +beforeEach(() => { + received = [] + vi.useFakeTimers() +}) + +afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() +}) + +describe('mention parser(纯函数)', () => { + it('CDATA 形式:剥掉外壳,只留 username', () => { + const source = + '1' + expect(extractMentionTargets(source)).toEqual(['SELF_USER']) + }) + + it('裸文本形式同样支持', () => { + const source = 'OTHER_USER' + expect(extractMentionTargets(source)).toEqual(['OTHER_USER']) + }) + + it('没有 atuserlist 时返回空数组', () => { + expect(extractMentionTargets('1')).toEqual([]) + expect(extractMentionTargets(undefined)).toEqual([]) + expect(extractMentionTargets('')).toEqual([]) + }) + + it('假 @:正文里有 @昵称,但 source 没有 atuserlist ⇒ 不产生 mention', () => { + const source = '134' + expect(extractMentionTargets(source)).toEqual([]) + }) + + it('不假设 username 以 wxid_ 开头(本机账号的 username 可能完全是别的形式)', () => { + const source = 'SELF_USER' + // 若实现里写了 `startsWith('wxid_')` 过滤,这条会变成 [] + expect(extractMentionTargets(source)).toEqual(['SELF_USER']) + }) +}) + +describe('decodeSourcePayload', () => { + it('zstd 压缩的 Buffer 能解出 XML', async () => { + const xml = 'SELF_USER' + const buffer = await zstdBuffer(xml) + // 魔数确认:确保我们构造的确实是 zstd + expect([...buffer.subarray(0, 4)]).toEqual([0x28, 0xb5, 0x2f, 0xfd]) + expect(decodeSourcePayload(buffer)).toBe(xml) + }) + + it('Node Buffer 的 JSON 形态({type:"Buffer",data:[...]})也能解', async () => { + const xml = 'SELF_USER' + const buffer = await zstdBuffer(xml) + const jsonForm = JSON.parse(JSON.stringify(buffer)) as { type: string; data: number[] } + expect(decodeSourcePayload(jsonForm)).toBe(xml) + }) + + it('空值 / 非 zstd 内容安全返回', () => { + expect(decodeSourcePayload(undefined)).toBeUndefined() + expect(decodeSourcePayload(Buffer.alloc(0))).toBeUndefined() + expect(decodeSourcePayload(Buffer.from('plain text'))).toBe('plain text') + }) +}) + +describe('coalesce:一批 native event 只回读一次', () => { + it('同一窗口收到 17 个 event,只产生 1 次回读、消息只投递 1 次', async () => { + const client = makeClient([makeMessage()]) + const listener = new MessageListenerService(client) + collect(listener) + + // 模拟实测观察到的「一条消息 ≈ 17 个 native event」 + for (let index = 0; index < 17; index += 1) listener.handleNativeChange() + await flush() + + expect((client as unknown as { getMessagesAsync: ReturnType }).getMessagesAsync) + .toHaveBeenCalledTimes(1) + expect(received).toHaveLength(1) + expect(listener.stats()).toMatchObject({ + nativeEvents: 17, + coalescedEvents: 16, + readbacks: 1, + delivered: 1 + }) + }) +}) + +describe('dedup', () => { + it('同一 sessionId:localId 被回读多次,只投递一次', async () => { + const client = makeClient([makeMessage({ mesLocalID: '100' })]) + const listener = new MessageListenerService(client) + collect(listener) + + // 三个分开的窗口,每次都读到同一条消息 + for (let round = 0; round < 3; round += 1) { + listener.handleNativeChange() + await flush() + } + + expect(received).toHaveLength(1) + expect(listener.stats().delivered).toBe(1) + expect(listener.stats().deduped).toBeGreaterThanOrEqual(2) + }) + + it('sessionId 相同但 localId 不同 ⇒ 不是同一条', async () => { + const client = makeClient([makeMessage({ mesLocalID: '100' })]) + const listener = new MessageListenerService(client) + collect(listener) + + listener.handleNativeChange() + await flush() + // 换成另一个 localId 再读一轮 + ;(client as unknown as { getMessagesAsync: ReturnType }).getMessagesAsync.mockResolvedValue( + [makeMessage({ mesLocalID: '101' })] + ) + listener.handleNativeChange() + await flush() + + expect(received.map((message) => message.localId)).toEqual(['100', '101']) + }) + + it('dedup 缓存有界:超过上限会淘汰最旧条目', async () => { + const client = makeClient([]) + const listener = new MessageListenerService(client, { dedupMaxEntries: 3 }) + collect(listener) + + const send = async (localId: string): Promise => { + ;(client as unknown as { getMessagesAsync: ReturnType }).getMessagesAsync.mockResolvedValue( + [makeMessage({ mesLocalID: localId })] + ) + listener.handleNativeChange() + await flush() + } + + for (const id of ['1', '2', '3', '4', '5']) await send(id) + + // 5 条都投递过(容量 3 只会影响「还能记住多少」) + expect(received).toHaveLength(5) + // 最早的两条已被淘汰 ⇒ 再读到 '1' 时会重新投递 + await send('1') + expect(received.filter((message) => message.localId === '1')).toHaveLength(2) + }) +}) + +describe('连续消息', () => { + it('同一窗口读到 3 条,必须全部投递(不能只取最后一条)', async () => { + const client = makeClient([ + makeMessage({ mesLocalID: '100' }), + makeMessage({ mesLocalID: '101' }), + makeMessage({ mesLocalID: '102' }) + ]) + const listener = new MessageListenerService(client) + collect(listener) + + listener.handleNativeChange() + await flush() + + expect(received.map((message) => message.localId)).toEqual(['100', '101', '102']) + expect(listener.stats().delivered).toBe(3) + }) +}) + +describe('self / incoming', () => { + it('mesDes = 0 ⇒ isSelf = true', async () => { + const client = makeClient([makeMessage({ mesDes: 0, sender: 'SELF_USER' })]) + const listener = new MessageListenerService(client) + collect(listener) + + listener.handleNativeChange() + await flush() + + expect(received[0].isSelf).toBe(true) + }) + + it('mesDes = 1 ⇒ isSelf = false', async () => { + const client = makeClient([makeMessage({ mesDes: 1 })]) + const listener = new MessageListenerService(client) + collect(listener) + + listener.handleNativeChange() + await flush() + + expect(received[0].isSelf).toBe(false) + }) +}) + +describe('normalize 输出', () => { + it('群消息:isGroup = true,且 mentionTargets 来自 source', async () => { + const source = '' + const client = makeClient([ + makeMessage({ raw: { source: await zstdBuffer(source) } }) + ]) + const listener = new MessageListenerService(client) + collect(listener) + + listener.handleNativeChange() + await flush() + + const message = received[0] + expect(message.isGroup).toBe(true) + expect(message.mentionTargets).toEqual(['SELF_USER']) + expect(message.source).toContain('atuserlist') + }) + + it('私聊:isGroup = false,无 source 时 mentionTargets 为空', async () => { + const client = makeClient([makeMessage({ raw: {} })], SESSION_DIRECT) + const listener = new MessageListenerService(client) + collect(listener) + + listener.handleNativeChange() + await flush() + + expect(received[0].isGroup).toBe(false) + expect(received[0].mentionTargets).toEqual([]) + }) + + it('createTime 保持 epoch 秒(不做毫秒换算)', async () => { + const client = makeClient([makeMessage({ msgCreateTime: '1789900000' })]) + const listener = new MessageListenerService(client) + collect(listener) + + listener.handleNativeChange() + await flush() + + expect(received[0].createTime).toBe(1_789_900_000) + expect(received[0].createTime).toBeLessThan(1e11) + }) + + it('缺关键标识(无 localId 或无 createTime)的行不投递', async () => { + const client = makeClient([ + makeMessage({ mesLocalID: '' }), + makeMessage({ mesLocalID: '200', msgCreateTime: '0' }), + makeMessage({ mesLocalID: '201' }) + ]) + const listener = new MessageListenerService(client) + collect(listener) + + listener.handleNativeChange() + await flush() + + expect(received.map((message) => message.localId)).toEqual(['201']) + }) +}) + +describe('Observation Mode 约束', () => { + it('没有订阅者时不会崩,也不会产生投递', async () => { + const client = makeClient([makeMessage()]) + const listener = new MessageListenerService(client) + + listener.handleNativeChange() + await flush() + + expect(listener.stats().readbacks).toBe(1) + expect(listener.stats().delivered).toBe(1) + expect(received).toHaveLength(0) + }) + + it('dispose 之后不再响应事件', async () => { + const client = makeClient([makeMessage()]) + const listener = new MessageListenerService(client) + collect(listener) + + listener.dispose() + listener.handleNativeChange() + await flush() + + expect(received).toHaveLength(0) + expect(listener.stats().nativeEvents).toBe(0) + }) + + it('取消订阅后不再收到消息', async () => { + const client = makeClient([makeMessage()]) + const listener = new MessageListenerService(client) + const unsubscribe = listener.onMessage((message) => received.push(message)) + + unsubscribe() + listener.handleNativeChange() + await flush() + + expect(received).toHaveLength(0) + }) +})