feat: 自动化

This commit is contained in:
Wxw-Gu
2026-09-21 00:35:32 +08:00
parent 766cd94788
commit 95384a4a92
32 changed files with 6420 additions and 1 deletions
+307
View File
@@ -0,0 +1,307 @@
import { describe, expect, it, vi } from 'vitest'
/**
* runner 本身不碰 electron,但它的模块图会加载 `agent-group-report-service`,
* 那条链上的 `report-template-service` / `settings-store` 在**模块加载时**就会调
* `app.getPath()`。所以这里必须给 electron 一个替身,否则 import 阶段就炸。
*
* 用一个固定字符串即可 —— 这些服务只把它当路径前缀拼,不会真的读写。
*/
vi.mock('electron', () => ({
app: { getPath: () => '/tmp/wxe-automation-runner-fixture' },
safeStorage: {
isEncryptionAvailable: () => false,
encryptString: (value: string) => Buffer.from(value, 'utf8'),
decryptString: (value: Buffer) => value.toString('utf8')
},
BrowserWindow: { getAllWindows: () => [] }
}))
import {
AUTOMATION_SEND_PURPOSE,
createDefaultDailyReportRule,
type AutomationRule,
type AutomationStepKey
} from '../../src/shared/automation'
import type { WechatActionRequest, WechatActionResult } from '../../src/shared/wechat-action'
import { AutomationActionRunner } from '../../src/main/services/automation-action-runner'
import type { AgentGroupReportResult } from '../../src/main/services/agent-group-report-service'
/**
* ActionRunner 的步骤语义。
*
* 这一层最重要的性质只有两条:
* 1. 顺序是 reply → report → send;
* 2. **失败必须切断后续** —— 日报没出来就绝不能继续发图。
*/
const EXECUTION_ID = 'exec-fixture-1'
const GROUP_ID = '12345678@chatroom'
function makeRule(mutate?: (rule: AutomationRule) => void): AutomationRule {
const rule = createDefaultDailyReportRule(0)
mutate?.(rule)
return rule
}
/** 单调递增的假时钟,让 durationMs 可预测。 */
function fakeClock(step = 5): () => number {
let value = 1_700_000_000_000
return () => {
value += step
return value
}
}
function sentResult(): WechatActionResult {
return {
actionId: 'action-1',
status: 'sent',
decision: 'allow',
startedAt: '2026-09-20T10:00:00.000Z',
finishedAt: '2026-09-20T10:00:01.000Z'
}
}
function failedResult(code: string, reason: string): WechatActionResult {
return {
actionId: 'action-1',
status: 'failed',
decision: 'allow',
errorCode: code,
reason,
startedAt: '2026-09-20T10:00:00.000Z',
finishedAt: '2026-09-20T10:00:01.000Z'
}
}
function buildRunner(options: {
executeAction?: (request: WechatActionRequest) => Promise<WechatActionResult>
generateReport?: () => Promise<AgentGroupReportResult>
}): {
runner: AutomationActionRunner
calls: WechatActionRequest[]
reports: Array<{ group: string; range?: string }>
} {
const calls: WechatActionRequest[] = []
const reports: Array<{ group: string; range?: string }> = []
const runner = new AutomationActionRunner({
now: fakeClock(),
executeAction: async (request) => {
calls.push(request)
return options.executeAction ? options.executeAction(request) : sentResult()
},
generateReport: async (request) => {
reports.push({ group: request.group, ...(request.range ? { range: request.range } : {}) })
return options.generateReport ? options.generateReport() : { success: true, pngPath: '/tmp/report.png' }
}
})
return { runner, calls, reports }
}
const run = (
runner: AutomationActionRunner,
rule: AutomationRule
): Promise<Awaited<ReturnType<AutomationActionRunner['run']>>> =>
runner.run({
executionId: EXECUTION_ID,
rule,
conversationId: GROUP_ID,
isGroup: true,
sourceDisplayName: '测试群'
})
const statusOf = (
result: Awaited<ReturnType<AutomationActionRunner['run']>>,
key: AutomationStepKey
): string => result.steps.find((step) => step.key === key)?.status ?? 'missing'
describe('AutomationActionRunner', () => {
it('按 收到消息 → 规则匹配 → 回复确认 → 生成日报 → 发送图片 的顺序执行', async () => {
const { runner, calls, reports } = buildRunner({})
const result = await run(runner, makeRule())
expect(result.status).toBe('success')
expect(result.pngPath).toBe('/tmp/report.png')
expect(result.steps.map((step) => step.key)).toEqual([
'received',
'matched',
'reply',
'report',
'send'
])
expect(result.steps.map((step) => step.status)).toEqual([
'success',
'success',
'success',
'success',
'success'
])
// 先回文字,再发图片 —— 顺序反了用户会先收到图再收到"正在生成"。
expect(calls).toHaveLength(2)
expect(calls[0].content.type).toBe('text')
expect(calls[1].content.type).toBe('image')
expect(reports).toEqual([{ group: GROUP_ID, range: 'today' }])
})
it('每一步都带独立的起止时间与耗时', async () => {
const { runner } = buildRunner({})
const result = await run(runner, makeRule())
for (const step of result.steps) {
expect(step.startedAt).toBeTypeOf('number')
expect(step.finishedAt).toBeTypeOf('number')
expect(step.durationMs).toBeGreaterThanOrEqual(0)
expect(step.finishedAt! >= step.startedAt!).toBe(true)
}
// matched 比 received 晚开始,说明确实是顺序执行而非一次性补写。
expect(result.steps[1].startedAt!).toBeGreaterThan(result.steps[0].startedAt!)
})
it('发送请求带上正确的 purpose / 幂等键 / 收件人', async () => {
const { runner, calls } = buildRunner({})
await run(runner, makeRule())
expect(calls[0].purpose).toBe(AUTOMATION_SEND_PURPOSE.reply)
expect(calls[0].idempotencyKey).toBe(`${AUTOMATION_SEND_PURPOSE.reply}:${EXECUTION_ID}`)
expect(calls[1].purpose).toBe(AUTOMATION_SEND_PURPOSE.report)
expect(calls[1].idempotencyKey).toBe(`${AUTOMATION_SEND_PURPOSE.report}:${EXECUTION_ID}`)
for (const call of calls) {
expect(call.origin).toBe('automation')
expect(call.triggerType).toBe('automation')
expect(call.recipient).toMatchObject({ type: 'group', id: GROUP_ID, name: '测试群' })
}
})
it('生成日报失败时,发送图片被跳过而不是继续乱发', async () => {
const { runner, calls } = buildRunner({
generateReport: async () => ({ success: false, error: '所选时间范围没有可总结的消息' })
})
const result = await run(runner, makeRule())
expect(result.status).toBe('failed')
expect(statusOf(result, 'reply')).toBe('success')
expect(statusOf(result, 'report')).toBe('failed')
expect(statusOf(result, 'send')).toBe('skipped')
expect(result.errorSummary).toBe('所选时间范围没有可总结的消息')
// 只发过那条文字回复,图片一次都没发。
expect(calls).toHaveLength(1)
expect(calls[0].content.type).toBe('text')
})
it('生成日报抛异常时同样记 failed 并跳过发送', async () => {
const { runner } = buildRunner({
generateReport: async () => {
throw new Error('AI 服务不可达')
}
})
const result = await run(runner, makeRule())
expect(statusOf(result, 'report')).toBe('failed')
expect(statusOf(result, 'send')).toBe('skipped')
expect(result.steps.find((step) => step.key === 'report')?.error).toBe('AI 服务不可达')
})
it('发送图片失败时整次执行判为失败并保留原因', async () => {
let callIndex = 0
const { runner } = buildRunner({
executeAction: async () => {
callIndex += 1
return callIndex === 1
? sentResult()
: failedResult('SEND_NOT_READY', '')
}
})
const result = await run(runner, makeRule())
expect(result.status).toBe('failed')
expect(statusOf(result, 'send')).toBe('failed')
expect(result.errorSummary).toBe('微信发送能力尚未就绪,请先绑定个人微信')
})
it('回复确认失败时,生成日报与发送都被跳过', async () => {
const { runner, calls, reports } = buildRunner({
executeAction: async () => failedResult('SEND_CAPABILITY_UNAVAILABLE', '当前环境没有可用的微信发送能力')
})
const result = await run(runner, makeRule())
expect(statusOf(result, 'reply')).toBe('failed')
expect(statusOf(result, 'report')).toBe('skipped')
expect(statusOf(result, 'send')).toBe('skipped')
expect(calls).toHaveLength(1)
expect(reports).toHaveLength(0)
})
it('未启用回复确认时该步 skipped,但不阻断后面的日报与发送', async () => {
const { runner, calls } = buildRunner({})
const rule = makeRule((item) => {
item.actions = item.actions.filter((action) => action.type !== 'replyText')
})
const result = await run(runner, rule)
expect(result.status).toBe('success')
expect(statusOf(result, 'reply')).toBe('skipped')
expect(statusOf(result, 'report')).toBe('success')
expect(statusOf(result, 'send')).toBe('success')
expect(calls).toHaveLength(1)
expect(calls[0].content.type).toBe('image')
})
it('未启用生成日报时,发送图片也必须 skipped(不能凭空发图)', async () => {
const { runner, calls, reports } = buildRunner({})
const rule = makeRule((item) => {
item.actions = item.actions.filter((action) => action.type !== 'generateReport')
})
const result = await run(runner, rule)
expect(statusOf(result, 'report')).toBe('skipped')
expect(statusOf(result, 'send')).toBe('skipped')
expect(reports).toHaveLength(0)
expect(calls).toHaveLength(1)
expect(calls[0].content.type).toBe('text')
})
it('只生成不发送时,日报步骤仍记成功', async () => {
const { runner, reports } = buildRunner({})
const rule = makeRule((item) => {
item.actions = item.actions.filter((action) => action.type !== 'sendReportImage')
})
const result = await run(runner, rule)
expect(result.status).toBe('success')
expect(statusOf(result, 'report')).toBe('success')
expect(statusOf(result, 'send')).toBe('skipped')
expect(reports).toHaveLength(1)
})
it('回复文案为空时回落到内置默认文案', async () => {
const { runner, calls } = buildRunner({})
const rule = makeRule((item) => {
item.actions = item.actions.map((action) =>
action.type === 'replyText' ? { ...action, text: ' ' } : action
)
})
await run(runner, rule)
expect(calls[0].content).toEqual({ type: 'text', text: '收到,正在生成今日日报' })
})
it('私聊场景下收件人类型是联系人', async () => {
const executeAction = vi.fn(async (_request: WechatActionRequest) => sentResult())
const runner = new AutomationActionRunner({
now: fakeClock(),
executeAction,
generateReport: async () => ({ success: true, pngPath: '/tmp/report.png' })
})
await runner.run({
executionId: EXECUTION_ID,
rule: makeRule((item) => {
item.scope = 'direct'
}),
conversationId: 'wxid_friend',
isGroup: false,
sourceDisplayName: '李四'
})
expect(executeAction.mock.calls[0][0].recipient).toMatchObject({
type: 'contact',
id: 'wxid_friend'
})
})
})
+195
View File
@@ -0,0 +1,195 @@
import { describe, expect, it } from 'vitest'
import {
BUILTIN_DAILY_REPORT_RULE_ID,
createDefaultDailyReportRule,
matchKeyword,
normalizeRuleDraft,
type AutomationRule
} from '../../src/shared/automation'
import {
matchAutomationRule,
type AutomationMatchInput
} from '../../src/shared/automation-matcher'
/**
* TriggerMatcher 的判定语义。
*
* 这些用例锁死的是 2026-09-20 真实环境 Spike 得出的**底层事实**,
* 不是我们想要的语义 —— 尤其「正文里的 @昵称 不算真 @」这一条。
*/
/** 自己的微信 username。**刻意不带 `wxid_` 前缀** —— 前缀判断是 Spike 明确禁止的。 */
const SELF = 'my_account_9527'
const OTHER = 'zhangsan_1234'
const GROUP_ID = '12345678@chatroom'
function baseRule(overrides: Partial<AutomationRule> = {}): AutomationRule {
return { ...createDefaultDailyReportRule(0), ...overrides }
}
function input(overrides: Partial<AutomationMatchInput> = {}): AutomationMatchInput {
return {
content: '今日日报',
mentionTargets: [SELF],
isSelf: false,
isGroup: true,
conversationId: GROUP_ID,
...overrides
}
}
const match = (rule: AutomationRule, value: AutomationMatchInput): boolean =>
matchAutomationRule(rule, value, [SELF]).matched
const reasonOf = (rule: AutomationRule, value: AutomationMatchInput): string | undefined =>
matchAutomationRule(rule, value, [SELF]).reason
describe('TriggerMatcher', () => {
it('内置规则命中:真正 @我 + 含关键词「日报」', () => {
const rule = baseRule()
expect(rule.id).toBe(BUILTIN_DAILY_REPORT_RULE_ID)
expect(match(rule, input())).toBe(true)
})
it('假 @:正文里手打「@昵称」但底层没有 @ 元数据时不命中', () => {
// 这正是 Spike 验证的核心事实:content 里的 @ 是纯文本,与真 @ 无关。
expect(match(baseRule(), input({ content: '@我 今日日报', mentionTargets: [] }))).toBe(false)
expect(reasonOf(baseRule(), input({ mentionTargets: [] }))).toBe('mention')
})
it('没有 @ 任何人时不命中', () => {
expect(match(baseRule(), input({ mentionTargets: [] }))).toBe(false)
})
it('@ 的是别人(不是自己)时不命中', () => {
expect(match(baseRule(), input({ mentionTargets: [OTHER] }))).toBe(false)
})
it('自己发送的消息不触发(防死循环第一道闸)', () => {
// TraceMemo 回复的「收到,正在生成今日日报」含关键词「日报」,
// 少了这道闸 MessageListener 再读到它就会自己触发自己。
expect(match(baseRule(), input({ isSelf: true }))).toBe(false)
expect(reasonOf(baseRule(), input({ isSelf: true }))).toBe('self')
})
it('ignoreSelf 被显式关掉时,自己的消息才会被判定为可触发', () => {
const rule = baseRule()
rule.conditions.ignoreSelf = false
expect(match(rule, input({ isSelf: true }))).toBe(true)
})
it('停用的规则不命中', () => {
expect(match(baseRule({ enabled: false }), input())).toBe(false)
expect(reasonOf(baseRule({ enabled: false }), input())).toBe('disabled')
})
it('群范围规则收到私聊消息时不命中', () => {
expect(match(baseRule(), input({ isGroup: false }))).toBe(false)
expect(reasonOf(baseRule(), input({ isGroup: false }))).toBe('scope')
})
it('指定了生效群时,其它群不命中', () => {
const rule = baseRule()
rule.conditions.conversationIds = ['other@chatroom']
expect(match(rule, input())).toBe(false)
expect(reasonOf(rule, input())).toBe('conversation')
})
it('指定了生效群且命中该群时正常触发', () => {
const rule = baseRule()
rule.conditions.conversationIds = [GROUP_ID]
expect(match(rule, input())).toBe(true)
})
it('空 conversationIds 表示不限会话', () => {
const rule = baseRule()
rule.conditions.conversationIds = []
expect(match(rule, input({ conversationId: 'anyone@chatroom' }))).toBe(true)
})
it('关键词不匹配时不命中', () => {
expect(match(baseRule(), input({ content: '今天的会议纪要' }))).toBe(false)
expect(reasonOf(baseRule(), input({ content: '今天的会议纪要' }))).toBe('keyword')
})
it('关键词为空表示不限关键词', () => {
const rule = baseRule()
rule.conditions.keyword = ''
expect(match(rule, input({ content: '随便说点什么' }))).toBe(true)
})
it('exact 模式要求整条消息等于关键词', () => {
const rule = baseRule()
rule.conditions.keywordMatchMode = 'exact'
expect(match(rule, input({ content: '日报' }))).toBe(true)
expect(match(rule, input({ content: '今天的日报' }))).toBe(false)
})
it('prefix 模式要求消息以关键词开头', () => {
const rule = baseRule()
rule.conditions.keywordMatchMode = 'prefix'
expect(match(rule, input({ content: '日报 今天' }))).toBe(true)
expect(match(rule, input({ content: '今天的日报' }))).toBe(false)
})
it('不假设自己的 username 以 wxid_ 开头', () => {
// 把 selfUsernames 换成一个完全不符合 wxid_ 形态的值,依然要能精确匹配。
const rule = baseRule()
expect(
matchAutomationRule(rule, input({ mentionTargets: ['plain_name'] }), ['plain_name']).matched
).toBe(true)
})
it('selfUsernames 为空时,任何 @ 都不算 @我', () => {
expect(matchAutomationRule(baseRule(), input(), []).matched).toBe(false)
})
it('不需要 @我时,未 @ 也能因关键词命中', () => {
const rule = baseRule()
rule.conditions.requireMentionMe = false
expect(match(rule, input({ mentionTargets: [], content: '今天的日报呢' }))).toBe(true)
})
})
describe('matchKeyword', () => {
it('大小写不敏感并忽略首尾空白', () => {
expect(matchKeyword(' Today Daily Report ', 'daily report', 'contains')).toBe(true)
})
it('空关键词视为不限制', () => {
expect(matchKeyword(undefined, ' ', 'contains')).toBe(true)
})
it('内容为空且关键词非空时不命中', () => {
expect(matchKeyword(undefined, '日报', 'contains')).toBe(false)
})
})
describe('normalizeRuleDraft', () => {
it('把非法的匹配方式与范围收敛成安全默认值', () => {
const draft = normalizeRuleDraft({
name: ' ',
scope: 'nonsense',
conditions: { keywordMatchMode: 'regex', keyword: 'x', conversationIds: ['a', '', 'b'] },
actions: [{ type: 'replyText', enabled: true }],
cooldownSeconds: -5
})
expect(draft.name).toBe('未命名自动化')
expect(draft.scope).toBe('group')
expect(draft.conditions.keywordMatchMode).toBe('contains')
expect(draft.conditions.conversationIds).toEqual(['a', 'b'])
expect(draft.cooldownSeconds).toBe(0)
// 默认必须是「忽略自己」+「必须 @我」—— 这两条是防循环的底线。
expect(draft.conditions.ignoreSelf).toBe(true)
expect(draft.conditions.requireMentionMe).toBe(true)
})
it('丢弃未知的 action 类型', () => {
const draft = normalizeRuleDraft({
name: 'x',
conditions: {},
actions: [{ type: 'generateReport', enabled: true }, { type: 'webhook', enabled: true }]
})
expect(draft.actions.map((action) => action.type)).toEqual(['generateReport'])
})
})
+255
View File
@@ -0,0 +1,255 @@
import fs from 'fs-extra'
import path from 'path'
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
const root = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const fs = require('node:fs') as typeof import('node:fs')
// eslint-disable-next-line @typescript-eslint/no-require-imports
const os = require('node:os') as typeof import('node:os')
// eslint-disable-next-line @typescript-eslint/no-require-imports
const path = require('node:path') as typeof import('node:path')
return fs.mkdtempSync(path.join(os.tmpdir(), 'wxe-automation-rule-store-'))
})
vi.mock('electron', () => ({ app: { getPath: () => root } }))
import { BUILTIN_DAILY_REPORT_RULE_ID } from '../../src/shared/automation'
import {
AutomationRuleStore,
type AutomationRuleStoreDependencies
} from '../../src/main/services/automation-rule-store'
import { AutomationExecutionLogService } from '../../src/main/services/automation-execution-log-service'
import type { AutomationExecution } from '../../src/shared/automation'
const rulesFile = (): string => path.join(root, 'automation', 'rules.json')
const executionsFile = (): string => path.join(root, 'automation', 'executions.json')
/** 只重定向目录,其余用真实实现 —— 目的是验证真实的读写与归一化。 */
const deps = (dir: string, now?: () => number): AutomationRuleStoreDependencies => ({
userDataPath: () => dir,
...(now ? { now } : {})
})
describe('AutomationRuleStore', () => {
beforeEach(() => {
fs.removeSync(path.join(root, 'automation'))
})
afterAll(() => fs.removeSync(root))
it('首次加载会播种内置规则「@我生成日报」并落盘', () => {
const store = new AutomationRuleStore(deps(root))
const rules = store.listRules()
expect(rules).toHaveLength(1)
const builtin = rules[0]
expect(builtin.id).toBe(BUILTIN_DAILY_REPORT_RULE_ID)
expect(builtin.name).toBe('@我生成日报')
expect(builtin.enabled).toBe(true)
expect(builtin.scope).toBe('group')
expect(builtin.conditions.requireMentionMe).toBe(true)
expect(builtin.conditions.keyword).toBe('日报')
expect(builtin.conditions.ignoreSelf).toBe(true)
expect(builtin.cooldownSeconds).toBe(60)
expect(builtin.actions.map((action) => action.type)).toEqual([
'replyText',
'generateReport',
'sendReportImage'
])
expect(fs.pathExistsSync(rulesFile())).toBe(true)
})
it('删掉内置规则后不会在下次启动时被重新塞回来', () => {
const store = new AutomationRuleStore(deps(root))
expect(store.deleteRule(BUILTIN_DAILY_REPORT_RULE_ID)).toBe(true)
expect(store.listRules()).toHaveLength(0)
// 关键:新实例(等于重启)不应该重新播种。
const reopened = new AutomationRuleStore(deps(root))
expect(reopened.listRules()).toHaveLength(0)
})
it('createRule 归一化脏输入并补上 id 与时间戳', () => {
const now = 1_700_000_000_000
const store = new AutomationRuleStore(deps(root, () => now))
const created = store.createRule({
name: ' 关键词回复 ',
scope: 'bogus',
conditions: { keyword: '周报', keywordMatchMode: 'regex', conversationIds: ['a', ''] },
actions: [{ type: 'replyText', enabled: true, text: '好的' }],
cooldownSeconds: 30
})
expect(created.id).not.toBe(BUILTIN_DAILY_REPORT_RULE_ID)
expect(created.name).toBe('关键词回复')
expect(created.scope).toBe('group')
expect(created.conditions.keywordMatchMode).toBe('contains')
expect(created.conditions.conversationIds).toEqual(['a'])
expect(created.createdAt).toBe(now)
expect(created.updatedAt).toBe(now)
expect(store.listRules()).toHaveLength(2)
})
it('updateRule 保留 id 与 createdAt,刷新 updatedAt', () => {
let now = 1_000
const store = new AutomationRuleStore(deps(root, () => now))
const created = store.createRule({ name: 'A', conditions: {}, actions: [] })
now = 2_000
const updated = store.updateRule(created.id, {
name: 'B',
conditions: { keyword: 'x' },
actions: [{ type: 'generateReport', enabled: true }]
})
expect(updated?.id).toBe(created.id)
expect(updated?.createdAt).toBe(1_000)
expect(updated?.updatedAt).toBe(2_000)
expect(updated?.name).toBe('B')
expect(updated?.conditions.keyword).toBe('x')
})
it('updateRule 名称为空时沿用原名,而不是变成「未命名自动化」', () => {
const store = new AutomationRuleStore(deps(root))
const builtin = store.listRules()[0]
const updated = store.updateRule(builtin.id, { ...builtin, name: ' ' })
expect(updated?.name).toBe('@我生成日报')
})
it('setRuleEnabled 只改开关', () => {
const store = new AutomationRuleStore(deps(root))
const builtin = store.listRules()[0]
const disabled = store.setRuleEnabled(builtin.id, false)
expect(disabled?.enabled).toBe(false)
expect(disabled?.conditions.keyword).toBe('日报')
expect(store.listRules()[0].enabled).toBe(false)
})
it('对不存在的 id 做更新/启停返回 undefined,删除返回 false', () => {
const store = new AutomationRuleStore(deps(root))
expect(store.updateRule('nope', { name: 'x' })).toBeUndefined()
expect(store.setRuleEnabled('nope', true)).toBeUndefined()
expect(store.deleteRule('nope')).toBe(false)
})
it('规则文件损坏时降级为空并重新播种,而不是让功能整体不可用', () => {
fs.ensureDirSync(path.dirname(rulesFile()))
fs.writeFileSync(rulesFile(), '{ 这不是 JSON', 'utf8')
const store = new AutomationRuleStore(deps(root))
expect(store.listRules()).toHaveLength(1)
expect(store.listRules()[0].id).toBe(BUILTIN_DAILY_REPORT_RULE_ID)
})
it('已播种但规则列表为空的存档不会被再次播种', () => {
fs.ensureDirSync(path.dirname(rulesFile()))
fs.writeJsonSync(rulesFile(), { version: 1, builtinSeeded: true, rules: [] })
const store = new AutomationRuleStore(deps(root))
expect(store.listRules()).toHaveLength(0)
})
it('写入的是真实文件,重新打开能读到', () => {
const store = new AutomationRuleStore(deps(root))
store.createRule({ name: '持久化检查', conditions: {}, actions: [] })
const reopened = new AutomationRuleStore(deps(root))
expect(reopened.listRules().map((rule) => rule.name)).toContain('持久化检查')
})
})
describe('AutomationExecutionLogService', () => {
beforeEach(() => {
fs.removeSync(path.join(root, 'automation'))
})
const execution = (overrides: Partial<AutomationExecution> = {}): AutomationExecution => ({
executionId: 'exec-1',
ruleId: 'rule-1',
ruleName: '测试规则',
triggerTime: 1_700_000_000_000,
sourceDisplayName: '测试群',
status: 'success',
durationMs: 1_234,
steps: [{ key: 'received', label: '收到消息', status: 'success' }],
...overrides
})
it('记录并返回,新的在前', () => {
const log = new AutomationExecutionLogService({ userDataPath: () => root })
log.record(execution({ executionId: 'a', ruleName: '规则A' }))
log.record(execution({ executionId: 'b', ruleName: '规则B' }))
expect(log.list().map((item) => item.ruleName)).toEqual(['规则B', '规则A'])
})
it('相同 executionId 会覆盖而不是追加(先 running 后终态)', () => {
const log = new AutomationExecutionLogService({ userDataPath: () => root })
log.record(execution({ status: 'running', steps: [] }))
log.record(execution({ status: 'failed', errorSummary: '生成日报失败' }))
const records = log.list()
expect(records).toHaveLength(1)
expect(records[0].status).toBe('failed')
expect(records[0].errorSummary).toBe('生成日报失败')
})
it('容量有界,超出后丢弃最旧的', () => {
const log = new AutomationExecutionLogService({ userDataPath: () => root })
for (let index = 0; index < 250; index += 1) {
log.record(execution({ executionId: `exec-${index}`, ruleName: `规则${index}` }))
}
const records = log.list({ limit: 1000 })
expect(records).toHaveLength(200)
expect(records[0].ruleName).toBe('规则249')
expect(records.some((item) => item.ruleName === '规则49')).toBe(false)
})
it('countSince 只统计时间窗口内的记录', () => {
const log = new AutomationExecutionLogService({ userDataPath: () => root })
log.record(execution({ executionId: 'old', triggerTime: 1_000, status: 'success' }))
log.record(execution({ executionId: 'new-ok', triggerTime: 5_000, status: 'success' }))
log.record(execution({ executionId: 'new-bad', triggerTime: 6_000, status: 'failed' }))
expect(log.countSince(4_000)).toEqual({ total: 2, success: 1 })
expect(log.countSince(0)).toEqual({ total: 3, success: 2 })
})
it('clear 清空并落盘', () => {
const log = new AutomationExecutionLogService({ userDataPath: () => root })
log.record(execution())
expect(log.clear()).toBe(true)
expect(log.list()).toHaveLength(0)
expect(new AutomationExecutionLogService({ userDataPath: () => root }).list()).toHaveLength(0)
})
it('落盘后新实例能读到(重启后仍能看到近期执行记录)', () => {
const log = new AutomationExecutionLogService({ userDataPath: () => root })
log.record(execution({ ruleName: '重启检查' }))
expect(fs.pathExistsSync(executionsFile())).toBe(true)
const reloaded = new AutomationExecutionLogService({ userDataPath: () => root })
expect(reloaded.list()[0].ruleName).toBe('重启检查')
})
it('文件损坏时降级为空数组而不是抛异常', () => {
fs.ensureDirSync(path.dirname(executionsFile()))
fs.writeFileSync(executionsFile(), 'not json at all', 'utf8')
const log = new AutomationExecutionLogService({ userDataPath: () => root })
expect(log.list()).toEqual([])
})
it('丢弃缺少 executionId 的脏记录', () => {
const log = new AutomationExecutionLogService({ userDataPath: () => root })
log.record({ ...execution(), executionId: ' ' })
expect(log.list()).toHaveLength(0)
})
it('list 的 limit 参数生效', () => {
const log = new AutomationExecutionLogService({ userDataPath: () => root })
for (let index = 0; index < 10; index += 1) {
log.record(execution({ executionId: `exec-${index}` }))
}
expect(log.list({ limit: 3 })).toHaveLength(3)
})
})
+387
View File
@@ -0,0 +1,387 @@
// `vi.mock` 的工厂会被提升到 import 之前执行,所以根目录必须用 `vi.hoisted` 先备好,
// 否则工厂里引用的 `root` 还在 TDZ 里。
const root = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const fs = require('node:fs') as typeof import('node:fs')
// eslint-disable-next-line @typescript-eslint/no-require-imports
const os = require('node:os') as typeof import('node:os')
// eslint-disable-next-line @typescript-eslint/no-require-imports
const path = require('node:path') as typeof import('node:path')
return fs.mkdtempSync(path.join(os.tmpdir(), 'wxe-automation-service-'))
})
vi.mock('electron', () => ({
app: { getPath: () => root },
safeStorage: {
isEncryptionAvailable: () => false,
encryptString: (value: string) => Buffer.from(value, 'utf8'),
decryptString: (value: Buffer) => value.toString('utf8')
},
BrowserWindow: { getAllWindows: () => [] }
}))
import fs from 'fs-extra'
import path from 'path'
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
import type { PersonalWechatSendCapability } from '../../src/shared/personal-wechat'
import type { Wcdb4Client } from '../../src/main/wcdb4-client'
import { AutomationService } from '../../src/main/services/automation-service'
import { AutomationRuleStore } from '../../src/main/services/automation-rule-store'
import { AutomationExecutionLogService } from '../../src/main/services/automation-execution-log-service'
import { AutomationActionRunner } from '../../src/main/services/automation-action-runner'
import type { NormalizedIncomingMessage } from '../../src/main/services/message-listener-service'
import type { AutomationRunInput, AutomationRunResult } from '../../src/main/services/automation-action-runner'
/**
* AutomationService 的编排语义:四道闸 + 执行日志落盘 + 发送能力预检。
*/
const SELF = 'my_account_9527'
const GROUP_ID = '12345678@chatroom'
const OTHER_GROUP = '87654321@chatroom'
/** 完整可用的发送能力替身(不用强转,避免类型加字段后静默失真)。 */
function capability(overrides: Partial<PersonalWechatSendCapability> = {}): PersonalWechatSendCapability {
return {
supported: true,
ready: true,
status: 'ready',
capabilities: { text: true, image: true, voice: false },
message: '个人微信已准备好发送日报',
senderStatus: {
state: 'online',
platform: 'darwin',
arch: 'arm64',
sipDisabled: false,
wechatRunning: true,
endpoint: '127.0.0.1:0',
endpointReady: true,
runtimeReady: true,
attachReady: true,
baseAddressReady: true,
textHookInstalled: true,
textHookReady: true,
imageHookInstalled: true,
imageHookReady: true,
messageListenerReady: true,
canSend: true,
canSendText: true,
canSendImage: true,
canSendVoice: false,
message: '个人微信已准备好发送日报'
},
...overrides
}
}
function message(overrides: Partial<NormalizedIncomingMessage> = {}): NormalizedIncomingMessage {
return {
sessionId: GROUP_ID,
localId: '100',
createTime: 1_789_900_000,
messageType: 1,
isSelf: false,
senderId: 'wxid_sender',
senderNickname: '张三',
content: '今日日报',
isGroup: true,
mentionTargets: [SELF],
...overrides
}
}
interface Harness {
service: AutomationService
store: AutomationRuleStore
log: AutomationExecutionLogService
runs: AutomationRunInput[]
setCapability: (value: PersonalWechatSendCapability) => void
setRunResult: (value: AutomationRunResult) => void
}
function buildHarness(options: { now?: () => number; capability?: PersonalWechatSendCapability } = {}): Harness {
const store = new AutomationRuleStore()
const log = new AutomationExecutionLogService()
const runs: AutomationRunInput[] = []
let currentCapability = options.capability ?? capability()
let runResult: AutomationRunResult = {
steps: [
{ key: 'received', label: '收到消息', status: 'success' },
{ key: 'matched', label: '规则匹配', status: 'success' },
{ key: 'reply', label: '回复确认', status: 'success' },
{ key: 'report', label: '生成日报', status: 'success' },
{ key: 'send', label: '发送日报图片', status: 'success' }
],
status: 'success'
}
// 只观察 Runner 被怎么调用,不在这里验证它的内部步骤(那是 runner 测试的职责)。
const spyRunner = {
run: async (input: AutomationRunInput): Promise<AutomationRunResult> => {
runs.push(input)
return runResult
}
} as unknown as AutomationActionRunner
const client = {
getMyUsernameCandidates: () => [SELF],
getSessions: () => [{ username: GROUP_ID, nickname: '测试群' }]
} as unknown as Wcdb4Client
const service = new AutomationService(client, {
ruleStore: store,
executionLog: log,
runner: spyRunner,
getCapability: async () => currentCapability,
isListening: () => true,
...(options.now ? { now: options.now } : {})
})
return {
service,
store,
log,
runs,
setCapability: (value) => {
currentCapability = value
},
setRunResult: (value) => {
runResult = value
}
}
}
describe('AutomationService', () => {
beforeEach(() => {
fs.removeSync(path.join(root, 'automation'))
})
afterAll(() => fs.removeSync(root))
it('真 @ + 关键词命中时执行一次,并留下完整的执行记录', async () => {
const harness = buildHarness()
await harness.service.handleMessage(message())
expect(harness.runs).toHaveLength(1)
expect(harness.runs[0]).toMatchObject({
conversationId: GROUP_ID,
isGroup: true,
sourceDisplayName: '测试群'
})
const records = harness.log.list()
expect(records).toHaveLength(1)
expect(records[0]).toMatchObject({
ruleName: '@我生成日报',
sourceDisplayName: '测试群',
status: 'success'
})
expect(records[0].steps.map((step) => step.key)).toEqual([
'received',
'matched',
'reply',
'report',
'send'
])
expect(records[0].executionId).toBe(harness.runs[0].executionId)
})
it('同一条消息被重复投递时只执行一次', async () => {
const harness = buildHarness()
await harness.service.handleMessage(message())
await harness.service.handleMessage(message())
await harness.service.handleMessage(message({ localId: '100' }))
expect(harness.runs).toHaveLength(1)
expect(harness.log.list()).toHaveLength(1)
})
it('同一条消息在不同群里各算一次(localId 单独跨群不唯一)', async () => {
const harness = buildHarness()
await harness.service.handleMessage(message())
await harness.service.handleMessage(message({ sessionId: OTHER_GROUP }))
expect(harness.runs).toHaveLength(2)
})
it('cooldown 内不重复触发', async () => {
let now = 1_700_000_000_000
const harness = buildHarness({ now: () => now })
await harness.service.handleMessage(message({ localId: '1' }))
now += 30_000
await harness.service.handleMessage(message({ localId: '2' }))
expect(harness.runs).toHaveLength(1)
})
it('cooldown 过后可以再次触发', async () => {
let now = 1_700_000_000_000
const harness = buildHarness({ now: () => now })
await harness.service.handleMessage(message({ localId: '1' }))
now += 61_000
await harness.service.handleMessage(message({ localId: '2' }))
expect(harness.runs).toHaveLength(2)
})
it('自己发送的消息不触发(否则「收到,正在生成今日日报」会形成死循环)', async () => {
const harness = buildHarness()
await harness.service.handleMessage(message({ isSelf: true, content: '收到,正在生成今日日报' }))
expect(harness.runs).toHaveLength(0)
expect(harness.log.list()).toHaveLength(0)
})
it('正文里手打 @昵称 但没有真 @ 元数据时不触发', async () => {
const harness = buildHarness()
await harness.service.handleMessage(message({ content: '@我 今日日报', mentionTargets: [] }))
expect(harness.runs).toHaveLength(0)
})
it('停用的规则不触发', async () => {
const harness = buildHarness()
const rule = harness.store.listRules()[0]
harness.store.setRuleEnabled(rule.id, false)
await harness.service.handleMessage(message())
expect(harness.runs).toHaveLength(0)
})
it('不在生效范围内的群不触发', async () => {
const harness = buildHarness()
const rule = harness.store.listRules()[0]
harness.store.updateRule(rule.id, { ...rule, conditions: { ...rule.conditions, conversationIds: [OTHER_GROUP] } })
await harness.service.handleMessage(message())
expect(harness.runs).toHaveLength(0)
})
it('发送能力不足时不执行,并记下说明清楚的失败记录', async () => {
const harness = buildHarness({
capability: capability({
ready: false,
capabilities: { text: false, image: false, voice: false },
message: '请先绑定个人微信'
})
})
await harness.service.handleMessage(message())
expect(harness.runs).toHaveLength(0)
const records = harness.log.list()
expect(records).toHaveLength(1)
expect(records[0].status).toBe('failed')
expect(records[0].errorSummary).toContain('当前环境无法发送文字')
})
it('只能发文字、不能发图片时不执行(不能假装日报发得出去)', async () => {
const harness = buildHarness({
capability: capability({ capabilities: { text: true, image: false, voice: false } })
})
await harness.service.handleMessage(message())
expect(harness.runs).toHaveLength(0)
expect(harness.log.list()[0].errorSummary).toContain('当前环境无法发送图片')
})
it('拿不到会话昵称时也不把 wxid 写进执行记录', async () => {
const store = new AutomationRuleStore()
const log = new AutomationExecutionLogService()
const client = {
getMyUsernameCandidates: () => [SELF],
getSessions: () => []
} as unknown as Wcdb4Client
const service = new AutomationService(client, {
ruleStore: store,
executionLog: log,
runner: {
run: async (input: AutomationRunInput) => ({
steps: [],
status: 'success' as const,
...(input ? {} : {})
})
} as unknown as AutomationActionRunner,
getCapability: async () => capability()
})
await service.handleMessage(message())
const record = log.list()[0]
expect(record.sourceDisplayName).toBe('群聊')
expect(JSON.stringify(record)).not.toContain('@chatroom')
expect(JSON.stringify(record)).not.toContain('wxid')
})
it('执行记录里不出现 localId / serverId / source 等内部字段', async () => {
const harness = buildHarness()
await harness.service.handleMessage(
message({ serverId: '7000000000000000001', localId: '424242' })
)
const serialized = JSON.stringify(harness.log.list())
expect(serialized).not.toContain('424242')
expect(serialized).not.toContain('7000000000000000001')
expect(serialized).not.toContain('localId')
expect(serialized).not.toContain('serverId')
})
it('执行失败时把失败摘要带进执行记录', async () => {
const harness = buildHarness()
harness.setRunResult({
steps: [
{ key: 'received', label: '收到消息', status: 'success' },
{ key: 'matched', label: '规则匹配', status: 'success' },
{ key: 'reply', label: '回复确认', status: 'success' },
{ key: 'report', label: '生成日报', status: 'failed', error: 'AI 服务不可达' },
{ key: 'send', label: '发送日报图片', status: 'skipped' }
],
status: 'failed',
errorSummary: 'AI 服务不可达'
})
await harness.service.handleMessage(message())
const record = harness.log.list()[0]
expect(record.status).toBe('failed')
expect(record.errorSummary).toBe('AI 服务不可达')
expect(record.steps.find((step) => step.key === 'send')?.status).toBe('skipped')
})
it('执行日志落盘后能被新的实例读到(重启不丢)', async () => {
const harness = buildHarness()
await harness.service.handleMessage(message())
const reloaded = new AutomationExecutionLogService()
expect(reloaded.list()).toHaveLength(1)
expect(reloaded.list()[0].ruleName).toBe('@我生成日报')
})
it('getStatus 如实反映监听状态、发送能力与今日执行数', async () => {
const harness = buildHarness()
await harness.service.handleMessage(message())
const status = await harness.service.getStatus()
expect(status.listening).toBe(true)
// 底层只能回读最近活跃会话,这个降级标记必须一直是 true。
expect(status.listeningDegraded).toBe(true)
expect(status.todayExecutions).toBe(1)
expect(status.todaySuccesses).toBe(1)
expect(status.sendCapability).toMatchObject({
supported: true,
ready: true,
canSendText: true,
canSendImage: true
})
})
it('handleMessage 内部异常不会向外抛', async () => {
const harness = buildHarness()
const brokenStore = {
listRules: () => {
throw new Error('磁盘炸了')
}
} as unknown as AutomationRuleStore
const service = new AutomationService(
{ getMyUsernameCandidates: () => [SELF], getSessions: () => [] } as unknown as Wcdb4Client,
{ ruleStore: brokenStore }
)
await expect(service.handleMessage(message())).resolves.toBeUndefined()
void harness
})
})
+346
View File
@@ -0,0 +1,346 @@
import { zstdCompressSync } from 'node:zlib'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
MessageListenerService,
decodeSourcePayload,
extractMentionTargets,
type NormalizedIncomingMessage
} from '../../src/main/services/message-listener-service'
import type { Wcdb4Client, Wcdb4Message } from '../../src/main/wcdb4-client'
/**
* MessageListener 底座测试。
*
* 覆盖 Spike 里用真实环境验证过的每一条行为,确保「正式实现 ≈ Spike 等价」。
* 所有身份标识都用 `SELF_USER` / `OTHER_USER` 占位 —— 测试里**不得**出现真实 wxid。
*/
const SESSION_GROUP = 'group_A@chatroom'
const SESSION_DIRECT = 'user_A'
const makeMessage = (overrides: Partial<Wcdb4Message> = {}): Wcdb4Message => ({
mesLocalID: '100',
serverId: '7000000000000000001',
mesDes: 1,
// ⚠️ WCDB 层这几个字段是 **string**(见 Wcdb4Message 定义),
// 归一化时才转 number。mock 必须跟随真实类型,否则类型检查会红。
messageType: '1',
msgCreateTime: '1789900000',
msgContent: 'hello',
sender: 'OTHER_USER',
senderNickname: '对方昵称',
senderAvatar: '',
raw: {},
...overrides
})
/**
* zstd 压缩一段文本,用来构造真实的 `source` 列形态。
*
* 用 **Node 内置 `zlib`** 而不是被测代码依赖的 `fzstd`:
* 这样测试侧的「压缩」与实现侧的「解压」是**两条独立的实现路径**,
* 不会因为同一个库的同一个缺陷而互相掩盖。
* (`fzstd` 本身只提供解压,没有压缩接口。)
*/
async function zstdBuffer(text: string): Promise<Buffer> {
return zstdCompressSync(Buffer.from(text, 'utf8'))
}
function makeClient(messages: Wcdb4Message[], username = SESSION_GROUP) {
return {
getSessions: vi.fn(() => [{ username }]),
getMessagesAsync: vi.fn(async () => messages)
} as unknown as Wcdb4Client
}
let received: NormalizedIncomingMessage[] = []
const collect = (listener: MessageListenerService): void => {
listener.onMessage((message) => received.push(message))
}
/** 推进 coalesce 窗口并等待回读完成。 */
const flush = async (ms = 200): Promise<void> => {
await vi.advanceTimersByTimeAsync(ms)
}
beforeEach(() => {
received = []
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
})
describe('mention parser(纯函数)', () => {
it('CDATA 形式:剥掉外壳,只留 username', () => {
const source =
'<msgsource><atuserlist><![CDATA[SELF_USER]]></atuserlist><silence>1</silence></msgsource>'
expect(extractMentionTargets(source)).toEqual(['SELF_USER'])
})
it('裸文本形式同样支持', () => {
const source = '<msgsource><atuserlist>OTHER_USER</atuserlist></msgsource>'
expect(extractMentionTargets(source)).toEqual(['OTHER_USER'])
})
it('没有 atuserlist 时返回空数组', () => {
expect(extractMentionTargets('<msgsource><silence>1</silence></msgsource>')).toEqual([])
expect(extractMentionTargets(undefined)).toEqual([])
expect(extractMentionTargets('')).toEqual([])
})
it('假 @:正文里有 @昵称,但 source 没有 atuserlist ⇒ 不产生 mention', () => {
const source = '<msgsource><silence>1</silence><membercount>34</membercount></msgsource>'
expect(extractMentionTargets(source)).toEqual([])
})
it('不假设 username 以 wxid_ 开头(本机账号的 username 可能完全是别的形式)', () => {
const source = '<msgsource><atuserlist>SELF_USER</atuserlist></msgsource>'
// 若实现里写了 `startsWith('wxid_')` 过滤,这条会变成 []
expect(extractMentionTargets(source)).toEqual(['SELF_USER'])
})
})
describe('decodeSourcePayload', () => {
it('zstd 压缩的 Buffer 能解出 XML', async () => {
const xml = '<msgsource><atuserlist>SELF_USER</atuserlist></msgsource>'
const buffer = await zstdBuffer(xml)
// 魔数确认:确保我们构造的确实是 zstd
expect([...buffer.subarray(0, 4)]).toEqual([0x28, 0xb5, 0x2f, 0xfd])
expect(decodeSourcePayload(buffer)).toBe(xml)
})
it('Node Buffer 的 JSON 形态({type:"Buffer",data:[...]})也能解', async () => {
const xml = '<msgsource><atuserlist>SELF_USER</atuserlist></msgsource>'
const buffer = await zstdBuffer(xml)
const jsonForm = JSON.parse(JSON.stringify(buffer)) as { type: string; data: number[] }
expect(decodeSourcePayload(jsonForm)).toBe(xml)
})
it('空值 / 非 zstd 内容安全返回', () => {
expect(decodeSourcePayload(undefined)).toBeUndefined()
expect(decodeSourcePayload(Buffer.alloc(0))).toBeUndefined()
expect(decodeSourcePayload(Buffer.from('plain text'))).toBe('plain text')
})
})
describe('coalesce:一批 native event 只回读一次', () => {
it('同一窗口收到 17 个 event,只产生 1 次回读、消息只投递 1 次', async () => {
const client = makeClient([makeMessage()])
const listener = new MessageListenerService(client)
collect(listener)
// 模拟实测观察到的「一条消息 ≈ 17 个 native event」
for (let index = 0; index < 17; index += 1) listener.handleNativeChange()
await flush()
expect((client as unknown as { getMessagesAsync: ReturnType<typeof vi.fn> }).getMessagesAsync)
.toHaveBeenCalledTimes(1)
expect(received).toHaveLength(1)
expect(listener.stats()).toMatchObject({
nativeEvents: 17,
coalescedEvents: 16,
readbacks: 1,
delivered: 1
})
})
})
describe('dedup', () => {
it('同一 sessionId:localId 被回读多次,只投递一次', async () => {
const client = makeClient([makeMessage({ mesLocalID: '100' })])
const listener = new MessageListenerService(client)
collect(listener)
// 三个分开的窗口,每次都读到同一条消息
for (let round = 0; round < 3; round += 1) {
listener.handleNativeChange()
await flush()
}
expect(received).toHaveLength(1)
expect(listener.stats().delivered).toBe(1)
expect(listener.stats().deduped).toBeGreaterThanOrEqual(2)
})
it('sessionId 相同但 localId 不同 ⇒ 不是同一条', async () => {
const client = makeClient([makeMessage({ mesLocalID: '100' })])
const listener = new MessageListenerService(client)
collect(listener)
listener.handleNativeChange()
await flush()
// 换成另一个 localId 再读一轮
;(client as unknown as { getMessagesAsync: ReturnType<typeof vi.fn> }).getMessagesAsync.mockResolvedValue(
[makeMessage({ mesLocalID: '101' })]
)
listener.handleNativeChange()
await flush()
expect(received.map((message) => message.localId)).toEqual(['100', '101'])
})
it('dedup 缓存有界:超过上限会淘汰最旧条目', async () => {
const client = makeClient([])
const listener = new MessageListenerService(client, { dedupMaxEntries: 3 })
collect(listener)
const send = async (localId: string): Promise<void> => {
;(client as unknown as { getMessagesAsync: ReturnType<typeof vi.fn> }).getMessagesAsync.mockResolvedValue(
[makeMessage({ mesLocalID: localId })]
)
listener.handleNativeChange()
await flush()
}
for (const id of ['1', '2', '3', '4', '5']) await send(id)
// 5 条都投递过(容量 3 只会影响「还能记住多少」)
expect(received).toHaveLength(5)
// 最早的两条已被淘汰 ⇒ 再读到 '1' 时会重新投递
await send('1')
expect(received.filter((message) => message.localId === '1')).toHaveLength(2)
})
})
describe('连续消息', () => {
it('同一窗口读到 3 条,必须全部投递(不能只取最后一条)', async () => {
const client = makeClient([
makeMessage({ mesLocalID: '100' }),
makeMessage({ mesLocalID: '101' }),
makeMessage({ mesLocalID: '102' })
])
const listener = new MessageListenerService(client)
collect(listener)
listener.handleNativeChange()
await flush()
expect(received.map((message) => message.localId)).toEqual(['100', '101', '102'])
expect(listener.stats().delivered).toBe(3)
})
})
describe('self / incoming', () => {
it('mesDes = 0 ⇒ isSelf = true', async () => {
const client = makeClient([makeMessage({ mesDes: 0, sender: 'SELF_USER' })])
const listener = new MessageListenerService(client)
collect(listener)
listener.handleNativeChange()
await flush()
expect(received[0].isSelf).toBe(true)
})
it('mesDes = 1 ⇒ isSelf = false', async () => {
const client = makeClient([makeMessage({ mesDes: 1 })])
const listener = new MessageListenerService(client)
collect(listener)
listener.handleNativeChange()
await flush()
expect(received[0].isSelf).toBe(false)
})
})
describe('normalize 输出', () => {
it('群消息:isGroup = true,且 mentionTargets 来自 source', async () => {
const source = '<msgsource><atuserlist><![CDATA[SELF_USER]]></atuserlist></msgsource>'
const client = makeClient([
makeMessage({ raw: { source: await zstdBuffer(source) } })
])
const listener = new MessageListenerService(client)
collect(listener)
listener.handleNativeChange()
await flush()
const message = received[0]
expect(message.isGroup).toBe(true)
expect(message.mentionTargets).toEqual(['SELF_USER'])
expect(message.source).toContain('atuserlist')
})
it('私聊:isGroup = false,无 source 时 mentionTargets 为空', async () => {
const client = makeClient([makeMessage({ raw: {} })], SESSION_DIRECT)
const listener = new MessageListenerService(client)
collect(listener)
listener.handleNativeChange()
await flush()
expect(received[0].isGroup).toBe(false)
expect(received[0].mentionTargets).toEqual([])
})
it('createTime 保持 epoch 秒(不做毫秒换算)', async () => {
const client = makeClient([makeMessage({ msgCreateTime: '1789900000' })])
const listener = new MessageListenerService(client)
collect(listener)
listener.handleNativeChange()
await flush()
expect(received[0].createTime).toBe(1_789_900_000)
expect(received[0].createTime).toBeLessThan(1e11)
})
it('缺关键标识(无 localId 或无 createTime)的行不投递', async () => {
const client = makeClient([
makeMessage({ mesLocalID: '' }),
makeMessage({ mesLocalID: '200', msgCreateTime: '0' }),
makeMessage({ mesLocalID: '201' })
])
const listener = new MessageListenerService(client)
collect(listener)
listener.handleNativeChange()
await flush()
expect(received.map((message) => message.localId)).toEqual(['201'])
})
})
describe('Observation Mode 约束', () => {
it('没有订阅者时不会崩,也不会产生投递', async () => {
const client = makeClient([makeMessage()])
const listener = new MessageListenerService(client)
listener.handleNativeChange()
await flush()
expect(listener.stats().readbacks).toBe(1)
expect(listener.stats().delivered).toBe(1)
expect(received).toHaveLength(0)
})
it('dispose 之后不再响应事件', async () => {
const client = makeClient([makeMessage()])
const listener = new MessageListenerService(client)
collect(listener)
listener.dispose()
listener.handleNativeChange()
await flush()
expect(received).toHaveLength(0)
expect(listener.stats().nativeEvents).toBe(0)
})
it('取消订阅后不再收到消息', async () => {
const client = makeClient([makeMessage()])
const listener = new MessageListenerService(client)
const unsubscribe = listener.onMessage((message) => received.push(message))
unsubscribe()
listener.handleNativeChange()
await flush()
expect(received).toHaveLength(0)
})
})