mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-08 15:43:22 +08:00
feat: 自动化
This commit is contained in:
@@ -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
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user