mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-04 03:01:42 +08:00
feat: 自动化
This commit is contained in:
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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 / 发送。
|
||||
*/
|
||||
@@ -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>
|
||||
|
||||
Vendored
+15
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -20,4 +20,5 @@
|
||||
@use './group-exit-event';
|
||||
@use './group-member-stats';
|
||||
@use './local-index';
|
||||
@use './automation';
|
||||
@use './theme';
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user