feat: 自动化

This commit is contained in:
Wxw-Gu
2026-09-21 00:35:32 +08:00
parent 766cd94788
commit 95384a4a92
32 changed files with 6420 additions and 1 deletions
+307
View File
@@ -0,0 +1,307 @@
import { describe, expect, it, vi } from 'vitest'
/**
* runner 本身不碰 electron,但它的模块图会加载 `agent-group-report-service`,
* 那条链上的 `report-template-service` / `settings-store` 在**模块加载时**就会调
* `app.getPath()`。所以这里必须给 electron 一个替身,否则 import 阶段就炸。
*
* 用一个固定字符串即可 —— 这些服务只把它当路径前缀拼,不会真的读写。
*/
vi.mock('electron', () => ({
app: { getPath: () => '/tmp/wxe-automation-runner-fixture' },
safeStorage: {
isEncryptionAvailable: () => false,
encryptString: (value: string) => Buffer.from(value, 'utf8'),
decryptString: (value: Buffer) => value.toString('utf8')
},
BrowserWindow: { getAllWindows: () => [] }
}))
import {
AUTOMATION_SEND_PURPOSE,
createDefaultDailyReportRule,
type AutomationRule,
type AutomationStepKey
} from '../../src/shared/automation'
import type { WechatActionRequest, WechatActionResult } from '../../src/shared/wechat-action'
import { AutomationActionRunner } from '../../src/main/services/automation-action-runner'
import type { AgentGroupReportResult } from '../../src/main/services/agent-group-report-service'
/**
* ActionRunner 的步骤语义。
*
* 这一层最重要的性质只有两条:
* 1. 顺序是 reply → report → send;
* 2. **失败必须切断后续** —— 日报没出来就绝不能继续发图。
*/
const EXECUTION_ID = 'exec-fixture-1'
const GROUP_ID = '12345678@chatroom'
function makeRule(mutate?: (rule: AutomationRule) => void): AutomationRule {
const rule = createDefaultDailyReportRule(0)
mutate?.(rule)
return rule
}
/** 单调递增的假时钟,让 durationMs 可预测。 */
function fakeClock(step = 5): () => number {
let value = 1_700_000_000_000
return () => {
value += step
return value
}
}
function sentResult(): WechatActionResult {
return {
actionId: 'action-1',
status: 'sent',
decision: 'allow',
startedAt: '2026-09-20T10:00:00.000Z',
finishedAt: '2026-09-20T10:00:01.000Z'
}
}
function failedResult(code: string, reason: string): WechatActionResult {
return {
actionId: 'action-1',
status: 'failed',
decision: 'allow',
errorCode: code,
reason,
startedAt: '2026-09-20T10:00:00.000Z',
finishedAt: '2026-09-20T10:00:01.000Z'
}
}
function buildRunner(options: {
executeAction?: (request: WechatActionRequest) => Promise<WechatActionResult>
generateReport?: () => Promise<AgentGroupReportResult>
}): {
runner: AutomationActionRunner
calls: WechatActionRequest[]
reports: Array<{ group: string; range?: string }>
} {
const calls: WechatActionRequest[] = []
const reports: Array<{ group: string; range?: string }> = []
const runner = new AutomationActionRunner({
now: fakeClock(),
executeAction: async (request) => {
calls.push(request)
return options.executeAction ? options.executeAction(request) : sentResult()
},
generateReport: async (request) => {
reports.push({ group: request.group, ...(request.range ? { range: request.range } : {}) })
return options.generateReport ? options.generateReport() : { success: true, pngPath: '/tmp/report.png' }
}
})
return { runner, calls, reports }
}
const run = (
runner: AutomationActionRunner,
rule: AutomationRule
): Promise<Awaited<ReturnType<AutomationActionRunner['run']>>> =>
runner.run({
executionId: EXECUTION_ID,
rule,
conversationId: GROUP_ID,
isGroup: true,
sourceDisplayName: '测试群'
})
const statusOf = (
result: Awaited<ReturnType<AutomationActionRunner['run']>>,
key: AutomationStepKey
): string => result.steps.find((step) => step.key === key)?.status ?? 'missing'
describe('AutomationActionRunner', () => {
it('按 收到消息 → 规则匹配 → 回复确认 → 生成日报 → 发送图片 的顺序执行', async () => {
const { runner, calls, reports } = buildRunner({})
const result = await run(runner, makeRule())
expect(result.status).toBe('success')
expect(result.pngPath).toBe('/tmp/report.png')
expect(result.steps.map((step) => step.key)).toEqual([
'received',
'matched',
'reply',
'report',
'send'
])
expect(result.steps.map((step) => step.status)).toEqual([
'success',
'success',
'success',
'success',
'success'
])
// 先回文字,再发图片 —— 顺序反了用户会先收到图再收到"正在生成"。
expect(calls).toHaveLength(2)
expect(calls[0].content.type).toBe('text')
expect(calls[1].content.type).toBe('image')
expect(reports).toEqual([{ group: GROUP_ID, range: 'today' }])
})
it('每一步都带独立的起止时间与耗时', async () => {
const { runner } = buildRunner({})
const result = await run(runner, makeRule())
for (const step of result.steps) {
expect(step.startedAt).toBeTypeOf('number')
expect(step.finishedAt).toBeTypeOf('number')
expect(step.durationMs).toBeGreaterThanOrEqual(0)
expect(step.finishedAt! >= step.startedAt!).toBe(true)
}
// matched 比 received 晚开始,说明确实是顺序执行而非一次性补写。
expect(result.steps[1].startedAt!).toBeGreaterThan(result.steps[0].startedAt!)
})
it('发送请求带上正确的 purpose / 幂等键 / 收件人', async () => {
const { runner, calls } = buildRunner({})
await run(runner, makeRule())
expect(calls[0].purpose).toBe(AUTOMATION_SEND_PURPOSE.reply)
expect(calls[0].idempotencyKey).toBe(`${AUTOMATION_SEND_PURPOSE.reply}:${EXECUTION_ID}`)
expect(calls[1].purpose).toBe(AUTOMATION_SEND_PURPOSE.report)
expect(calls[1].idempotencyKey).toBe(`${AUTOMATION_SEND_PURPOSE.report}:${EXECUTION_ID}`)
for (const call of calls) {
expect(call.origin).toBe('automation')
expect(call.triggerType).toBe('automation')
expect(call.recipient).toMatchObject({ type: 'group', id: GROUP_ID, name: '测试群' })
}
})
it('生成日报失败时,发送图片被跳过而不是继续乱发', async () => {
const { runner, calls } = buildRunner({
generateReport: async () => ({ success: false, error: '所选时间范围没有可总结的消息' })
})
const result = await run(runner, makeRule())
expect(result.status).toBe('failed')
expect(statusOf(result, 'reply')).toBe('success')
expect(statusOf(result, 'report')).toBe('failed')
expect(statusOf(result, 'send')).toBe('skipped')
expect(result.errorSummary).toBe('所选时间范围没有可总结的消息')
// 只发过那条文字回复,图片一次都没发。
expect(calls).toHaveLength(1)
expect(calls[0].content.type).toBe('text')
})
it('生成日报抛异常时同样记 failed 并跳过发送', async () => {
const { runner } = buildRunner({
generateReport: async () => {
throw new Error('AI 服务不可达')
}
})
const result = await run(runner, makeRule())
expect(statusOf(result, 'report')).toBe('failed')
expect(statusOf(result, 'send')).toBe('skipped')
expect(result.steps.find((step) => step.key === 'report')?.error).toBe('AI 服务不可达')
})
it('发送图片失败时整次执行判为失败并保留原因', async () => {
let callIndex = 0
const { runner } = buildRunner({
executeAction: async () => {
callIndex += 1
return callIndex === 1
? sentResult()
: failedResult('SEND_NOT_READY', '')
}
})
const result = await run(runner, makeRule())
expect(result.status).toBe('failed')
expect(statusOf(result, 'send')).toBe('failed')
expect(result.errorSummary).toBe('微信发送能力尚未就绪,请先绑定个人微信')
})
it('回复确认失败时,生成日报与发送都被跳过', async () => {
const { runner, calls, reports } = buildRunner({
executeAction: async () => failedResult('SEND_CAPABILITY_UNAVAILABLE', '当前环境没有可用的微信发送能力')
})
const result = await run(runner, makeRule())
expect(statusOf(result, 'reply')).toBe('failed')
expect(statusOf(result, 'report')).toBe('skipped')
expect(statusOf(result, 'send')).toBe('skipped')
expect(calls).toHaveLength(1)
expect(reports).toHaveLength(0)
})
it('未启用回复确认时该步 skipped,但不阻断后面的日报与发送', async () => {
const { runner, calls } = buildRunner({})
const rule = makeRule((item) => {
item.actions = item.actions.filter((action) => action.type !== 'replyText')
})
const result = await run(runner, rule)
expect(result.status).toBe('success')
expect(statusOf(result, 'reply')).toBe('skipped')
expect(statusOf(result, 'report')).toBe('success')
expect(statusOf(result, 'send')).toBe('success')
expect(calls).toHaveLength(1)
expect(calls[0].content.type).toBe('image')
})
it('未启用生成日报时,发送图片也必须 skipped(不能凭空发图)', async () => {
const { runner, calls, reports } = buildRunner({})
const rule = makeRule((item) => {
item.actions = item.actions.filter((action) => action.type !== 'generateReport')
})
const result = await run(runner, rule)
expect(statusOf(result, 'report')).toBe('skipped')
expect(statusOf(result, 'send')).toBe('skipped')
expect(reports).toHaveLength(0)
expect(calls).toHaveLength(1)
expect(calls[0].content.type).toBe('text')
})
it('只生成不发送时,日报步骤仍记成功', async () => {
const { runner, reports } = buildRunner({})
const rule = makeRule((item) => {
item.actions = item.actions.filter((action) => action.type !== 'sendReportImage')
})
const result = await run(runner, rule)
expect(result.status).toBe('success')
expect(statusOf(result, 'report')).toBe('success')
expect(statusOf(result, 'send')).toBe('skipped')
expect(reports).toHaveLength(1)
})
it('回复文案为空时回落到内置默认文案', async () => {
const { runner, calls } = buildRunner({})
const rule = makeRule((item) => {
item.actions = item.actions.map((action) =>
action.type === 'replyText' ? { ...action, text: ' ' } : action
)
})
await run(runner, rule)
expect(calls[0].content).toEqual({ type: 'text', text: '收到,正在生成今日日报' })
})
it('私聊场景下收件人类型是联系人', async () => {
const executeAction = vi.fn(async (_request: WechatActionRequest) => sentResult())
const runner = new AutomationActionRunner({
now: fakeClock(),
executeAction,
generateReport: async () => ({ success: true, pngPath: '/tmp/report.png' })
})
await runner.run({
executionId: EXECUTION_ID,
rule: makeRule((item) => {
item.scope = 'direct'
}),
conversationId: 'wxid_friend',
isGroup: false,
sourceDisplayName: '李四'
})
expect(executeAction.mock.calls[0][0].recipient).toMatchObject({
type: 'contact',
id: 'wxid_friend'
})
})
})