mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-04 03:01:42 +08:00
feat: 精确会话自动化规则
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { RuleEditorPanel } from '../../src/renderer/src/features/automation/RuleEditorPanel'
|
||||
import {
|
||||
createDefaultDailyReportRule,
|
||||
type AutomationRule,
|
||||
type AutomationRuleDraft
|
||||
} from '../../src/shared/automation'
|
||||
|
||||
/**
|
||||
* 「回复前等待」是**规则自己的一项执行参数**,配置位置就是
|
||||
* 「编辑自动化 → 3 · 触发后执行(命中条件后,TraceMemo 会按下面的顺序依次执行)」。
|
||||
*
|
||||
* 这一层要锁的是**接线**:初值必须来自规则本身、保存必须带回去。
|
||||
* 因为 `draftFromRule` 是逐字段搬运的,漏一个字段就会被 `normalizeRuleDraft`
|
||||
* 的默认值悄悄覆盖(把用户设成 0 的等待重置回 2 秒),这种 bug 只有真渲染才发现。
|
||||
*/
|
||||
const GROUPS = [{ id: '12345678@chatroom', name: '测试群' }]
|
||||
|
||||
function renderEditor(
|
||||
rule: AutomationRule,
|
||||
onSave: (draft: AutomationRuleDraft) => void = () => {}
|
||||
): void {
|
||||
render(
|
||||
<RuleEditorPanel
|
||||
mode="edit"
|
||||
rule={rule}
|
||||
groups={GROUPS}
|
||||
saving={false}
|
||||
onCancel={() => {}}
|
||||
onSave={onSave}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const delayField = (): HTMLInputElement =>
|
||||
screen.getByLabelText('回复前等待秒数') as HTMLInputElement
|
||||
|
||||
describe('RuleEditorPanel · 回复前等待', () => {
|
||||
it('它就在「3 · 触发后执行」里,和动作顺序在同一段', () => {
|
||||
renderEditor(createDefaultDailyReportRule(0))
|
||||
|
||||
expect(screen.getByText('命中条件后,TraceMemo 会按下面的顺序依次执行。')).toBeInTheDocument()
|
||||
expect(delayField()).toBeInTheDocument()
|
||||
expect(screen.getByText('回复前等待(秒)')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('初值来自规则本身,不是硬编码的默认值', () => {
|
||||
const rule = createDefaultDailyReportRule(0)
|
||||
rule.replyDelaySeconds = 7
|
||||
renderEditor(rule)
|
||||
|
||||
expect(delayField()).toHaveValue(7)
|
||||
})
|
||||
|
||||
it('保存时把改动带回去', () => {
|
||||
const saved: AutomationRuleDraft[] = []
|
||||
renderEditor(createDefaultDailyReportRule(0), (draft) => saved.push(draft))
|
||||
|
||||
fireEvent.change(delayField(), { target: { value: '0' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存' }))
|
||||
|
||||
expect(saved).toHaveLength(1)
|
||||
expect(saved[0].replyDelaySeconds).toBe(0)
|
||||
})
|
||||
|
||||
it('用户设成 0(立刻回复)时,保存不会把它顶回默认 2 秒', () => {
|
||||
const saved: AutomationRuleDraft[] = []
|
||||
const rule = createDefaultDailyReportRule(0)
|
||||
rule.replyDelaySeconds = 0
|
||||
renderEditor(rule, (draft) => saved.push(draft))
|
||||
|
||||
// 什么都不改,直接保存:0 必须原样保留(而不是被默认的 2 顶掉)。
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存' }))
|
||||
|
||||
expect(saved[0].replyDelaySeconds).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* 「所有群聊」的边界提示。
|
||||
*
|
||||
* 真实能力是:同一时刻多个会话同时来消息时可能漏掉少量触发。
|
||||
* 这条限制**只挂在「所有群聊」这个上下文里** —— 首页不挂、指定群聊时不挂,
|
||||
* 而且只讲用户能理解的结果,不解释表事件 / 会话回读这类实现细节。
|
||||
*/
|
||||
const LIMITATION_TEXT = '当前版本在多个会话同时收到消息时,极少数自动化触发可能遗漏。'
|
||||
|
||||
function renderScopeEditor(conversationIds: string[]): void {
|
||||
const rule = createDefaultDailyReportRule(0)
|
||||
rule.conditions.conversationIds = conversationIds
|
||||
renderEditor(rule)
|
||||
}
|
||||
|
||||
describe('RuleEditorPanel · 生效范围的边界提示', () => {
|
||||
it('一个群都没选(所有群聊)时显示提示', () => {
|
||||
renderScopeEditor([])
|
||||
|
||||
// 编辑页与右侧效果预览都会显示范围描述,所以这里用 getAllByText。
|
||||
expect(screen.getAllByText('所有群聊').length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByText(LIMITATION_TEXT)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('选了具体群聊时不显示提示', () => {
|
||||
renderScopeEditor([GROUPS[0].id])
|
||||
|
||||
expect(screen.getAllByText('已选 1 个群').length).toBeGreaterThan(0)
|
||||
expect(screen.queryAllByText(LIMITATION_TEXT)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('提示里不出现底层实现词,也不做绝对承诺', () => {
|
||||
renderScopeEditor([])
|
||||
|
||||
expect(LIMITATION_TEXT).not.toMatch(/WCDB|SessionTable|表变化|native|回读/)
|
||||
expect(document.body.textContent).not.toMatch(/覆盖所有群聊|100%|不会遗漏/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { ToastProvider } from '../../src/renderer/src/components/ui'
|
||||
import { AutomationWorkspace } from '../../src/renderer/src/features/automation/AutomationWorkspace'
|
||||
|
||||
/**
|
||||
* 首页状态卡只讲用户能理解的状态。
|
||||
*
|
||||
* 以前这里挂着一句「当前仅能捕获最近活跃的会话」,还用 Tooltip 解释了
|
||||
* 「微信底层只会上报有表发生了变化」——那是开发报告的内容,不该出现在普通用户界面。
|
||||
* 真实边界改挂在「规则编辑页 → 所有群聊」那个具体上下文里。
|
||||
*/
|
||||
describe('Automation 首页状态卡', () => {
|
||||
function renderWorkspace(): void {
|
||||
render(
|
||||
<ToastProvider>
|
||||
<AutomationWorkspace dbReady={false} />
|
||||
</ToastProvider>
|
||||
)
|
||||
}
|
||||
|
||||
it('仍然如实展示「消息监听」与发送能力两项', () => {
|
||||
renderWorkspace()
|
||||
|
||||
expect(screen.getByText('消息监听')).toBeInTheDocument()
|
||||
expect(screen.getByText('发送能力')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('不再出现「最近活跃的会话」这类实现说明', () => {
|
||||
renderWorkspace()
|
||||
|
||||
expect(screen.queryByText(/最近活跃的会话/)).toBeNull()
|
||||
expect(screen.queryByText(/有表发生了变化/)).toBeNull()
|
||||
})
|
||||
|
||||
it('首页不出现底层实现词', () => {
|
||||
renderWorkspace()
|
||||
|
||||
expect(document.body.textContent).not.toMatch(
|
||||
/WCDB|SessionTable|表变化|native event|回读机制|coalesce/
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -131,6 +131,10 @@ function buildChain(): Chain {
|
||||
|
||||
const store = new AutomationRuleStore({ userDataPath: () => root })
|
||||
const log = new AutomationExecutionLogService({ userDataPath: () => root })
|
||||
// 回复等待归零:端到端链路要跑得快且可复现。
|
||||
// 真实默认值是 2 秒(编辑自动化 → 3 · 触发后执行),等待语义本身由 runner 单测覆盖。
|
||||
const seeded = store.listRules()[0]
|
||||
if (seeded) store.updateRule(seeded.id, { ...seeded, replyDelaySeconds: 0 })
|
||||
const runner = new AutomationActionRunner({
|
||||
executeAction: (request) => gateway.execute(request),
|
||||
generateReport: async (request) => {
|
||||
|
||||
@@ -53,6 +53,14 @@ function fakeClock(step = 5): () => number {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认不真的等。
|
||||
*
|
||||
* 内置规则的 `replyDelaySeconds` 默认是 2 秒,如果每条例都真 sleep,
|
||||
* 这个文件会平白多出几十秒。**等待语义**由「回复前等待」那组用例显式覆盖。
|
||||
*/
|
||||
const noDelay = async (): Promise<void> => {}
|
||||
|
||||
function sentResult(): WechatActionResult {
|
||||
return {
|
||||
actionId: 'action-1',
|
||||
@@ -78,6 +86,7 @@ function failedResult(code: string, reason: string): WechatActionResult {
|
||||
function buildRunner(options: {
|
||||
executeAction?: (request: WechatActionRequest) => Promise<WechatActionResult>
|
||||
generateReport?: () => Promise<AgentGroupReportResult>
|
||||
delay?: (ms: number) => Promise<void>
|
||||
}): {
|
||||
runner: AutomationActionRunner
|
||||
calls: WechatActionRequest[]
|
||||
@@ -87,6 +96,7 @@ function buildRunner(options: {
|
||||
const reports: Array<{ group: string; range?: string }> = []
|
||||
const runner = new AutomationActionRunner({
|
||||
now: fakeClock(),
|
||||
delay: options.delay ?? noDelay,
|
||||
executeAction: async (request) => {
|
||||
calls.push(request)
|
||||
return options.executeAction ? options.executeAction(request) : sentResult()
|
||||
@@ -184,6 +194,8 @@ describe('AutomationActionRunner', () => {
|
||||
expect(statusOf(result, 'report')).toBe('failed')
|
||||
expect(statusOf(result, 'send')).toBe('skipped')
|
||||
expect(result.errorSummary).toBe('所选时间范围没有可总结的消息')
|
||||
// 跳过必须写清「是因为前置那步挂了」,否则用户看到「已跳过」会以为规则没配好。
|
||||
expect(result.steps.find((step) => step.key === 'send')?.skipReason).toContain('前置步骤失败')
|
||||
// 只发过那条文字回复,图片一次都没发。
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0].content.type).toBe('text')
|
||||
@@ -245,16 +257,38 @@ describe('AutomationActionRunner', () => {
|
||||
expect(calls[0].content.type).toBe('image')
|
||||
})
|
||||
|
||||
it('未启用生成日报时,发送图片也必须 skipped(不能凭空发图)', async () => {
|
||||
it('规则没启用「发送日报图片」时,该步 skipped 且整体仍算成功(情况 A)', async () => {
|
||||
const { runner, calls, 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, 'reply')).toBe('success')
|
||||
expect(statusOf(result, 'report')).toBe('success')
|
||||
expect(statusOf(result, 'send')).toBe('skipped')
|
||||
// 没配发送就只发了一条回复,不该有多余的图片请求。
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0].content.type).toBe('text')
|
||||
expect(reports).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('规则要求发送图片、但手上没有图片时判失败,不允许静默跳过(情况 B)', async () => {
|
||||
const { runner, calls } = buildRunner({})
|
||||
// 只留 reply + send:没有 generateReport ⇒ 永远拿不到 pngPath。
|
||||
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(statusOf(result, 'send')).toBe('failed')
|
||||
// 关键:整次执行必须是 failed(合并分支会把它错记成 success)。
|
||||
expect(result.status).toBe('failed')
|
||||
const sendStep = result.steps.find((step) => step.key === 'send')
|
||||
expect(sendStep?.error).toContain('日报图片未生成')
|
||||
// 而且**绝不**退而求其次去发别的东西:只有那条文字回复出去过。
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0].content.type).toBe('text')
|
||||
})
|
||||
@@ -287,6 +321,7 @@ describe('AutomationActionRunner', () => {
|
||||
const executeAction = vi.fn(async (_request: WechatActionRequest) => sentResult())
|
||||
const runner = new AutomationActionRunner({
|
||||
now: fakeClock(),
|
||||
delay: noDelay,
|
||||
executeAction,
|
||||
generateReport: async () => ({ success: true, pngPath: '/tmp/report.png' })
|
||||
})
|
||||
@@ -304,4 +339,95 @@ describe('AutomationActionRunner', () => {
|
||||
id: 'wxid_friend'
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* 回复前等待:规则一命中就秒回看起来像机器人,所以它是**规则自己的一项执行参数**
|
||||
* (`rule.replyDelaySeconds`,在「编辑自动化 → 3 · 触发后执行」里配),
|
||||
* 而不是全局设置 —— 不同规则可以不一样。
|
||||
*
|
||||
* 这一组锁三件事:等待真的发生在回复之前、等待不计入回复步骤耗时、
|
||||
* 以及「不该等的时候一步都不等」。
|
||||
*/
|
||||
it('回复等待发生在发送回复之前,顺序为 等待 → 回复 → 日报 → 图片', async () => {
|
||||
const order: string[] = []
|
||||
const delay = vi.fn(async (ms: number) => {
|
||||
order.push(`delay:${ms}`)
|
||||
})
|
||||
const runner = new AutomationActionRunner({
|
||||
now: fakeClock(),
|
||||
delay,
|
||||
executeAction: async (request) => {
|
||||
order.push(`send:${request.content.type}`)
|
||||
return sentResult()
|
||||
},
|
||||
generateReport: async () => {
|
||||
order.push('report')
|
||||
return { success: true, pngPath: '/tmp/report.png' }
|
||||
}
|
||||
})
|
||||
|
||||
await run(
|
||||
runner,
|
||||
makeRule((rule) => {
|
||||
rule.replyDelaySeconds = 2
|
||||
})
|
||||
)
|
||||
|
||||
expect(delay).toHaveBeenCalledTimes(1)
|
||||
expect(delay).toHaveBeenCalledWith(2_000)
|
||||
expect(order).toEqual(['delay:2000', 'send:text', 'report', 'send:image'])
|
||||
})
|
||||
|
||||
it('等待不计入「回复确认」这一步的耗时', async () => {
|
||||
const { runner } = buildRunner({ delay: noDelay })
|
||||
const result = await run(
|
||||
runner,
|
||||
makeRule((rule) => {
|
||||
rule.replyDelaySeconds = 2
|
||||
})
|
||||
)
|
||||
|
||||
// fakeClock 每调用一次进 5ms:reply 从 startedAt 到 finishedAt 只跨一次 now()。
|
||||
const replyStep = result.steps.find((step) => step.key === 'reply')
|
||||
expect(replyStep?.durationMs).toBe(5)
|
||||
})
|
||||
|
||||
it('等待为 0 时完全不等待;字段缺失(旧版 rules.json)按默认 2 秒', async () => {
|
||||
const delay = vi.fn(noDelay)
|
||||
const { runner } = buildRunner({ delay })
|
||||
|
||||
await run(
|
||||
runner,
|
||||
makeRule((rule) => {
|
||||
rule.replyDelaySeconds = 0
|
||||
})
|
||||
)
|
||||
expect(delay).not.toHaveBeenCalled()
|
||||
|
||||
// 历史规则里没有这个字段:按默认 2 秒处理,而不是凭空变成「不等待」。
|
||||
await run(
|
||||
runner,
|
||||
makeRule((rule) => {
|
||||
delete (rule as { replyDelaySeconds?: number }).replyDelaySeconds
|
||||
})
|
||||
)
|
||||
expect(delay).toHaveBeenCalledWith(2_000)
|
||||
})
|
||||
|
||||
it('回复动作被关掉时不再空等', async () => {
|
||||
const delay = vi.fn(noDelay)
|
||||
const { runner } = buildRunner({ delay })
|
||||
|
||||
await run(
|
||||
runner,
|
||||
makeRule((rule) => {
|
||||
rule.replyDelaySeconds = 2
|
||||
for (const action of rule.actions) {
|
||||
if (action.type === 'replyText') action.enabled = false
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
expect(delay).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
AUTOMATION_REPLY_DELAY_DEFAULT_SECONDS,
|
||||
AUTOMATION_REPLY_DELAY_MAX_SECONDS,
|
||||
createDefaultDailyReportRule,
|
||||
normalizeReplyDelaySeconds,
|
||||
normalizeRuleDraft
|
||||
} from '../../src/shared/automation'
|
||||
|
||||
/**
|
||||
* 「回复前等待」的口径:它是**规则自己的一项执行参数**(`replyDelaySeconds`,秒),
|
||||
* 与 `cooldownSeconds` 同口径 —— 界面上是秒、落盘也是秒,只有 runner 换算成毫秒。
|
||||
*
|
||||
* 核心契约:**填错了不该静默变成「不等待」**。默认 2 秒、0 是合法值、
|
||||
* 垃圾输入回落默认、超限截断。
|
||||
*/
|
||||
describe('normalizeReplyDelaySeconds', () => {
|
||||
it('缺省 / 空串 / null 都回落到默认 2 秒', () => {
|
||||
for (const value of [undefined, null, '']) {
|
||||
expect(normalizeReplyDelaySeconds(value)).toBe(AUTOMATION_REPLY_DELAY_DEFAULT_SECONDS)
|
||||
}
|
||||
expect(AUTOMATION_REPLY_DELAY_DEFAULT_SECONDS).toBe(2)
|
||||
})
|
||||
|
||||
it('非法输入回落到默认值,而不是 0', () => {
|
||||
for (const value of ['abc', NaN, Infinity, -Infinity, '两秒', {}]) {
|
||||
expect(normalizeReplyDelaySeconds(value)).toBe(AUTOMATION_REPLY_DELAY_DEFAULT_SECONDS)
|
||||
}
|
||||
})
|
||||
|
||||
it('0 与负数都收敛成「不等待」,但两者语义不同 —— 0 是显式合法值', () => {
|
||||
expect(normalizeReplyDelaySeconds(0)).toBe(0)
|
||||
expect(normalizeReplyDelaySeconds('0')).toBe(0)
|
||||
expect(normalizeReplyDelaySeconds(-1)).toBe(0)
|
||||
})
|
||||
|
||||
it('正常值取整保留,超过上限按上限截断', () => {
|
||||
expect(normalizeReplyDelaySeconds(5)).toBe(5)
|
||||
expect(normalizeReplyDelaySeconds('30')).toBe(30)
|
||||
expect(normalizeReplyDelaySeconds(4.6)).toBe(5)
|
||||
expect(normalizeReplyDelaySeconds(AUTOMATION_REPLY_DELAY_MAX_SECONDS + 1)).toBe(
|
||||
AUTOMATION_REPLY_DELAY_MAX_SECONDS
|
||||
)
|
||||
expect(normalizeReplyDelaySeconds(99_999)).toBe(AUTOMATION_REPLY_DELAY_MAX_SECONDS)
|
||||
})
|
||||
})
|
||||
|
||||
describe('规则里的回复前等待', () => {
|
||||
it('内置「@我生成日报」默认 2 秒', () => {
|
||||
expect(createDefaultDailyReportRule(0).replyDelaySeconds).toBe(
|
||||
AUTOMATION_REPLY_DELAY_DEFAULT_SECONDS
|
||||
)
|
||||
})
|
||||
|
||||
it('草稿归一化会带上这个字段(否则编辑页拿不到值)', () => {
|
||||
const draft = normalizeRuleDraft({
|
||||
name: '测试',
|
||||
replyDelaySeconds: 7,
|
||||
actions: [{ type: 'replyText', enabled: true, text: '收到' }]
|
||||
})
|
||||
expect(draft.replyDelaySeconds).toBe(7)
|
||||
})
|
||||
|
||||
it('旧版 rules.json 里的规则没有这个字段 → 读出来是默认 2 秒,不是 undefined', () => {
|
||||
const draft = normalizeRuleDraft({
|
||||
name: '旧规则',
|
||||
actions: [{ type: 'replyText', enabled: true, text: '收到' }]
|
||||
})
|
||||
expect(draft.replyDelaySeconds).toBe(AUTOMATION_REPLY_DELAY_DEFAULT_SECONDS)
|
||||
})
|
||||
|
||||
it('草稿里的脏值同样被收敛', () => {
|
||||
expect(
|
||||
normalizeRuleDraft({ name: 'x', replyDelaySeconds: -3, actions: [] }).replyDelaySeconds
|
||||
).toBe(0)
|
||||
expect(
|
||||
normalizeRuleDraft({ name: 'x', replyDelaySeconds: 'abc', actions: [] }).replyDelaySeconds
|
||||
).toBe(AUTOMATION_REPLY_DELAY_DEFAULT_SECONDS)
|
||||
expect(
|
||||
normalizeRuleDraft({ name: 'x', replyDelaySeconds: 1e9, actions: [] }).replyDelaySeconds
|
||||
).toBe(AUTOMATION_REPLY_DELAY_MAX_SECONDS)
|
||||
})
|
||||
})
|
||||
@@ -100,7 +100,18 @@ interface Harness {
|
||||
setRunResult: (value: AutomationRunResult) => void
|
||||
}
|
||||
|
||||
function buildHarness(options: { now?: () => number; capability?: PersonalWechatSendCapability } = {}): Harness {
|
||||
function buildHarness(
|
||||
options: {
|
||||
now?: () => number
|
||||
capability?: PersonalWechatSendCapability
|
||||
/** 执行期间的挂起点:resolve 之前 `runner.run` 不会返回(用来模拟长任务)。 */
|
||||
holdExecution?: () => Promise<void>
|
||||
/** 每次真正进入 runner 时通知测试,便于确定时序。 */
|
||||
onRunStart?: () => void
|
||||
/** gate 表容量上限,用来在单测里触发清理路径。 */
|
||||
gateMaxEntries?: number
|
||||
} = {}
|
||||
): Harness {
|
||||
const store = new AutomationRuleStore()
|
||||
const log = new AutomationExecutionLogService()
|
||||
const runs: AutomationRunInput[] = []
|
||||
@@ -120,6 +131,8 @@ function buildHarness(options: { now?: () => number; capability?: PersonalWechat
|
||||
const spyRunner = {
|
||||
run: async (input: AutomationRunInput): Promise<AutomationRunResult> => {
|
||||
runs.push(input)
|
||||
options.onRunStart?.()
|
||||
await options.holdExecution?.()
|
||||
return runResult
|
||||
}
|
||||
} as unknown as AutomationActionRunner
|
||||
@@ -135,7 +148,8 @@ function buildHarness(options: { now?: () => number; capability?: PersonalWechat
|
||||
runner: spyRunner,
|
||||
getCapability: async () => currentCapability,
|
||||
isListening: () => true,
|
||||
...(options.now ? { now: options.now } : {})
|
||||
...(options.now ? { now: options.now } : {}),
|
||||
...(options.gateMaxEntries !== undefined ? { gateMaxEntries: options.gateMaxEntries } : {})
|
||||
})
|
||||
|
||||
return {
|
||||
@@ -187,13 +201,17 @@ describe('AutomationService', () => {
|
||||
expect(records[0].executionId).toBe(harness.runs[0].executionId)
|
||||
})
|
||||
|
||||
it('同一条消息被重复投递时只执行一次', async () => {
|
||||
it('同一条消息被重复投递时只执行一次,且**不产生任何额外记录**', async () => {
|
||||
const harness = buildHarness()
|
||||
// 关掉 cooldown,单独验证「同消息幂等」这条闸 —— 否则第二次会先被 gate 拦下。
|
||||
const seeded = harness.store.listRules()[0]
|
||||
harness.store.updateRule(seeded.id, { ...seeded, cooldownSeconds: 0 })
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
@@ -205,7 +223,7 @@ describe('AutomationService', () => {
|
||||
expect(harness.runs).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('cooldown 内不重复触发', async () => {
|
||||
it('cooldown 内不重复触发,且对用户完全静默', async () => {
|
||||
let now = 1_700_000_000_000
|
||||
const harness = buildHarness({ now: () => now })
|
||||
|
||||
@@ -213,7 +231,12 @@ describe('AutomationService', () => {
|
||||
now += 30_000
|
||||
await harness.service.handleMessage(message({ localId: '2' }))
|
||||
|
||||
// 只执行一次 —— 这条是硬要求。
|
||||
expect(harness.runs).toHaveLength(1)
|
||||
// 但第二次**不留任何痕迹**:没有 execution、没有「已跳过」、没有「冷却中」。
|
||||
expect(harness.log.list()).toHaveLength(1)
|
||||
// 只进诊断计数(不上 UI)。
|
||||
expect(harness.service.getBlockedMessageCount()).toBe(1)
|
||||
})
|
||||
|
||||
it('cooldown 过后可以再次触发', async () => {
|
||||
@@ -370,6 +393,16 @@ describe('AutomationService', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('把命中时的规则整条交给 Runner(含回复前等待)', async () => {
|
||||
const harness = buildHarness()
|
||||
await harness.service.handleMessage(message())
|
||||
|
||||
expect(harness.runs).toHaveLength(1)
|
||||
// 「回复前等待」是规则自己的字段,编排层只负责把规则原样传下去 ——
|
||||
// 不该再有一份全局设置参与,否则同一字段有两个来源就必然对不上。
|
||||
expect(harness.runs[0].rule.replyDelaySeconds).toBe(2)
|
||||
})
|
||||
|
||||
it('handleMessage 内部异常不会向外抛', async () => {
|
||||
const harness = buildHarness()
|
||||
const brokenStore = {
|
||||
@@ -385,3 +418,249 @@ describe('AutomationService', () => {
|
||||
void harness
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Automation rule conversation gate —— 规则 × 会话维度的阻塞窗口。
|
||||
*
|
||||
* ```
|
||||
* blocked = inFlight || now < triggeredAt + cooldown // 粒度:ruleId + conversationId
|
||||
* ```
|
||||
*
|
||||
* 窗口内的消息:不匹配、不执行、不回复、**不写用户执行日志**,只进诊断计数。
|
||||
* `triggeredAt` 是**第一次触发**的时间;执行完成不重置它。
|
||||
*/
|
||||
describe('AutomationService · rule conversation gate', () => {
|
||||
const T0 = 1_700_000_000_000
|
||||
|
||||
// 这个 describe 在文件后段,**不会**继承上一个 describe 的 beforeEach;
|
||||
// 而规则是写盘的(同一个 userData root),不清理就会互相污染
|
||||
// (Test 6 建的第二条规则会漏给 Test 7/8)。
|
||||
beforeEach(() => {
|
||||
fs.removeSync(path.join(root, 'automation'))
|
||||
})
|
||||
|
||||
it('Test 1 · 0s 触发,2/20/59s 全忽略,61s 重新触发(executions = 2 而不是 5)', async () => {
|
||||
let now = T0
|
||||
const harness = buildHarness({ now: () => now })
|
||||
|
||||
await harness.service.handleMessage(message({ localId: '1' }))
|
||||
for (const offset of [2_000, 20_000, 59_000]) {
|
||||
now = T0 + offset
|
||||
await harness.service.handleMessage(message({ localId: `t${offset}` }))
|
||||
}
|
||||
now = T0 + 61_000
|
||||
await harness.service.handleMessage(message({ localId: 'e' }))
|
||||
|
||||
expect(harness.runs).toHaveLength(2)
|
||||
expect(harness.log.list()).toHaveLength(2)
|
||||
expect(harness.service.getBlockedMessageCount()).toBe(3)
|
||||
})
|
||||
|
||||
it('Test 2 · 短任务(8s 就完成)仍然阻塞到 cooldown 到期', async () => {
|
||||
let now = T0
|
||||
const harness = buildHarness({ now: () => now })
|
||||
|
||||
await harness.service.handleMessage(message({ localId: '1' })) // 0s 触发,立即完成
|
||||
now = T0 + 8_000
|
||||
now = T0 + 30_000 // 任务早完成了,但 cooldown 还没到
|
||||
await harness.service.handleMessage(message({ localId: '2' }))
|
||||
expect(harness.runs).toHaveLength(1)
|
||||
|
||||
now = T0 + 61_000
|
||||
await harness.service.handleMessage(message({ localId: '3' }))
|
||||
expect(harness.runs).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('Test 3 · 长任务(超过 cooldown)一直阻塞到执行结束:解锁 = max(完成, 触发+cooldown)', async () => {
|
||||
let now = T0
|
||||
let release!: () => void
|
||||
const held = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
let markStarted!: () => void
|
||||
const started = new Promise<void>((resolve) => {
|
||||
markStarted = resolve
|
||||
})
|
||||
const harness = buildHarness({
|
||||
now: () => now,
|
||||
holdExecution: () => held,
|
||||
onRunStart: markStarted
|
||||
})
|
||||
|
||||
const firstRun = harness.service.handleMessage(message({ localId: '1' }))
|
||||
await started
|
||||
|
||||
// 61s:cooldown 早就过期了,但第一条执行还在跑 ⇒ 仍然忽略
|
||||
now = T0 + 61_000
|
||||
await harness.service.handleMessage(message({ localId: '2' }))
|
||||
expect(harness.runs).toHaveLength(1)
|
||||
|
||||
// 75s:第一条终于结束
|
||||
now = T0 + 75_000
|
||||
release()
|
||||
await firstRun
|
||||
|
||||
// 76s:这才重新允许 —— **不是**「完成后再等 60 秒」
|
||||
now = T0 + 76_000
|
||||
await harness.service.handleMessage(message({ localId: '3' }))
|
||||
expect(harness.runs).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('Test 4 · 同群不同发送者也忽略(gate 不是 sender 级)', async () => {
|
||||
let now = T0
|
||||
const harness = buildHarness({ now: () => now })
|
||||
|
||||
await harness.service.handleMessage(message({ localId: '1' }))
|
||||
now = T0 + 20_000
|
||||
await harness.service.handleMessage(
|
||||
message({ localId: '2', senderId: 'wxid_other', senderNickname: '李四' })
|
||||
)
|
||||
|
||||
expect(harness.runs).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('Test 5 · 同一条规则在另一个群正常执行(gate 是 ruleId + conversationId)', async () => {
|
||||
let now = T0
|
||||
const harness = buildHarness({ now: () => now })
|
||||
|
||||
await harness.service.handleMessage(message({ localId: '1' }))
|
||||
now = T0 + 10_000
|
||||
await harness.service.handleMessage(message({ localId: '2', sessionId: OTHER_GROUP }))
|
||||
|
||||
expect(harness.runs).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('Test 6 · 同一群里的另一条规则不受影响(不是会话全局锁)', async () => {
|
||||
let now = T0
|
||||
const harness = buildHarness({ now: () => now })
|
||||
const seeded = harness.store.listRules()[0]
|
||||
// 第二条规则同样命中这条消息,但**没有冷却** —— 用它证明 gate 按 ruleId 隔离。
|
||||
harness.store.createRule({
|
||||
name: '帮助',
|
||||
enabled: true,
|
||||
scope: 'group',
|
||||
conditions: {
|
||||
requireMentionMe: true,
|
||||
keyword: '日报',
|
||||
keywordMatchMode: 'contains',
|
||||
conversationIds: [],
|
||||
ignoreSelf: true
|
||||
},
|
||||
actions: [{ type: 'replyText', enabled: true, text: '好的' }],
|
||||
cooldownSeconds: 0,
|
||||
replyDelaySeconds: 0
|
||||
})
|
||||
|
||||
await harness.service.handleMessage(message({ localId: '1' }))
|
||||
expect(harness.runs).toHaveLength(2)
|
||||
|
||||
now = T0 + 10_000
|
||||
await harness.service.handleMessage(message({ localId: '2' }))
|
||||
// 规则 A 被 gate 挡下,规则 B 照常执行。
|
||||
expect(harness.runs).toHaveLength(3)
|
||||
expect(harness.runs[2].rule.id).not.toBe(seeded.id)
|
||||
})
|
||||
|
||||
it('Test 7 · 第一条失败后仍然走完整 cooldown(避免故障期疯狂重试)', async () => {
|
||||
let now = T0
|
||||
const harness = buildHarness({ now: () => now })
|
||||
harness.setRunResult({ steps: [], status: 'failed', errorSummary: 'AI 服务不可达' })
|
||||
|
||||
await harness.service.handleMessage(message({ localId: '1' }))
|
||||
expect(harness.runs).toHaveLength(1)
|
||||
|
||||
now = T0 + 5_000
|
||||
await harness.service.handleMessage(message({ localId: '2' }))
|
||||
expect(harness.runs).toHaveLength(1)
|
||||
|
||||
now = T0 + 61_000
|
||||
await harness.service.handleMessage(message({ localId: '3' }))
|
||||
expect(harness.runs).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('Test 8 · 60 秒内 10 条符合条件的消息,用户执行日志只留 1 条', async () => {
|
||||
let now = T0
|
||||
const harness = buildHarness({ now: () => now })
|
||||
|
||||
await harness.service.handleMessage(message({ localId: '1' }))
|
||||
for (let index = 2; index <= 11; index += 1) {
|
||||
now = T0 + index * 5_000 // 5s..55s,全部落在 60s 窗口内
|
||||
await harness.service.handleMessage(message({ localId: String(index) }))
|
||||
}
|
||||
|
||||
expect(harness.runs).toHaveLength(1)
|
||||
const records = harness.log.list()
|
||||
expect(records).toHaveLength(1)
|
||||
expect(records[0].status).toBe('success')
|
||||
// 不许出现「冷却中 / 剩余 N 秒」这类记录。
|
||||
expect(JSON.stringify(records)).not.toContain('冷却')
|
||||
expect(harness.service.getBlockedMessageCount()).toBe(10)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* gate 表清理的硬不变量:**正在执行(`inFlight === true`)的 gate 永不被淘汰**。
|
||||
*
|
||||
* 淘汰它 = 把一条正在跑的规则提前放开,立刻会产生第二次并发执行。
|
||||
* 这里用极小的上限(1)逼出清理路径。
|
||||
*/
|
||||
describe('AutomationService · gate 缓存清理', () => {
|
||||
const T0 = 1_700_000_000_000
|
||||
const GROUP_A = 'aaa@chatroom'
|
||||
const GROUP_B = 'bbb@chatroom'
|
||||
const GROUP_C = 'ccc@chatroom'
|
||||
|
||||
beforeEach(() => {
|
||||
fs.removeSync(path.join(root, 'automation'))
|
||||
})
|
||||
|
||||
it('清理只删「inFlight=false 且 cooldown 已过期」的条目,正在执行的一条必须留着', async () => {
|
||||
let now = T0
|
||||
let release!: () => void
|
||||
const held = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
let markStarted!: () => void
|
||||
const started = new Promise<void>((resolve) => {
|
||||
markStarted = resolve
|
||||
})
|
||||
// 只把**第一次**执行挂住(也就是 A 群那条);B/C 的执行立即完成。
|
||||
let runCount = 0
|
||||
const harness = buildHarness({
|
||||
now: () => now,
|
||||
gateMaxEntries: 1,
|
||||
holdExecution: () => {
|
||||
runCount += 1
|
||||
return runCount === 1 ? held : Promise.resolve()
|
||||
},
|
||||
onRunStart: markStarted
|
||||
})
|
||||
// cooldown = 0:这样「执行完成」的 gate 立刻就算「已解锁」,可以被清理。
|
||||
const seeded = harness.store.listRules()[0]
|
||||
harness.store.updateRule(seeded.id, { ...seeded, cooldownSeconds: 0 })
|
||||
|
||||
// A 群:开始执行并**挂住**(gate A 处于 inFlight)
|
||||
const runningA = harness.service.handleMessage(
|
||||
message({ localId: 'a1', sessionId: GROUP_A })
|
||||
)
|
||||
await started
|
||||
expect(harness.runs).toHaveLength(1)
|
||||
|
||||
// B 群:执行一次并正常结束(gate B 变成「已解锁」,是本次清理的目标)
|
||||
now = T0 + 1_000
|
||||
await harness.service.handleMessage(message({ localId: 'b1', sessionId: GROUP_B }))
|
||||
expect(harness.runs).toHaveLength(2)
|
||||
|
||||
// C 群:claim 一条新 gate ⇒ 表 size 超过上限 ⇒ 触发 evictGatesIfNeeded
|
||||
now = T0 + 2_000
|
||||
await harness.service.handleMessage(message({ localId: 'c1', sessionId: GROUP_C }))
|
||||
|
||||
// 关键不变量:A 的执行还在跑,往 A 发消息必须**仍然被挡住**。
|
||||
now = T0 + 3_000
|
||||
await harness.service.handleMessage(message({ localId: 'a2', sessionId: GROUP_A }))
|
||||
expect(harness.runs.filter((run) => run.conversationId === GROUP_A)).toHaveLength(1)
|
||||
|
||||
release()
|
||||
await runningA
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
extractMentionTargets,
|
||||
type NormalizedIncomingMessage
|
||||
} from '../../src/main/services/message-listener-service'
|
||||
import { parseMonitorEvent } from '../../src/main/wcdb4-client'
|
||||
import type { Wcdb4Client, Wcdb4Message } from '../../src/main/wcdb4-client'
|
||||
|
||||
/**
|
||||
@@ -344,3 +345,166 @@ describe('Observation Mode 约束', () => {
|
||||
expect(received).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Native Monitor Event v2:pipe payload 里带上**发生变化的会话**。
|
||||
*
|
||||
* 这一组锁的是「不再依赖 `getSessions()[0]`」这件事,以及两个必须保留的行为:
|
||||
* legacy(v1 runtime)fallback 和同消息 dedup。
|
||||
*/
|
||||
describe('parseMonitorEvent(协议解析)', () => {
|
||||
it('v2 + message_change + session_id ⇒ precise', () => {
|
||||
const event = parseMonitorEvent(
|
||||
JSON.stringify({
|
||||
version: 2,
|
||||
kind: 'message_change',
|
||||
session_id: 'group_A@chatroom',
|
||||
db: 'message_0.db',
|
||||
table: 'message',
|
||||
action: 'update',
|
||||
observed_at_ms: 1_700_000_000_000
|
||||
})
|
||||
)
|
||||
expect(event.protocol).toBe(2)
|
||||
expect(event.sessionId).toBe('group_A@chatroom')
|
||||
expect(event.table).toBe('message')
|
||||
expect(event.observedAtMs).toBe(1_700_000_000_000)
|
||||
})
|
||||
|
||||
it('v1 payload 仍然解析成 legacy(不带会话)', () => {
|
||||
const event = parseMonitorEvent(
|
||||
JSON.stringify({ db: 'session.db', table: 'Session', action: 'update' })
|
||||
)
|
||||
expect(event.protocol).toBe(1)
|
||||
expect(event.sessionId).toBeUndefined()
|
||||
expect(event.action).toBe('update')
|
||||
expect(event.raw).toContain('session.db')
|
||||
})
|
||||
|
||||
it('v2 但缺 session_id / kind 不对 ⇒ 一律降级成 legacy,绝不猜会话', () => {
|
||||
for (const payload of [
|
||||
{ version: 2, kind: 'message_change' },
|
||||
{ version: 2, kind: 'message_change', session_id: ' ' },
|
||||
{ version: 2, session_id: 'group_A@chatroom' },
|
||||
{ version: 1, kind: 'message_change', session_id: 'group_A@chatroom' }
|
||||
]) {
|
||||
expect(parseMonitorEvent(JSON.stringify(payload)).protocol).toBe(1)
|
||||
expect(parseMonitorEvent(JSON.stringify(payload)).sessionId).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('不是 JSON 也不崩', () => {
|
||||
const event = parseMonitorEvent('not-json')
|
||||
expect(event.protocol).toBe(1)
|
||||
expect(event.action).toBe('update')
|
||||
})
|
||||
})
|
||||
|
||||
describe('MessageListener · precise session path', () => {
|
||||
const v2Event = (sessionId: string) =>
|
||||
parseMonitorEvent(
|
||||
JSON.stringify({ version: 2, kind: 'message_change', session_id: sessionId, table: 'message' })
|
||||
)
|
||||
const v1Event = () =>
|
||||
parseMonitorEvent(JSON.stringify({ db: 'session.db', table: 'Session', action: 'update' }))
|
||||
|
||||
/** 按会话返回消息,并记录每个会话被回读了几次。 */
|
||||
function multiSessionClient(bySession: Record<string, Wcdb4Message[]>): {
|
||||
client: Wcdb4Client
|
||||
calls: string[]
|
||||
} {
|
||||
const calls: string[] = []
|
||||
const client = {
|
||||
// legacy fallback 用的「最近活跃会话」,故意与 precise 的会话不同。
|
||||
getSessions: vi.fn(() => [{ username: 'recent@chatroom' }]),
|
||||
getMessagesAsync: vi.fn(async (sessionId: string) => {
|
||||
calls.push(sessionId)
|
||||
return bySession[sessionId] ?? []
|
||||
})
|
||||
} as unknown as Wcdb4Client
|
||||
return { client, calls }
|
||||
}
|
||||
|
||||
it('v2 事件直接按 sessionId 回读,不再碰 getSessions()[0]', async () => {
|
||||
const { client, calls } = multiSessionClient({
|
||||
'A@chatroom': [makeMessage({ mesLocalID: '1' })]
|
||||
})
|
||||
const listener = new MessageListenerService(client)
|
||||
collect(listener)
|
||||
|
||||
listener.handleNativeChange(v2Event('A@chatroom'))
|
||||
await flush()
|
||||
|
||||
expect(calls).toEqual(['A@chatroom'])
|
||||
expect(received.map((message) => message.sessionId)).toEqual(['A@chatroom'])
|
||||
})
|
||||
|
||||
it('120ms 内 A/B/A/C 会回读 A、B、C 各一次(不是「最后一个 wins」)', async () => {
|
||||
const { client, calls } = multiSessionClient({
|
||||
'A@chatroom': [makeMessage({ mesLocalID: '1' })],
|
||||
'B@chatroom': [makeMessage({ mesLocalID: '2' })],
|
||||
'C@chatroom': [makeMessage({ mesLocalID: '3' })]
|
||||
})
|
||||
const listener = new MessageListenerService(client)
|
||||
collect(listener)
|
||||
|
||||
listener.handleNativeChange(v2Event('A@chatroom'))
|
||||
listener.handleNativeChange(v2Event('B@chatroom'))
|
||||
listener.handleNativeChange(v2Event('A@chatroom'))
|
||||
listener.handleNativeChange(v2Event('C@chatroom'))
|
||||
await flush()
|
||||
|
||||
expect(calls.sort()).toEqual(['A@chatroom', 'B@chatroom', 'C@chatroom'])
|
||||
expect(received.map((message) => message.sessionId).sort()).toEqual([
|
||||
'A@chatroom',
|
||||
'B@chatroom',
|
||||
'C@chatroom'
|
||||
])
|
||||
})
|
||||
|
||||
it('一批里既有 v2 又有 v1 时,仍然按 v2 的精确会话回读', async () => {
|
||||
const { client, calls } = multiSessionClient({
|
||||
'A@chatroom': [makeMessage({ mesLocalID: '1' })],
|
||||
'recent@chatroom': [makeMessage({ mesLocalID: '9' })]
|
||||
})
|
||||
const listener = new MessageListenerService(client)
|
||||
collect(listener)
|
||||
|
||||
listener.handleNativeChange(v2Event('A@chatroom'))
|
||||
listener.handleNativeChange(v1Event())
|
||||
await flush()
|
||||
|
||||
expect(calls).toEqual(['A@chatroom'])
|
||||
})
|
||||
|
||||
it('legacy(v1 runtime)仍然退回最近活跃会话,行为不变', async () => {
|
||||
const { client, calls } = multiSessionClient({
|
||||
'recent@chatroom': [makeMessage({ mesLocalID: '1' })]
|
||||
})
|
||||
const listener = new MessageListenerService(client)
|
||||
collect(listener)
|
||||
|
||||
listener.handleNativeChange(v1Event())
|
||||
await flush()
|
||||
|
||||
expect(calls).toEqual(['recent@chatroom'])
|
||||
expect(received).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('native 重复发同一条消息的 v2 事件,只会投递一次', async () => {
|
||||
const { client, calls } = multiSessionClient({
|
||||
'A@chatroom': [makeMessage({ mesLocalID: '42' })]
|
||||
})
|
||||
const listener = new MessageListenerService(client)
|
||||
collect(listener)
|
||||
|
||||
listener.handleNativeChange(v2Event('A@chatroom'))
|
||||
await flush()
|
||||
listener.handleNativeChange(v2Event('A@chatroom'))
|
||||
await flush()
|
||||
|
||||
// 回读了两次(两次事件各一个窗口),但同一条消息只投递一次。
|
||||
expect(calls).toEqual(['A@chatroom', 'A@chatroom'])
|
||||
expect(received).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -18,10 +18,11 @@ vi.mock('../../src/main/services/personal-wechat-send-service', () => ({
|
||||
|
||||
import {
|
||||
AUTOMATION_SEND_INTERVAL_MS,
|
||||
WechatActionGateway
|
||||
WechatActionGateway,
|
||||
toPersonalWechatSendResult
|
||||
} from '../../src/main/services/wechat-action-gateway'
|
||||
import type { PersonalWechatSendCapability } from '../../src/shared/personal-wechat'
|
||||
import type { WechatActionResult } from '../../src/shared/wechat-action'
|
||||
import type { WechatActionRequest, WechatActionResult } from '../../src/shared/wechat-action'
|
||||
|
||||
const readyCapability: PersonalWechatSendCapability = {
|
||||
supported: true,
|
||||
@@ -375,4 +376,132 @@ describe('WechatActionGateway', () => {
|
||||
expect(Date.now()).toBe(startedAt)
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
/**
|
||||
* 项目规则:**所有发送都走 WechatActionGateway**。
|
||||
*
|
||||
* 手动发送(`wechat-personal:send`)改造前是直连 `PersonalWechatSendService` 的,
|
||||
* 于是没有审计、没有幂等、也不进 Send Log。这一组锁住改造后的语义。
|
||||
*/
|
||||
describe('用户手动发送(triggerType: user)', () => {
|
||||
function manualAction(
|
||||
gateway: WechatActionGateway,
|
||||
overrides: Partial<WechatActionRequest> = {}
|
||||
): Promise<WechatActionResult> {
|
||||
return gateway.execute({
|
||||
origin: 'user_manual',
|
||||
purpose: 'manual_image',
|
||||
triggerType: 'user',
|
||||
recipient: { type: 'group', id: 'room@chatroom' },
|
||||
content: { type: 'image', path: '/tmp/report.png' },
|
||||
...overrides
|
||||
} as WechatActionRequest)
|
||||
}
|
||||
|
||||
it('放行、发送一次,并写入审计记录', async () => {
|
||||
const userData = mkdtempSync(join(tmpdir(), 'tracememo-wechat-action-'))
|
||||
directories.push(userData)
|
||||
const gateway = new WechatActionGateway({ getUserDataPath: () => userData })
|
||||
|
||||
const result = await manualAction(gateway)
|
||||
|
||||
expect(result).toMatchObject({ status: 'sent', decision: 'allow' })
|
||||
expect(mocks.sender.send).toHaveBeenCalledWith({
|
||||
type: 'image',
|
||||
to: 'room@chatroom',
|
||||
isGroup: true,
|
||||
filePath: '/tmp/report.png'
|
||||
})
|
||||
const audit = readJsonSync(join(userData, 'actions', 'wechat-actions.json'))
|
||||
expect(audit).toEqual([
|
||||
expect.objectContaining({
|
||||
origin: 'user_manual',
|
||||
purpose: 'manual_image',
|
||||
triggerType: 'user',
|
||||
recipientId: 'room@chatroom',
|
||||
decision: 'allow',
|
||||
sendStatus: 'sent'
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('不受 automation 的 purpose allowlist 限制,也不吃 3s 节流', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-09-21T01:00:00.000Z'))
|
||||
const gateway = createGateway()
|
||||
|
||||
const first = await manualAction(gateway)
|
||||
const second = await manualAction(gateway)
|
||||
|
||||
expect(first.status).toBe('sent')
|
||||
expect(second.status).toBe('sent')
|
||||
// 节流只对 triggerType === 'automation' 生效:两次点击必须立刻都发出去。
|
||||
expect(mocks.sender.send).toHaveBeenCalledTimes(2)
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('空文本由规范化拦下,不落到发送层', async () => {
|
||||
const gateway = createGateway()
|
||||
const result = await manualAction(gateway, {
|
||||
purpose: 'manual_text',
|
||||
content: { type: 'text', text: ' ' }
|
||||
})
|
||||
|
||||
expect(result.status).toBe('blocked')
|
||||
expect(result.errorCode).toBe('INVALID_REQUEST')
|
||||
expect(mocks.sender.send).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('toPersonalWechatSendResult', () => {
|
||||
const fallback = { canSend: true } as unknown as Parameters<
|
||||
typeof toPersonalWechatSendResult
|
||||
>[1]
|
||||
|
||||
it('优先返回底层 sendResult(它带着真实的 status)', () => {
|
||||
const underlying = { success: true, status: { canSend: true, marker: 'real' } }
|
||||
expect(
|
||||
toPersonalWechatSendResult(
|
||||
{
|
||||
actionId: 'a1',
|
||||
status: 'sent',
|
||||
decision: 'allow',
|
||||
startedAt: '2026-09-21T01:00:00.000Z',
|
||||
finishedAt: '2026-09-21T01:00:01.000Z',
|
||||
sendResult: underlying
|
||||
},
|
||||
fallback
|
||||
)
|
||||
).toBe(underlying)
|
||||
})
|
||||
|
||||
it('拿不到 sendResult 时按 action.status 合成', () => {
|
||||
const sent = toPersonalWechatSendResult(
|
||||
{
|
||||
actionId: 'a1',
|
||||
status: 'sent',
|
||||
decision: 'allow',
|
||||
startedAt: '2026-09-21T01:00:00.000Z',
|
||||
finishedAt: '2026-09-21T01:00:01.000Z'
|
||||
},
|
||||
fallback
|
||||
)
|
||||
expect(sent).toEqual({ success: true, status: fallback })
|
||||
|
||||
const blocked = toPersonalWechatSendResult(
|
||||
{
|
||||
actionId: 'a2',
|
||||
status: 'blocked',
|
||||
decision: 'block',
|
||||
errorCode: 'ACTION_NOT_ALLOWED',
|
||||
reason: '自动化动作不允许执行',
|
||||
startedAt: '2026-09-21T01:00:00.000Z',
|
||||
finishedAt: '2026-09-21T01:00:01.000Z'
|
||||
},
|
||||
fallback
|
||||
)
|
||||
expect(blocked.success).toBe(false)
|
||||
expect(blocked.error).toBe('自动化动作不允许执行')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user