mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-07 06:17:07 +08:00
feat: 自动化
This commit is contained in:
@@ -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