feat: 自动化

This commit is contained in:
Wxw-Gu
2026-09-21 00:35:32 +08:00
parent 766cd94788
commit 95384a4a92
32 changed files with 6420 additions and 1 deletions
+106
View File
@@ -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<typeof getAutomationService> | 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',
@@ -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<AgentGroupReportResult>
executeAction?: (request: WechatActionRequest) => Promise<WechatActionResult>
now?: () => number
}
/** 策略层的错误码 → 用户可读短句。UI 直接展示这些文案,不做二次翻译。 */
const ACTION_ERROR_MESSAGES: Record<string, string> = {
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<AgentGroupReportResult>
private readonly executeAction: (request: WechatActionRequest) => Promise<WechatActionResult>
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<AutomationRunResult> {
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()
@@ -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<AutomationExecution>
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()
+166
View File
@@ -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<StoredRules>
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()
+422
View File
@@ -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<PersonalWechatSendCapability>
/** 由主进程注入: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<PersonalWechatSendCapability>
private readonly isListening: () => boolean
private readonly now: () => number
/** `ruleId:sessionId:localId` → 登记时间。 */
private readonly claims = new Map<string, ClaimEntry>()
/** `${ruleId}:${conversationId}` → 上次真正开始执行的毫秒时间戳。 */
private readonly cooldowns = new Map<string, number>()
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<void> {
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<AutomationStatusSummary> {
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<void> {
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<ReturnType<AutomationActionRunner['run']>>
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<string | undefined> {
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
}
@@ -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
* <atuserlist><![CDATA[SELF_USERNAME]]></atuserlist>
* <atuserlist>SELF_USERNAME</atuserlist>
* ```
*
* ⚠️ 三条硬规则:
* 1. **CDATA 外壳必须剥掉**,否则拿到的是 `<![CDATA[xxx]]>` 字面量;
* 2. **值就是微信 username,不保证以 `wxid_` 开头** —— 绝不能用前缀做过滤/校验;
* 3. **不允许多人分隔符的猜测** —— 多人 @ 的格式尚未在真实样本中验证过,
* 这里按「单个值原样返回」处理;一旦拿到真实多人样本需补解析与测试
* (当前形状已经预留为 `string[]`)。
*/
export function extractMentionTargets(source: string | undefined): string[] {
const text = String(source ?? '')
if (!text) return []
const match = /<atuserlist>([\s\S]*?)<\/atuserlist>/i.exec(text)
if (!match) return []
const inner = match[1].trim()
const unwrapped = inner.replace(/^<!\[CDATA\[/, '').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<string, number>()
private coalesceTimer: ReturnType<typeof setTimeout> | 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<void> {
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<string, unknown>
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 / 发送。
*/
+9 -1
View File
@@ -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<PersonalWechatSendCapability>
+15
View File
@@ -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<GroupExitMonitorState>
markGroupExitMonitorRead: (readAt?: number) => Promise<GroupExitMonitorState>
listWechatActionLogs: () => Promise<ActionLogEntry[]>
getAutomationStatus: () => Promise<AutomationStatusSummary>
listAutomationRules: () => Promise<AutomationRule[]>
createAutomationRule: (draft: AutomationRuleDraft) => Promise<AutomationRule>
updateAutomationRule: (id: string, draft: AutomationRuleDraft) => Promise<AutomationRule | null>
deleteAutomationRule: (id: string) => Promise<boolean>
setAutomationRuleEnabled: (id: string, enabled: boolean) => Promise<AutomationRule | null>
listAutomationExecutions: (query?: { limit?: number }) => Promise<AutomationExecution[]>
clearAutomationExecutions: () => Promise<boolean>
listAutomationGroups: () => Promise<Array<{ id: string; name: string }>>
onGroupExitMonitorState: (callback: (state: GroupExitMonitorState) => void) => () => void
search: (keyword: string) => Promise<string | null>
searchKnowledge: (request: KnowledgeSearchIpcRequest) => Promise<KnowledgeSearchIpcResult>
+25
View File
@@ -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<ActionLogEntry[]> =>
ipcRenderer.invoke('wechat-action-log:list'),
// ---- Automation v1(@我生成日报)。命名与既有扁平风格一致。 ----
getAutomationStatus: (): Promise<AutomationStatusSummary> =>
ipcRenderer.invoke('automation:getStatus'),
listAutomationRules: (): Promise<AutomationRule[]> => ipcRenderer.invoke('automation:listRules'),
createAutomationRule: (draft: AutomationRuleDraft): Promise<AutomationRule> =>
ipcRenderer.invoke('automation:createRule', draft),
updateAutomationRule: (id: string, draft: AutomationRuleDraft): Promise<AutomationRule | null> =>
ipcRenderer.invoke('automation:updateRule', { id, draft }),
deleteAutomationRule: (id: string): Promise<boolean> =>
ipcRenderer.invoke('automation:deleteRule', id),
setAutomationRuleEnabled: (id: string, enabled: boolean): Promise<AutomationRule | null> =>
ipcRenderer.invoke('automation:setRuleEnabled', { id, enabled }),
listAutomationExecutions: (query?: { limit?: number }): Promise<AutomationExecution[]> =>
ipcRenderer.invoke('automation:listExecutions', query),
clearAutomationExecutions: (): Promise<boolean> =>
ipcRenderer.invoke('automation:clearExecutions'),
/** 「在哪些聊天生效」的可选项。`id` 为 `xxx@chatroom`,与规则内 conversationIds 同口径。 */
listAutomationGroups: (): Promise<Array<{ id: string; name: string }>> =>
ipcRenderer.invoke('automation:listGroups'),
onGroupExitMonitorState: (callback: (state: GroupExitMonitorState) => void) => {
const listener = (_event: Electron.IpcRendererEvent, state: GroupExitMonitorState): void =>
callback(state)
+8
View File
@@ -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 (
<AutomationWorkspace
dbReady={isDatabaseConnected}
onOpenSendSettings={openWechatSendSettings}
/>
)
case 'agent-hub':
return <AgentHubWorkspace selfInfo={selfInfo} />
case 'api':
@@ -54,6 +54,13 @@ function NavIcon({ page }: NavIconProps): React.ReactElement {
<path d="M5.5 15.5v3h13v-3" />
</svg>
)
case 'automation':
// 闪电:强调「命中即自动执行」,与退群监控的箭头区分开。
return (
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M13 4.5 7.5 13h4l-.5 6.5L16.5 11h-4z" />
</svg>
)
case 'agent-hub':
return (
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
@@ -3,6 +3,7 @@ export type AppPage =
| 'search'
| 'report'
| 'exit-monitor'
| 'automation'
| 'agent-hub'
| 'export'
| 'api'
@@ -18,6 +19,7 @@ export const PRIMARY_NAV_ITEMS: NavigationItem[] = [
{ id: 'search', label: '问问微信' },
{ id: 'report', label: '日报' },
{ id: 'exit-monitor', label: '退群监控' },
{ id: 'automation', label: '自动化' },
{ id: 'agent-hub', label: 'Agent' },
{ id: 'export', label: '导出' },
{ id: 'api', label: 'API' },
@@ -0,0 +1,300 @@
import * as React from 'react'
import type {
AutomationExecution,
AutomationRule,
AutomationRuleDraft,
AutomationStatusSummary
} from '../../../../shared/automation'
import {
Button,
SegmentedControl,
SegmentedControlItem,
Tooltip,
TooltipContent,
TooltipTrigger,
useToast
} from '../../components/ui'
import { RuleListPanel } from './RuleListPanel'
import { RuleEditorPanel } from './RuleEditorPanel'
import { ExecutionLogPanel } from './ExecutionLogPanel'
import { ExecutionDetailDrawer } from './ExecutionDetailDrawer'
import { UNAVAILABLE_STATUS, automationApi, isAutomationApiAvailable, type AutomationGroupOption } from './model/api'
/**
* AutomationWorkspace —— 自动化一级菜单。
*
* 两个 tab:规则 / 执行日志。顶部三块状态(监听 / 发送能力 / 今日执行)
* **全部来自 main 的真实能力**,渲染层不做任何平台猜测 ——
* 粗平台判断在这里是错的:装了 macOS 但没绑定个人微信,照样发不出去。
*/
type AutomationTab = 'rules' | 'logs'
export interface AutomationWorkspaceProps {
dbReady: boolean
/** 发送能力不可用时,引导用户去设置页。 */
onOpenSendSettings?: () => void
}
interface EditorState {
mode: 'create' | 'edit'
rule: AutomationRule | null
}
export function AutomationWorkspace({
dbReady,
onOpenSendSettings
}: AutomationWorkspaceProps): React.ReactElement {
const { toast } = useToast()
const apiAvailable = React.useMemo(() => isAutomationApiAvailable(), [])
const [tab, setTab] = React.useState<AutomationTab>('rules')
const [status, setStatus] = React.useState<AutomationStatusSummary>(UNAVAILABLE_STATUS)
const [rules, setRules] = React.useState<AutomationRule[]>([])
const [groups, setGroups] = React.useState<AutomationGroupOption[]>([])
const [executions, setExecutions] = React.useState<AutomationExecution[]>([])
const [loading, setLoading] = React.useState(true)
const [clearing, setClearing] = React.useState(false)
const [saving, setSaving] = React.useState(false)
const [busyRuleId, setBusyRuleId] = React.useState<string | null>(null)
const [editor, setEditor] = React.useState<EditorState | null>(null)
const [selectedExecution, setSelectedExecution] = React.useState<AutomationExecution | null>(null)
const [pendingDelete, setPendingDelete] = React.useState<AutomationRule | null>(null)
const reload = React.useCallback(async (): Promise<void> => {
const [nextStatus, nextRules, nextGroups, nextExecutions] = await Promise.all([
automationApi.getStatus(),
automationApi.listRules(),
automationApi.listGroups(),
automationApi.listExecutions(100)
])
setStatus(nextStatus)
setRules(nextRules)
setGroups(nextGroups)
setExecutions(nextExecutions)
}, [])
React.useEffect(() => {
let disposed = false
setLoading(true)
void reload().finally(() => {
if (!disposed) setLoading(false)
})
return () => {
disposed = true
}
}, [reload])
// 切到日志 tab 时刷新一次:规则跑完后台不会主动推事件给渲染层,
// 用户点进来看到的是上一次加载的快照才是真的误导。
React.useEffect(() => {
if (tab !== 'logs') return
void reload()
}, [tab, reload])
const handleToggle = async (rule: AutomationRule, enabled: boolean): Promise<void> => {
setBusyRuleId(rule.id)
const updated = await automationApi.setRuleEnabled(rule.id, enabled)
setBusyRuleId(null)
if (!updated) {
toast({ description: '切换失败,规则状态未改变', variant: 'destructive', duration: 3200 })
return
}
setRules((current) => current.map((item) => (item.id === updated.id ? updated : item)))
void automationApi.getStatus().then(setStatus)
}
const handleSave = async (draft: AutomationRuleDraft): Promise<void> => {
if (!editor) return
setSaving(true)
const saved =
editor.mode === 'create'
? await automationApi.createRule(draft)
: await automationApi.updateRule(editor.rule?.id ?? '', draft)
setSaving(false)
if (!saved) {
toast({ description: '保存失败,请稍后重试', variant: 'destructive', duration: 3200 })
return
}
setRules((current) => {
const exists = current.some((item) => item.id === saved.id)
return exists
? current.map((item) => (item.id === saved.id ? saved : item))
: [...current, saved]
})
setEditor(null)
toast({
description: editor.mode === 'create' ? `已创建「${saved.name}」` : `已保存「${saved.name}」`,
duration: 2800
})
void automationApi.getStatus().then(setStatus)
}
const confirmDelete = async (): Promise<void> => {
if (!pendingDelete) return
const target = pendingDelete
setPendingDelete(null)
setBusyRuleId(target.id)
const removed = await automationApi.deleteRule(target.id)
setBusyRuleId(null)
if (!removed) {
toast({ description: '删除失败,规则仍然存在', variant: 'destructive', duration: 3200 })
return
}
setRules((current) => current.filter((item) => item.id !== target.id))
toast({ description: `已删除「${target.name}」`, duration: 2800 })
}
const handleClearExecutions = async (): Promise<void> => {
setClearing(true)
const cleared = await automationApi.clearExecutions()
setClearing(false)
if (!cleared) {
toast({ description: '清空失败,请稍后重试', variant: 'destructive', duration: 3200 })
return
}
setExecutions([])
void automationApi.getStatus().then(setStatus)
}
const capability = status.sendCapability
const capabilityReady = capability.ready && capability.canSendText && capability.canSendImage
const showEditor = tab === 'rules' && editor !== null
return (
<div className="automation-page">
<header className="automation-page-header">
<div>
<h1>自动化</h1>
<p>当群里出现符合条件的消息时,TraceMemo 会自动执行你配置的动作。</p>
</div>
{tab === 'rules' && !showEditor ? (
<Button onClick={() => setEditor({ mode: 'create', rule: null })}>新建自动化</Button>
) : null}
</header>
{!apiAvailable ? (
<p className="automation-notice warning">
自动化接口尚未就绪(可能正在启动或版本不匹配)。下面的数据可能不是最新的。
</p>
) : null}
{!dbReady ? (
<p className="automation-notice warning">
微信数据库尚未连接,规则可以编辑,但无法读取群列表,也不会触发。
</p>
) : null}
<section className="automation-status-bar" aria-label="自动化状态">
<div className="automation-status-card">
<span className="automation-status-label">消息监听</span>
<span className={`automation-status-value ${status.listening ? 'ok' : 'off'}`}>
{status.listening ? '正在监听新消息' : '未在监听'}
</span>
{status.listeningDegraded ? (
<Tooltip>
<TooltipTrigger asChild>
<small className="automation-status-note" tabIndex={0}>
当前仅能捕获最近活跃的会话
</small>
</TooltipTrigger>
<TooltipContent side="bottom">
微信底层只会上报「有表发生了变化」,不带是哪条会话。TraceMemo
目前据此回读最近活跃的会话,因此同一时刻多个群同时来消息时,只会处理其中的一个。
</TooltipContent>
</Tooltip>
) : null}
</div>
<div className="automation-status-card">
<span className="automation-status-label">发送能力</span>
<span className={`automation-status-value ${capabilityReady ? 'ok' : 'warn'}`}>
{capabilityReady ? '可以发送文字和图片' : capability.ready ? '发送能力不完整' : '尚未就绪'}
</span>
<small className="automation-status-note">{capability.message}</small>
{!capabilityReady && onOpenSendSettings ? (
<Button variant="link" size="sm" onClick={onOpenSendSettings}>
去设置发送能力
</Button>
) : null}
</div>
<div className="automation-status-card">
<span className="automation-status-label">今日执行</span>
<span className="automation-status-value">
{status.todaySuccesses} / {status.todayExecutions}
</span>
<small className="automation-status-note">成功 / 总计</small>
</div>
</section>
{!showEditor ? (
<SegmentedControl
value={tab}
onValueChange={(value) => setTab(value as AutomationTab)}
aria-label="自动化视图"
>
<SegmentedControlItem value="rules">规则</SegmentedControlItem>
<SegmentedControlItem value="logs">执行日志</SegmentedControlItem>
</SegmentedControl>
) : null}
{showEditor && editor ? (
<RuleEditorPanel
mode={editor.mode}
rule={editor.rule}
groups={groups}
saving={saving}
onCancel={() => setEditor(null)}
onSave={(draft) => void handleSave(draft)}
/>
) : tab === 'rules' ? (
<RuleListPanel
rules={rules}
groups={groups}
loading={loading}
busyRuleId={busyRuleId}
onToggle={(rule, enabled) => void handleToggle(rule, enabled)}
onEdit={(rule) => setEditor({ mode: 'edit', rule })}
onDelete={(rule) => setPendingDelete(rule)}
onCreate={() => setEditor({ mode: 'create', rule: null })}
/>
) : (
<ExecutionLogPanel
executions={executions}
loading={loading}
clearing={clearing}
onSelect={setSelectedExecution}
onClear={() => void handleClearExecutions()}
/>
)}
<ExecutionDetailDrawer
execution={selectedExecution}
onClose={() => setSelectedExecution(null)}
/>
{pendingDelete ? (
<div className="automation-confirm-layer" role="presentation" onClick={() => setPendingDelete(null)}>
<div
className="automation-confirm"
role="alertdialog"
aria-modal="true"
aria-label="删除自动化"
onClick={(event) => event.stopPropagation()}
>
<h2>删除「{pendingDelete.name}」?</h2>
<p>删除后不会再触发,已有的执行记录会保留。</p>
<div className="automation-confirm-actions">
<Button variant="ghost" onClick={() => setPendingDelete(null)}>
取消
</Button>
<Button variant="destructive" onClick={() => void confirmDelete()}>
删除
</Button>
</div>
</div>
</div>
) : null}
</div>
)
}
@@ -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 (
<section className="automation-preview" aria-label="效果模拟预览">
<div className="automation-preview-heading">
<h3>效果预览</h3>
<span className="automation-preview-badge">仅预览,不会真实发送</span>
</div>
<div className="automation-preview-phone">
<div className="automation-preview-chat">
{/* 左侧:触发消息 */}
<div className="automation-chat-row incoming">
<span className="automation-chat-avatar" aria-hidden="true">
{SAMPLE_SENDER_NAME.slice(0, 1)}
</span>
<div className="automation-chat-body">
<span className="automation-chat-name">{SAMPLE_SENDER_NAME}</span>
<span className="automation-chat-bubble">{sampleIncomingText(draft)}</span>
</div>
</div>
{mentionMe ? (
<p className="automation-preview-hint">
只有当对方<strong>真正</strong> @ 你时才会触发;正文里手打的「@昵称」不算。
</p>
) : (
<p className="automation-preview-hint">
当前不要求 @你,该会话内任何含关键词的消息都会触发。
</p>
)}
{/* 右侧:自动回复 */}
{hasReply ? (
<div className="automation-chat-row outgoing">
<div className="automation-chat-body">
<span className="automation-chat-name">我</span>
<span className="automation-chat-bubble">{replyText(draft)}</span>
</div>
<span className="automation-chat-avatar self" aria-hidden="true">
我
</span>
</div>
) : (
<p className="automation-preview-hint muted">未启用「回复确认」,命中后不会先回一句。</p>
)}
{/* 右侧:日报图片 */}
{showReportImage ? (
<div className="automation-chat-row outgoing">
<div className="automation-chat-body">
<span className="automation-chat-name">我</span>
<figure className="automation-chat-image">
<div className="automation-chat-image-canvas" aria-hidden="true">
<span className="automation-chat-image-title">群聊日报</span>
<span className="automation-chat-image-line" />
<span className="automation-chat-image-line short" />
<span className="automation-chat-image-line" />
<span className="automation-chat-image-line short" />
</div>
<figcaption>日报图片预览</figcaption>
</figure>
</div>
<span className="automation-chat-avatar self" aria-hidden="true">
我
</span>
</div>
) : (
<p className="automation-preview-hint muted">
{!hasReport
? '未启用「生成日报」,命中后不会产出日报。'
: '日报仍会生成,但未启用发送,不会发到群里。'}
</p>
)}
</div>
</div>
<dl className="automation-preview-facts">
<div>
<dt>生效范围</dt>
<dd>
{draft.conditions.conversationIds.length
? `已选 ${draft.conditions.conversationIds.length} 个群`
: '所有群聊'}
</dd>
</div>
<div>
<dt>触发间隔</dt>
<dd>{draft.cooldownSeconds > 0 ? `${draft.cooldownSeconds} 秒` : '不限制'}</dd>
</div>
<div>
<dt>自己发的消息</dt>
<dd>{draft.conditions.ignoreSelf ? '不触发' : '也会触发'}</dd>
</div>
</dl>
</section>
)
}
@@ -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 (
<div className="automation-drawer-layer" role="presentation" onClick={onClose}>
<aside
className="automation-drawer"
role="dialog"
aria-modal="true"
aria-label="执行详情"
onClick={(event) => event.stopPropagation()}
>
<header className="automation-drawer-header">
<div>
<span className="automation-drawer-eyebrow">执行详情</span>
<h2>{execution.ruleName}</h2>
</div>
<Button variant="ghost" size="sm" onClick={onClose}>
关闭
</Button>
</header>
<div className="automation-drawer-body">
<dl className="automation-detail-facts">
<div>
<dt>触发时间</dt>
<dd>{formatTriggerTime(execution.triggerTime)}</dd>
</div>
<div>
<dt>来源</dt>
<dd>{execution.sourceDisplayName}</dd>
</div>
<div>
<dt>结果</dt>
<dd>
<span className={`automation-status-chip ${execution.status}`}>
{execution.status === 'success' ? '成功' : '失败'}
</span>
</dd>
</div>
<div>
<dt>总耗时</dt>
<dd>{formatDuration(execution.durationMs)}</dd>
</div>
</dl>
{execution.errorSummary ? (
<p className="automation-detail-error">
<strong>失败原因:</strong>
{execution.errorSummary}
</p>
) : null}
<section className="automation-detail-steps">
<h3>执行步骤</h3>
{execution.steps.length === 0 ? (
<p className="automation-detail-empty">
本次执行没有产生步骤记录(可能在发送能力检查阶段就被拦下了)。
</p>
) : (
<ol className="automation-step-list">
{execution.steps.map((step) => (
<li key={step.key} className={`automation-step ${stepStatusTone(step.status)}`}>
<span className="automation-step-marker" aria-hidden="true" />
<div className="automation-step-body">
<div className="automation-step-title">
<span className="automation-step-label">{step.label}</span>
<span className="automation-step-status">
{STEP_STATUS_LABELS[step.status]}
</span>
</div>
<div className="automation-step-meta">
{step.status === 'skipped' ? (
<span>{describeSkippedStep(step, execution.steps)}</span>
) : (
<>
<span>{formatClockTime(step.startedAt)}</span>
<span className="automation-step-sep" aria-hidden="true">
→
</span>
<span>{formatClockTime(step.finishedAt)}</span>
<span className="automation-step-duration">
{formatDuration(step.durationMs)}
</span>
</>
)}
</div>
{step.error ? <p className="automation-step-error">{step.error}</p> : null}
</div>
</li>
))}
</ol>
)}
</section>
</div>
</aside>
</div>
)
}
@@ -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 <span className="automation-trail-empty">—</span>
}
return (
<span className="automation-trail" aria-label="执行步骤">
{execution.steps.map((step, index) => (
<React.Fragment key={step.key}>
{index > 0 ? <span className="automation-trail-link" aria-hidden="true" /> : null}
<span
className={`automation-trail-dot ${stepStatusTone(step.status)}`}
title={`${step.label}:${STEP_STATUS_LABELS[step.status]}`}
aria-label={`${step.label}:${STEP_STATUS_LABELS[step.status]}`}
role="img"
/>
</React.Fragment>
))}
</span>
)
}
export function ExecutionLogPanel({
executions,
loading,
clearing,
onSelect,
onClear
}: ExecutionLogPanelProps): React.ReactElement {
return (
<div className="automation-log-panel">
<div className="automation-section-heading">
<h2>执行日志</h2>
<div className="automation-log-heading-actions">
<span className="automation-section-note">最近 {executions.length} 条</span>
<Button
variant="outline"
size="sm"
onClick={onClear}
disabled={clearing || executions.length === 0}
>
{clearing ? '清空中…' : '清空记录'}
</Button>
</div>
</div>
{loading ? (
<div className="automation-loading">
<Spinner />
<span>正在读取执行记录…</span>
</div>
) : executions.length === 0 ? (
<div className="automation-empty-card">
<p>暂无执行记录。</p>
<small>规则命中并开始执行后,这里会留下完整的步骤轨迹。</small>
</div>
) : (
<div className="automation-log-table" role="table" aria-label="执行日志">
<div className="automation-log-row head" role="row">
<span role="columnheader">时间</span>
<span role="columnheader">规则</span>
<span role="columnheader">来源</span>
<span role="columnheader">结果</span>
<span role="columnheader">耗时</span>
<span role="columnheader">步骤轨迹</span>
<span role="columnheader" />
</div>
{executions.map((execution) => (
<div key={execution.executionId} className="automation-log-row" role="row">
<span role="cell" className="automation-log-time">
{formatTriggerTime(execution.triggerTime)}
</span>
<span role="cell" className="automation-log-rule">
{execution.ruleName}
</span>
<span role="cell" className="automation-log-source">
{execution.sourceDisplayName}
</span>
<span role="cell">
<span className={`automation-status-chip ${execution.status}`}>
{execution.status === 'success' ? '成功' : '失败'}
</span>
</span>
<span role="cell" className="automation-log-duration">
{formatDuration(execution.durationMs)}
</span>
<span role="cell">
<StepTrail execution={execution} />
</span>
<span role="cell" className="automation-log-action">
<Button variant="link" size="sm" onClick={() => onSelect(execution)}>
查看详情
</Button>
</span>
</div>
))}
</div>
)}
</div>
)
}
@@ -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<AutomationRuleDraft>(() => draftFromRule(rule))
const [groupFilter, setGroupFilter] = React.useState('')
const [validationError, setValidationError] = React.useState('')
// 切换编辑对象时重建草稿,避免把上一条规则的修改带过去。
React.useEffect(() => {
setDraft(draftFromRule(rule))
setValidationError('')
}, [rule])
const patchConditions = (patch: Partial<AutomationRuleDraft['conditions']>): 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 (
<div className="automation-editor">
<header className="automation-editor-header">
<div>
<h2>{mode === 'create' ? '新建自动化' : '编辑自动化'}</h2>
<p>命中条件后,TraceMemo 会按下面的顺序依次执行。</p>
</div>
<div className="automation-editor-actions">
<Button variant="ghost" onClick={onCancel} disabled={saving}>
取消
</Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? '保存中…' : '保存'}
</Button>
</div>
</header>
{validationError ? <p className="automation-form-error">{validationError}</p> : null}
<div className="automation-editor-body">
<div className="automation-editor-form">
<section className="automation-section">
<div className="automation-section-heading">
<h3>1 · 什么时候触发</h3>
</div>
<div className="automation-field">
<label htmlFor="automation-name">名称</label>
<Input
id="automation-name"
value={draft.name}
onChange={(event) =>
setDraft((current) => ({ ...current, name: event.target.value }))
}
placeholder="给这条自动化起个名字"
/>
</div>
<div className="automation-inline-row">
<span className="automation-field-label">触发方式</span>
<span className="automation-static-value">收到消息</span>
</div>
<div className="automation-switch-row">
<div>
<span className="automation-field-label">必须真正 @我</span>
<small>根据微信底层 @ 元数据判断,不依赖普通昵称纯文本</small>
</div>
<Switch
checked={draft.conditions.requireMentionMe}
onCheckedChange={(checked) => patchConditions({ requireMentionMe: checked })}
/>
</div>
<div className="automation-switch-row">
<div>
<span className="automation-field-label">启用这条自动化</span>
<small>关闭后规则会保留,但不会触发</small>
</div>
<Switch
checked={draft.enabled}
onCheckedChange={(checked) =>
setDraft((current) => ({ ...current, enabled: checked }))
}
/>
</div>
<div className="automation-field">
<label htmlFor="automation-keyword">关键词</label>
<Input
id="automation-keyword"
value={draft.conditions.keyword}
onChange={(event) => patchConditions({ keyword: event.target.value })}
placeholder="例如:日报(留空表示不限关键词)"
/>
</div>
<div className="automation-field">
<span className="automation-field-label">匹配方式</span>
<SegmentedControl
value={draft.conditions.keywordMatchMode}
onValueChange={(value) =>
patchConditions({ keywordMatchMode: value as KeywordMatchMode })
}
aria-label="关键词匹配方式"
>
{KEYWORD_MODES.map((mode) => (
<SegmentedControlItem key={mode} value={mode}>
{KEYWORD_MATCH_MODE_LABELS[mode]}
</SegmentedControlItem>
))}
</SegmentedControl>
</div>
</section>
<section className="automation-section">
<div className="automation-section-heading">
<h3>2 · 在哪些聊天生效</h3>
<span className="automation-section-note">
{draft.conditions.conversationIds.length
? `已选 ${draft.conditions.conversationIds.length} 个群`
: '未选择时对所有群聊生效'}
</span>
</div>
<Input
value={groupFilter}
onChange={(event) => setGroupFilter(event.target.value)}
placeholder="搜索群聊"
aria-label="搜索群聊"
/>
<div className="automation-group-list" role="group" aria-label="生效群聊">
{groups.length === 0 ? (
<p className="automation-group-empty">
还没有读取到群聊。请先在「档案」中连接并解锁微信数据库。
</p>
) : visibleGroups.length === 0 ? (
<p className="automation-group-empty">没有匹配「{groupFilter}」的群聊</p>
) : (
visibleGroups.map((group) => {
const checked = draft.conditions.conversationIds.includes(group.id)
return (
<label
key={group.id}
className={`automation-group-option ${checked ? 'selected' : ''}`}
>
<input type="checkbox" checked={checked} onChange={() => toggleGroup(group.id)} />
<span>{group.name}</span>
</label>
)
})
)}
</div>
</section>
<section className="automation-section">
<div className="automation-section-heading">
<h3>3 · 触发后执行</h3>
<span className="automation-section-note">按下列顺序执行</span>
</div>
{ACTION_META.map((meta) => {
const action = actionOf(meta.type)
return (
<div key={meta.type} className="automation-action-row">
<div className="automation-action-main">
<div className="automation-switch-row compact">
<div>
<span className="automation-field-label">{meta.title}</span>
<small>{meta.description}</small>
</div>
<Switch
checked={action.enabled}
onCheckedChange={(checked) => patchAction(meta.type, { enabled: checked })}
/>
</div>
{meta.type === 'replyText' && action.enabled ? (
<Input
value={action.text ?? ''}
onChange={(event) => patchAction(meta.type, { text: event.target.value })}
placeholder={DEFAULT_REPLY_TEXT}
aria-label="回复内容"
/>
) : null}
</div>
</div>
)
})}
</section>
<section className="automation-section">
<div className="automation-section-heading">
<h3>4 · 高级设置</h3>
</div>
<div className="automation-inline-row">
<div>
<span className="automation-field-label">触发间隔(秒)</span>
<small>同一个群里,两次触发之间至少间隔这么久,避免刷屏</small>
</div>
<Input
type="number"
min={0}
value={String(draft.cooldownSeconds)}
onChange={(event) => {
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="触发间隔秒数"
/>
</div>
<div className="automation-switch-row">
<div>
<span className="automation-field-label">忽略自己发送的消息</span>
<small>
{draft.conditions.ignoreSelf
? '推荐保持开启,可避免自动回复被再次触发'
: '关闭后可能形成自动回复循环,请确认你知道后果'}
</small>
</div>
<Switch
checked={draft.conditions.ignoreSelf}
onCheckedChange={(checked) => patchConditions({ ignoreSelf: checked })}
/>
</div>
<p className="automation-static-note">同一消息只执行一次。</p>
</section>
</div>
<EffectPreview draft={draft} />
</div>
</div>
)
}
@@ -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 (
<div className="automation-rule-list">
<section className="automation-rule-group">
<div className="automation-section-heading">
<h2>已配置的自动化</h2>
<span className="automation-section-note">{rules.length} 条</span>
</div>
{loading ? (
<div className="automation-loading">
<Spinner />
<span>正在读取规则…</span>
</div>
) : rules.length === 0 ? (
<div className="automation-empty-card">
<p>还没有任何自动化。</p>
<Button onClick={onCreate}>新建自动化</Button>
</div>
) : (
rules.map((rule) => (
<article key={rule.id} className={`automation-rule-card ${rule.enabled ? '' : 'disabled'}`}>
<div className="automation-rule-card-main">
<div className="automation-rule-card-title">
<h3>{rule.name}</h3>
<span className={`automation-rule-state ${rule.enabled ? 'on' : 'off'}`}>
{rule.enabled ? '运行中' : '已停用'}
</span>
</div>
<dl className="automation-rule-meta">
<div>
<dt>触发</dt>
<dd>{describeRuleTrigger(rule)}</dd>
</div>
<div>
<dt>范围</dt>
<dd>{scopeLabel(rule, groups)}</dd>
</div>
<div>
<dt>动作</dt>
<dd>{describeRuleActions(rule)}</dd>
</div>
<div>
<dt>间隔</dt>
<dd>{rule.cooldownSeconds > 0 ? `${rule.cooldownSeconds} 秒` : '不限制'}</dd>
</div>
</dl>
</div>
<div className="automation-rule-card-side">
<Switch
checked={rule.enabled}
disabled={busyRuleId === rule.id}
onCheckedChange={(checked) => onToggle(rule, checked)}
aria-label={`${rule.name} 启停`}
/>
<Button variant="outline" size="sm" onClick={() => onEdit(rule)}>
编辑
</Button>
<Button
variant="link"
size="sm"
onClick={() => onDelete(rule)}
disabled={busyRuleId === rule.id}
>
删除
</Button>
</div>
</article>
))
)}
</section>
<section className="automation-rule-group">
<div className="automation-section-heading">
<h2>更多模板</h2>
<span className="automation-section-note">后续版本开放</span>
</div>
<div className="automation-template-grid">
{AUTOMATION_RULE_TEMPLATES.map((template) => (
<article key={template.id} className="automation-template-card">
<div className="automation-template-title">
<h3>{template.name}</h3>
<span className="automation-template-flag">即将支持</span>
</div>
<p>{template.description}</p>
{template.unavailableReason ? (
<small>{template.unavailableReason}</small>
) : null}
</article>
))}
</div>
</section>
</div>
)
}
@@ -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<string, unknown> | undefined {
if (typeof window === 'undefined') return undefined
const target = window.api as unknown as Record<string, unknown> | undefined
return target ?? undefined
}
/**
* 调用一个可能不存在的 preload 方法。
*
* - 方法缺失 / 抛异常 / reject / 返回空值 → 一律返回 `fallback`。
* - 刻意不区分「方法不存在」与「调用失败」:对界面而言,两者都只是「暂时拿不到」。
*/
function invoke<T>(name: string, fallback: T, ...args: unknown[]): Promise<T> {
const target = apiTarget()
const fn = target?.[name]
if (typeof fn !== 'function') return Promise.resolve(fallback)
try {
const result = (fn as (...input: unknown[]) => Promise<T>).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<AutomationStatusSummary> =>
invoke('getAutomationStatus', UNAVAILABLE_STATUS),
listRules: (): Promise<AutomationRule[]> => invoke('listAutomationRules', []),
createRule: (draft: AutomationRuleDraft): Promise<AutomationRule | null> =>
invoke<AutomationRule | null>('createAutomationRule', null, draft),
updateRule: (id: string, draft: AutomationRuleDraft): Promise<AutomationRule | null> =>
invoke<AutomationRule | null>('updateAutomationRule', null, id, draft),
deleteRule: (id: string): Promise<boolean> => invoke('deleteAutomationRule', false, id),
setRuleEnabled: (id: string, enabled: boolean): Promise<AutomationRule | null> =>
invoke<AutomationRule | null>('setAutomationRuleEnabled', null, id, enabled),
listExecutions: (limit = 100): Promise<AutomationExecution[]> =>
invoke('listAutomationExecutions', [], { limit }),
clearExecutions: (): Promise<boolean> => invoke('clearAutomationExecutions', false),
listGroups: (): Promise<AutomationGroupOption[]> => invoke('listAutomationGroups', [])
}
@@ -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<AutomationStepStatus, string> = {
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 '未执行'
}
File diff suppressed because it is too large Load Diff
+1
View File
@@ -20,4 +20,5 @@
@use './group-exit-event';
@use './group-member-stats';
@use './local-index';
@use './automation';
@use './theme';
+92
View File
@@ -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 → <atuserlist>` 解析,不是正文里的 `@昵称`)。 */
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 }
}
+328
View File
@@ -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<AutomationStepKey, string> = {
received: '收到消息',
matched: '规则匹配',
reply: '回复确认',
report: '生成日报',
send: '发送日报图片'
}
export const KEYWORD_MATCH_MODE_LABELS: Record<KeywordMatchMode, string> = {
contains: '包含',
exact: '完全匹配',
prefix: '以…开头'
}
export const SCOPE_LABELS: Record<AutomationMessageScope, string> = {
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<AutomationActionType, string> = {
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<AutomationRuleDraft>
const conditions = (raw.conditions ?? {}) as Partial<AutomationConditions>
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
}
}
+11
View File
@@ -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'
@@ -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(<EffectPreview draft={draft()} />)
expect(incomingBubble()).toBe('@你 今日日报')
expect(screen.getByText('收到,正在生成今日日报')).toBeInTheDocument()
expect(hasReportImage()).toBe(true)
expect(screen.getByText('仅预览,不会真实发送')).toBeInTheDocument()
})
it('改关键词后左侧模拟消息同步变化', () => {
const { rerender } = render(<EffectPreview draft={draft()} />)
expect(incomingBubble()).toBe('@你 今日日报')
rerender(
<EffectPreview
draft={draft((value) => {
value.conditions.keyword = '周报'
})}
/>
)
expect(incomingBubble()).toBe('@你 今日周报')
})
it('改回复文案后右侧气泡同步变化', () => {
const { rerender } = render(<EffectPreview draft={draft()} />)
rerender(
<EffectPreview
draft={draft((value) => {
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(<EffectPreview draft={draft()} />)
expect(screen.getByText('收到,正在生成今日日报')).toBeInTheDocument()
rerender(
<EffectPreview
draft={draft((value) => {
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(<EffectPreview draft={draft()} />)
expect(incomingBubble().startsWith('@你')).toBe(true)
rerender(
<EffectPreview
draft={draft((value) => {
value.conditions.requireMentionMe = false
})}
/>
)
expect(incomingBubble().startsWith('@你')).toBe(false)
expect(incomingBubble()).toBe('今日日报')
})
it('关闭「生成日报」后不再出图片,并说明原因', () => {
const { rerender } = render(<EffectPreview draft={draft()} />)
expect(hasReportImage()).toBe(true)
rerender(
<EffectPreview
draft={draft((value) => {
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(<EffectPreview draft={draft()} />)
expect(hasReportImage()).toBe(true)
rerender(
<EffectPreview
draft={draft((value) => {
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(
<EffectPreview
draft={draft((value) => {
value.conditions.keyword = ' '
})}
/>
)
expect(incomingBubble()).toBe('@你 今天群里有什么新消息')
})
it('生效范围与触发间隔随时间反映在摘要里', () => {
const { rerender } = render(<EffectPreview draft={draft()} />)
expect(screen.getByText('所有群聊')).toBeInTheDocument()
expect(screen.getByText('60 秒')).toBeInTheDocument()
rerender(
<EffectPreview
draft={draft((value) => {
value.conditions.conversationIds = ['a@chatroom', 'b@chatroom']
value.cooldownSeconds = 0
})}
/>
)
expect(screen.getByText('已选 2 个群')).toBeInTheDocument()
expect(screen.getByText('不限制')).toBeInTheDocument()
})
it('关闭「忽略自己发送」时摘要如实标注会触发', () => {
const { rerender } = render(<EffectPreview draft={draft()} />)
expect(screen.getByText('不触发')).toBeInTheDocument()
rerender(
<EffectPreview
draft={draft((value) => {
value.conditions.ignoreSelf = false
})}
/>
)
expect(screen.getByText('也会触发')).toBeInTheDocument()
})
})
+272
View File
@@ -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> = {}): 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)
})
})
+307
View File
@@ -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<WechatActionResult>
generateReport?: () => Promise<AgentGroupReportResult>
}): {
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<Awaited<ReturnType<AutomationActionRunner['run']>>> =>
runner.run({
executionId: EXECUTION_ID,
rule,
conversationId: GROUP_ID,
isGroup: true,
sourceDisplayName: '测试群'
})
const statusOf = (
result: Awaited<ReturnType<AutomationActionRunner['run']>>,
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'
})
})
})
+195
View File
@@ -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> = {}): AutomationRule {
return { ...createDefaultDailyReportRule(0), ...overrides }
}
function input(overrides: Partial<AutomationMatchInput> = {}): 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'])
})
})
+255
View File
@@ -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> = {}): 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)
})
})
+387
View File
@@ -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> = {}): 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> = {}): 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<AutomationRunResult> => {
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
})
})
+346
View File
@@ -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> = {}): 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<Buffer> {
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<void> => {
await vi.advanceTimersByTimeAsync(ms)
}
beforeEach(() => {
received = []
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
})
describe('mention parser(纯函数)', () => {
it('CDATA 形式:剥掉外壳,只留 username', () => {
const source =
'<msgsource><atuserlist><![CDATA[SELF_USER]]></atuserlist><silence>1</silence></msgsource>'
expect(extractMentionTargets(source)).toEqual(['SELF_USER'])
})
it('裸文本形式同样支持', () => {
const source = '<msgsource><atuserlist>OTHER_USER</atuserlist></msgsource>'
expect(extractMentionTargets(source)).toEqual(['OTHER_USER'])
})
it('没有 atuserlist 时返回空数组', () => {
expect(extractMentionTargets('<msgsource><silence>1</silence></msgsource>')).toEqual([])
expect(extractMentionTargets(undefined)).toEqual([])
expect(extractMentionTargets('')).toEqual([])
})
it('假 @:正文里有 @昵称,但 source 没有 atuserlist ⇒ 不产生 mention', () => {
const source = '<msgsource><silence>1</silence><membercount>34</membercount></msgsource>'
expect(extractMentionTargets(source)).toEqual([])
})
it('不假设 username 以 wxid_ 开头(本机账号的 username 可能完全是别的形式)', () => {
const source = '<msgsource><atuserlist>SELF_USER</atuserlist></msgsource>'
// 若实现里写了 `startsWith('wxid_')` 过滤,这条会变成 []
expect(extractMentionTargets(source)).toEqual(['SELF_USER'])
})
})
describe('decodeSourcePayload', () => {
it('zstd 压缩的 Buffer 能解出 XML', async () => {
const xml = '<msgsource><atuserlist>SELF_USER</atuserlist></msgsource>'
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 = '<msgsource><atuserlist>SELF_USER</atuserlist></msgsource>'
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<typeof vi.fn> }).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<typeof vi.fn> }).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<void> => {
;(client as unknown as { getMessagesAsync: ReturnType<typeof vi.fn> }).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 = '<msgsource><atuserlist><![CDATA[SELF_USER]]></atuserlist></msgsource>'
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)
})
})