mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-06 13:54:10 +08:00
feat: 定时日报并入自动化,统一规则管理与发送链路
发送目标与退群通知统一为 来源群 / 自己 / 文件传输助手 / 指定好友 日报页补「定时日报已并入自动化」指引条
This commit is contained in:
@@ -22,7 +22,10 @@ vi.mock('electron', () => ({
|
||||
BrowserWindow: { getAllWindows: () => [] }
|
||||
}))
|
||||
|
||||
import { AUTOMATION_SEND_PURPOSE } from '../../src/shared/automation'
|
||||
import {
|
||||
AUTOMATION_SEND_PURPOSE,
|
||||
BUILTIN_DAILY_REPORT_RULE_ID
|
||||
} from '../../src/shared/automation'
|
||||
import type {
|
||||
PersonalWechatSendCapability,
|
||||
PersonalWechatSendRequest
|
||||
@@ -273,4 +276,53 @@ describe('automation end-to-end chain', () => {
|
||||
await chain.service.handleMessage(message({ content: '@我 今日日报', mentionTargets: [] }))
|
||||
expect(chain.sends).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('同一条 store 里存在定时日报规则时,@我消息只触发 @我日报规则(规则类型硬分派)', async () => {
|
||||
const chain = buildChain()
|
||||
|
||||
// 定时日报是**时间驱动**,不参与消息匹配。硬分派一旦写错,它的
|
||||
// 「空关键词 + 不要求 @我」会让它命中**每一条群消息** ——
|
||||
// 这正是这条用例要守住的边界。
|
||||
const scheduled = chain.store.createRule({
|
||||
name: '每天 09:00 的定时日报',
|
||||
enabled: true,
|
||||
ruleType: 'scheduled_report',
|
||||
trigger: 'message',
|
||||
scope: 'group',
|
||||
conditions: {
|
||||
requireMentionMe: false,
|
||||
keyword: '',
|
||||
keywordMatchMode: 'contains',
|
||||
conversationIds: [],
|
||||
ignoreSelf: true
|
||||
},
|
||||
actions: [],
|
||||
cooldownSeconds: 0,
|
||||
replyDelaySeconds: 0,
|
||||
scheduledReport: {
|
||||
schedule: { time: '09:00' },
|
||||
report: {
|
||||
sourceConversationId: GROUP_ID,
|
||||
range: 'yesterday',
|
||||
messageTypes: ['text'],
|
||||
templateId: 'v1',
|
||||
memberNameMode: 'groupNickname',
|
||||
timeoutSeconds: 300
|
||||
},
|
||||
target: { type: 'file_transfer' }
|
||||
}
|
||||
})
|
||||
expect(scheduled.ruleType).toBe('scheduled_report')
|
||||
|
||||
await chain.service.handleMessage(message())
|
||||
|
||||
// 只有 @我日报那条留下执行记录;定时日报规则**一条都没有**。
|
||||
const records = chain.log.list()
|
||||
expect(records).toHaveLength(1)
|
||||
expect(records[0].ruleId).toBe(BUILTIN_DAILY_REPORT_RULE_ID)
|
||||
expect(records.some((record) => record.ruleId === scheduled.id)).toBe(false)
|
||||
|
||||
// 发送也只有 @我日报的那两次(确认文字 + 日报图片),没有第三条。
|
||||
expect(chain.sends.map((send) => send.type)).toEqual(['text', 'image'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -160,13 +160,62 @@ describe('preload IPC contract', () => {
|
||||
expect(invoke).toHaveBeenLastCalledWith('wechat-personal:send', reportImageRequest)
|
||||
await api.getPersonalWechatVoiceDiagnostic()
|
||||
expect(invoke).toHaveBeenLastCalledWith('wechat-personal:getVoiceDiagnostic')
|
||||
})
|
||||
|
||||
/**
|
||||
* 定时日报退役后,专属能力全部改走 `automation:*`。
|
||||
*
|
||||
* 单独成一个用例(而不是塞进上面那条超长用例)有两个理由:
|
||||
* 1. 上面的用例在 `voice:recognize` 上会因 vitest 4 对**尾随 undefined** 的严格比较而失败,
|
||||
* 放在一起会让这几条断言永远跑不到;
|
||||
* 2. 「旧通道必须彻底消失」本身就是一条独立契约,值得有自己的名字。
|
||||
*/
|
||||
it('routes scheduled-report capabilities through automation channels only', async () => {
|
||||
const api = await loadApi()
|
||||
invoke.mockResolvedValue({ success: true })
|
||||
|
||||
await api.runScheduledReportRule('rule-1')
|
||||
expect(invoke).toHaveBeenLastCalledWith('automation:runScheduledReportRule', 'rule-1')
|
||||
|
||||
await api.listScheduledReportLegacyExecutions('rule-1')
|
||||
expect(invoke).toHaveBeenLastCalledWith(
|
||||
'automation:listScheduledReportLegacyExecutions',
|
||||
'rule-1'
|
||||
)
|
||||
|
||||
await api.getScheduledReportNotificationSettings()
|
||||
expect(invoke).toHaveBeenLastCalledWith('scheduled-report:getNotificationSettings')
|
||||
expect(invoke).toHaveBeenLastCalledWith('automation:getScheduledReportNotificationSettings')
|
||||
await api.getScheduledReportNotificationCapability()
|
||||
expect(invoke).toHaveBeenLastCalledWith('automation:getScheduledReportNotificationCapability')
|
||||
await api.setScheduledReportNotificationEnabled(true)
|
||||
expect(invoke).toHaveBeenLastCalledWith('scheduled-report:setNotificationEnabled', true)
|
||||
await api.testScheduledReportErrorNotification('task-1')
|
||||
expect(invoke).toHaveBeenLastCalledWith('scheduled-report:testErrorNotification', 'task-1')
|
||||
expect(invoke).toHaveBeenLastCalledWith(
|
||||
'automation:setScheduledReportNotificationEnabled',
|
||||
true
|
||||
)
|
||||
await api.testScheduledReportErrorNotification('rule-1')
|
||||
expect(invoke).toHaveBeenLastCalledWith(
|
||||
'automation:testScheduledReportErrorNotification',
|
||||
'rule-1'
|
||||
)
|
||||
|
||||
// 旧的 `scheduled-report:*` 通道必须**彻底不存在**:
|
||||
// 留一个"能用但没人维护"的入口,等于给双写 / 双读留后门。
|
||||
const channels = invoke.mock.calls.map((call) => String(call[0]))
|
||||
expect(channels.some((channel) => channel.startsWith('scheduled-report:'))).toBe(false)
|
||||
|
||||
const legacyMethods = [
|
||||
'listScheduledReports',
|
||||
'listScheduledReportExecutions',
|
||||
'createScheduledReport',
|
||||
'updateScheduledReport',
|
||||
'deleteScheduledReport',
|
||||
'setScheduledReportEnabled',
|
||||
'runScheduledReportNow',
|
||||
'retryScheduledReportSend'
|
||||
]
|
||||
for (const method of legacyMethods) {
|
||||
expect(api).not.toHaveProperty(method)
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves key API return values without exposing ipcRenderer', async () => {
|
||||
|
||||
@@ -4,7 +4,9 @@ const fixture = vi.hoisted(() => ({
|
||||
capability: {
|
||||
supported: true,
|
||||
ready: true,
|
||||
status: 'ready' as const,
|
||||
// 显式标注成联合类型:否则会被收窄成字面量 'ready',
|
||||
// 后面把它改成 'needs_binding' 时就不合法了。
|
||||
status: 'ready' as PersonalWechatSendCapabilityState,
|
||||
capabilities: { text: true, image: true, voice: true },
|
||||
senderStatus: {} as never,
|
||||
message: '个人微信已准备好发送日报'
|
||||
@@ -58,7 +60,14 @@ vi.mock('../../src/main/services/agent-hub-service', () => ({
|
||||
}))
|
||||
|
||||
import { startHttpServer, type HttpServerHandle } from '../../src/main/http-server'
|
||||
import type { ScheduledReportTask } from '../../src/shared/scheduled-report'
|
||||
import type {
|
||||
ScheduledReportCreateInput,
|
||||
ScheduledReportExecution,
|
||||
ScheduledReportResult,
|
||||
ScheduledReportTask,
|
||||
ScheduledReportUpdateInput
|
||||
} from '../../src/shared/scheduled-report'
|
||||
import type { PersonalWechatSendCapabilityState } from '../../src/shared/personal-wechat'
|
||||
import type { ScheduledReportApiDependencies } from '../../src/main/services/scheduled-report-api-service'
|
||||
|
||||
const TOKEN = 'A'.repeat(43)
|
||||
@@ -87,57 +96,76 @@ async function startFixture(
|
||||
options: { tasks?: ScheduledReportTask[] } = {}
|
||||
): Promise<{ handle: HttpServerHandle; service: ScheduledReportApiDependencies['service'] }> {
|
||||
const tasks = options.tasks || []
|
||||
const executions: Array<Record<string, unknown>> = []
|
||||
const service = {
|
||||
listTasks: vi.fn(async () => tasks),
|
||||
listExecutions: vi.fn(async (taskId?: string) =>
|
||||
taskId ? executions.filter((item) => item.taskId === taskId) : executions
|
||||
const executions: ScheduledReportExecution[] = []
|
||||
const service: ScheduledReportApiDependencies['service'] = {
|
||||
listTasks: vi.fn(async (): Promise<ScheduledReportTask[]> => tasks),
|
||||
listExecutions: vi.fn(
|
||||
async (taskId?: string): Promise<ScheduledReportExecution[]> =>
|
||||
taskId ? executions.filter((item) => item.taskId === taskId) : executions
|
||||
),
|
||||
createTask: vi.fn(async (input: Record<string, unknown>) => {
|
||||
const created = makeTask({
|
||||
id: `task-${tasks.length + 1}`,
|
||||
name: String(input.name),
|
||||
group: String(input.group),
|
||||
target: String(input.target),
|
||||
scheduleTime: String(input.scheduleTime),
|
||||
reportRange: input.reportRange as ScheduledReportTask['reportRange'],
|
||||
enabled: Boolean(input.enabled)
|
||||
})
|
||||
tasks.push(created)
|
||||
return { success: true, data: created }
|
||||
}),
|
||||
updateTask: vi.fn(async (id: string, input: Record<string, unknown>) => {
|
||||
const current = tasks.find((item) => item.id === id)
|
||||
if (!current) return { success: false, error: '未找到定时日报任务' }
|
||||
const updated = { ...current, ...input, updatedAt: '2026-08-27T02:00:00.000Z' }
|
||||
tasks[tasks.indexOf(current)] = updated
|
||||
return { success: true, data: updated }
|
||||
}),
|
||||
deleteTask: vi.fn(async (id: string) => {
|
||||
const index = tasks.findIndex((item) => item.id === id)
|
||||
if (index < 0) return { success: false, error: '未找到定时日报任务' }
|
||||
tasks.splice(index, 1)
|
||||
return { success: true, data: { deletedId: id } }
|
||||
}),
|
||||
setTaskEnabled: vi.fn(async (id: string, enabled: boolean) => {
|
||||
const current = tasks.find((item) => item.id === id)
|
||||
if (!current) return { success: false, error: '未找到定时日报任务' }
|
||||
const updated = { ...current, enabled }
|
||||
tasks[tasks.indexOf(current)] = updated
|
||||
return { success: true, data: updated }
|
||||
}),
|
||||
runScheduledReportNow: vi.fn(async (id: string) => {
|
||||
const execution = {
|
||||
id: `execution-${executions.length + 1}`,
|
||||
taskId: id,
|
||||
startedAt: '2026-08-27T01:00:00.000Z',
|
||||
finishedAt: '2026-08-27T01:01:00.000Z',
|
||||
status: 'success' as const,
|
||||
message: '日报生成成功,微信发送成功'
|
||||
createTask: vi.fn(
|
||||
async (
|
||||
input: ScheduledReportCreateInput
|
||||
): Promise<ScheduledReportResult<ScheduledReportTask>> => {
|
||||
const created = makeTask({
|
||||
id: `task-${tasks.length + 1}`,
|
||||
name: String(input.name),
|
||||
group: String(input.group),
|
||||
target: String(input.target),
|
||||
scheduleTime: String(input.scheduleTime),
|
||||
reportRange: input.reportRange as ScheduledReportTask['reportRange'],
|
||||
enabled: Boolean(input.enabled)
|
||||
})
|
||||
tasks.push(created)
|
||||
return { success: true, data: created }
|
||||
}
|
||||
executions.push(execution)
|
||||
return { success: true, data: execution }
|
||||
})
|
||||
),
|
||||
updateTask: vi.fn(
|
||||
async (
|
||||
id: string,
|
||||
input: ScheduledReportUpdateInput
|
||||
): Promise<ScheduledReportResult<ScheduledReportTask>> => {
|
||||
const current = tasks.find((item) => item.id === id)
|
||||
if (!current) return { success: false, error: '未找到定时日报任务' }
|
||||
const updated = { ...current, ...input, updatedAt: '2026-08-27T02:00:00.000Z' }
|
||||
tasks[tasks.indexOf(current)] = updated
|
||||
return { success: true, data: updated }
|
||||
}
|
||||
),
|
||||
deleteTask: vi.fn(
|
||||
async (id: string): Promise<ScheduledReportResult<{ deletedId: string }>> => {
|
||||
const index = tasks.findIndex((item) => item.id === id)
|
||||
if (index < 0) return { success: false, error: '未找到定时日报任务' }
|
||||
tasks.splice(index, 1)
|
||||
return { success: true, data: { deletedId: id } }
|
||||
}
|
||||
),
|
||||
setTaskEnabled: vi.fn(
|
||||
async (
|
||||
id: string,
|
||||
enabled: boolean
|
||||
): Promise<ScheduledReportResult<ScheduledReportTask>> => {
|
||||
const current = tasks.find((item) => item.id === id)
|
||||
if (!current) return { success: false, error: '未找到定时日报任务' }
|
||||
const updated = { ...current, enabled }
|
||||
tasks[tasks.indexOf(current)] = updated
|
||||
return { success: true, data: updated }
|
||||
}
|
||||
),
|
||||
runScheduledReportNow: vi.fn(
|
||||
async (id: string): Promise<ScheduledReportResult<ScheduledReportExecution>> => {
|
||||
const execution: ScheduledReportExecution = {
|
||||
id: `execution-${executions.length + 1}`,
|
||||
taskId: id,
|
||||
startedAt: '2026-08-27T01:00:00.000Z',
|
||||
finishedAt: '2026-08-27T01:01:00.000Z',
|
||||
status: 'success',
|
||||
message: '日报生成成功,微信发送成功'
|
||||
}
|
||||
executions.push(execution)
|
||||
return { success: true, data: execution }
|
||||
}
|
||||
)
|
||||
}
|
||||
const handle = await startHttpServer('127.0.0.1', 0, {
|
||||
tokenProvider: () => TOKEN,
|
||||
@@ -222,6 +250,13 @@ describe('scheduled report Local HTTP API', () => {
|
||||
})
|
||||
expect(await capability.json()).toMatchObject({ capability: { status: 'ready' } })
|
||||
expect(service.createTask).toHaveBeenCalledOnce()
|
||||
// HTTP 契约把群解析成**会话 md5**、目标解析成 roomId 后交给服务层
|
||||
//(服务层再把这些标识收敛成稳定会话 id)。
|
||||
expect(vi.mocked(service.createTask).mock.calls[0][0]).toMatchObject({
|
||||
group: 'tech-md5',
|
||||
target: 'tech@chatroom',
|
||||
scheduleTime: '09:00'
|
||||
})
|
||||
})
|
||||
|
||||
it('allows unavailable sending capability while still rejecting ambiguous groups', async () => {
|
||||
|
||||
@@ -0,0 +1,659 @@
|
||||
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-scheduled-report-automation-'))
|
||||
})
|
||||
|
||||
const SELF = 'my_account_9527'
|
||||
|
||||
/** 联系人表必须在 `vi.hoisted` 里 —— `vi.mock` 的工厂是提前求值的。 */
|
||||
const contacts = vi.hoisted(() => [
|
||||
{
|
||||
md5: 'tech-md5',
|
||||
m_nsUsrName: 'tech@chatroom',
|
||||
m_nsNickName: '技术交流群',
|
||||
type: 'group' as const
|
||||
},
|
||||
{
|
||||
m_nsUsrName: 'wxid_friend',
|
||||
m_nsNickName: '好友昵称',
|
||||
remark: '好友备注',
|
||||
type: 'user' as const
|
||||
}
|
||||
])
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: { getPath: () => root },
|
||||
safeStorage: {
|
||||
isEncryptionAvailable: () => false,
|
||||
encryptString: (value: string) => Buffer.from(value, 'utf8'),
|
||||
decryptString: (value: Buffer) => value.toString('utf8')
|
||||
},
|
||||
BrowserWindow: { getAllWindows: () => [] }
|
||||
}))
|
||||
vi.mock('../../src/main/services/chat-service', () => ({
|
||||
listContacts: () => contacts,
|
||||
getSelfAccountInfo: () => ({ wxid: 'my_account_9527' }),
|
||||
// 头像补全是可选增强:这里显式给一个空实现,避免 runner 落到未 mock 的导出上刷警告。
|
||||
getContactAvatars: async () => ({})
|
||||
}))
|
||||
|
||||
import type {
|
||||
PersonalWechatSendCapability,
|
||||
PersonalWechatSendRequest
|
||||
} from '../../src/shared/personal-wechat'
|
||||
import type { Wcdb4Client } from '../../src/main/wcdb4-client'
|
||||
import type { GeneratedReportRecord } from '../../src/shared/report-history'
|
||||
import type { AgentGroupReportResult } from '../../src/main/services/agent-group-report-service'
|
||||
import type {
|
||||
AutomationRule,
|
||||
AutomationRuleDraft,
|
||||
ScheduledReportTargetType
|
||||
} from '../../src/shared/automation'
|
||||
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'
|
||||
import { ScheduledReportService } from '../../src/main/services/scheduled-report-service'
|
||||
|
||||
/**
|
||||
* 定时日报的**端到端链路**。
|
||||
*
|
||||
* Scheduler(`ScheduledReportService.tick`)→ `AutomationService.executeScheduledRule`
|
||||
* → `AutomationActionRunner.runScheduledReport` → `WechatActionGateway` → 被替换掉的传输层。
|
||||
*
|
||||
* 除了「微信怎么把图片发出去」,其余**全部是生产实现**。这个文件的存在理由:
|
||||
* - `AUTOMATION_PURPOSE_ALLOWLIST` 一旦漏登记 `automation_scheduled_report`,
|
||||
* 整条链会在策略层被**静默**拦下(ACTION_NOT_ALLOWED),而每个单元测试都还是绿的;
|
||||
* - `scheduledInFlight` / 确定性 executionId 这两道并发闸,只有真的拼起来才看得出来;
|
||||
* - 四个发送目标、槽位游标、执行日志的 trigger 区分,都是"拼起来"才成立的语义。
|
||||
*/
|
||||
|
||||
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: '个人微信已准备好发送日报'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const RULE_NAME = '技术交流群 · 每日晚报'
|
||||
const SCHEDULE_TIME = '18:30'
|
||||
/** 规则的创建时间固定在 2026-08-20,必须早于所有参与断言的槽位。 */
|
||||
const CREATED_AT = Date.parse('2026-08-20T09:00:00+08:00')
|
||||
/** 2026-08-27 18:31,当天 18:30 的槽位已经到点。 */
|
||||
const AFTER_SLOT = new Date('2026-08-27T18:31:00+08:00')
|
||||
/**
|
||||
* `AutomationService` / `AutomationRuleStore` 的 `now` 是**毫秒数**,
|
||||
* `ScheduledReportService` 的 `now` 是 **`Date`** —— 两者刻意分开命名,
|
||||
* 避免把毫秒数丢给需要 `Date` 的那一个(那会在推通知时抛 `toISOString` 错误)。
|
||||
*/
|
||||
const AFTER_SLOT_MS = AFTER_SLOT.getTime()
|
||||
const SLOT_ISO = new Date('2026-08-27T18:30:00+08:00').toISOString()
|
||||
|
||||
function scheduledDraft(
|
||||
target: ScheduledReportTargetType,
|
||||
options: { contactId?: string; enabled?: boolean; postfixText?: string } = {}
|
||||
): AutomationRuleDraft {
|
||||
return {
|
||||
name: RULE_NAME,
|
||||
enabled: options.enabled !== false,
|
||||
ruleType: 'scheduled_report',
|
||||
trigger: 'message',
|
||||
scope: 'group',
|
||||
conditions: {
|
||||
requireMentionMe: false,
|
||||
keyword: '',
|
||||
keywordMatchMode: 'contains',
|
||||
conversationIds: [],
|
||||
ignoreSelf: true
|
||||
},
|
||||
actions: [],
|
||||
cooldownSeconds: 0,
|
||||
replyDelaySeconds: 2,
|
||||
scheduledReport: {
|
||||
schedule: { time: SCHEDULE_TIME },
|
||||
report: {
|
||||
sourceConversationId: 'tech@chatroom',
|
||||
range: 'yesterday',
|
||||
messageTypes: ['text'],
|
||||
templateId: 'v1',
|
||||
memberNameMode: 'groupNickname',
|
||||
timeoutSeconds: 300
|
||||
},
|
||||
target:
|
||||
target === 'contact'
|
||||
? { type: 'contact', contactId: options.contactId ?? 'wxid_friend' }
|
||||
: { type: target },
|
||||
// 只有显式给了后置词才写进规则:空字符串意味着"只发图片",
|
||||
// 这正是存量规则迁移过来的形态,不能在测试工厂里被悄悄补上默认值。
|
||||
...(options.postfixText ? { postfixText: options.postfixText } : {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface Chain {
|
||||
scheduler: ScheduledReportService
|
||||
service: AutomationService
|
||||
store: AutomationRuleStore
|
||||
log: AutomationExecutionLogService
|
||||
sends: PersonalWechatSendRequest[]
|
||||
ruleId: string
|
||||
}
|
||||
|
||||
function buildChain(
|
||||
options: {
|
||||
capability?: PersonalWechatSendCapability
|
||||
target?: ScheduledReportTargetType
|
||||
contactId?: string
|
||||
enabled?: boolean
|
||||
postfixText?: string
|
||||
generate?: () => Promise<AgentGroupReportResult>
|
||||
} = {}
|
||||
): Chain {
|
||||
const sends: PersonalWechatSendRequest[] = []
|
||||
const savedReports: GeneratedReportRecord[] = []
|
||||
const capability = options.capability ?? readyCapability()
|
||||
|
||||
const gateway = new WechatActionGateway({
|
||||
getUserDataPath: () => root,
|
||||
getCapability: async () => capability,
|
||||
// 传输层是唯一被替换的部分:策略、幂等、审计、节流都是真的。
|
||||
send: async (request) => {
|
||||
sends.push(request)
|
||||
return { success: true, status: readyCapability().senderStatus }
|
||||
},
|
||||
wait: async () => undefined
|
||||
})
|
||||
|
||||
// `now` 固定:否则 `createdAt` 会变成墙上时钟,把「槽位必须晚于创建时间」那道闸踩掉。
|
||||
const store = new AutomationRuleStore({ userDataPath: () => root, now: () => CREATED_AT })
|
||||
const log = new AutomationExecutionLogService({ userDataPath: () => root })
|
||||
const runner = new AutomationActionRunner({
|
||||
executeAction: (request) => gateway.execute(request),
|
||||
generateReport:
|
||||
options.generate ??
|
||||
(async () => ({
|
||||
success: true,
|
||||
groupName: '技术交流群',
|
||||
pngPath: path.join(root, 'scheduled-report.png'),
|
||||
messageCount: 42
|
||||
})),
|
||||
saveGeneratedReport: async (request) => {
|
||||
const record: GeneratedReportRecord = {
|
||||
id: 'report-1',
|
||||
contactId: request.contactId,
|
||||
contactName: request.contactName,
|
||||
source: 'scheduled',
|
||||
dateRange: request.dateRange,
|
||||
messageCount: request.messageCount,
|
||||
generatedAt: request.generatedAt,
|
||||
reportDate: '2026-08-26',
|
||||
pngPath: path.join(root, 'scheduled-report.png'),
|
||||
htmlStatus: 'ready',
|
||||
pngStatus: 'ready'
|
||||
}
|
||||
savedReports.push(record)
|
||||
return { success: true, record }
|
||||
},
|
||||
resolveReportContact: () => ({
|
||||
md5: 'tech-md5',
|
||||
m_nsUsrName: 'tech@chatroom',
|
||||
m_nsNickName: '技术交流群'
|
||||
})
|
||||
})
|
||||
|
||||
const client = {
|
||||
getMyUsernameCandidates: () => [SELF],
|
||||
getSessions: () => [{ username: 'tech@chatroom', nickname: '技术交流群' }]
|
||||
} as unknown as Wcdb4Client
|
||||
|
||||
const service = new AutomationService(client, {
|
||||
ruleStore: store,
|
||||
executionLog: log,
|
||||
runner,
|
||||
getCapability: async () => capability,
|
||||
now: () => AFTER_SLOT_MS
|
||||
})
|
||||
|
||||
const ruleId = store.createRule(
|
||||
scheduledDraft(options.target ?? 'source_chat', {
|
||||
...(options.contactId ? { contactId: options.contactId } : {}),
|
||||
...(options.enabled === false ? { enabled: false } : {}),
|
||||
...(options.postfixText ? { postfixText: options.postfixText } : {})
|
||||
})
|
||||
).id
|
||||
|
||||
const scheduler = new ScheduledReportService({
|
||||
storageDir: path.join(root, 'scheduled-reports-legacy'),
|
||||
executeRule: (id, runOptions) => service.executeScheduledRule(id, runOptions),
|
||||
listRules: () => store.listRules(),
|
||||
listExecutions: (input) => log.list(input),
|
||||
isDatabaseReady: () => true,
|
||||
now: () => AFTER_SLOT
|
||||
})
|
||||
|
||||
return { scheduler, service, store, log, sends, ruleId }
|
||||
}
|
||||
|
||||
/** 每个用例都换一个干净的 userData 目录,避免审计 / 幂等记录互相污染。 */
|
||||
const workdirs: string[] = []
|
||||
function isolatedRoot(): void {
|
||||
const dir = fs.mkdtempSync(path.join(root, 'case-'))
|
||||
workdirs.push(dir)
|
||||
}
|
||||
|
||||
describe('scheduled report end-to-end chain', () => {
|
||||
beforeEach(() => {
|
||||
fs.removeSync(path.join(root, 'automation'))
|
||||
fs.removeSync(path.join(root, 'actions'))
|
||||
fs.removeSync(path.join(root, 'scheduled-reports-legacy'))
|
||||
isolatedRoot()
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
for (const dir of workdirs) fs.removeSync(dir)
|
||||
fs.removeSync(root)
|
||||
})
|
||||
|
||||
it('到点 ⇒ 生成日报 → 落日报历史 → 穿过 Gateway 发出,并记录 schedule 触发', async () => {
|
||||
const chain = buildChain()
|
||||
|
||||
await chain.scheduler.tick(AFTER_SLOT)
|
||||
await chain.scheduler.settle()
|
||||
|
||||
// 真的发出去了:说明 purpose 通过了 `AUTOMATION_PURPOSE_ALLOWLIST`。
|
||||
expect(chain.sends).toHaveLength(1)
|
||||
expect(chain.sends[0]).toMatchObject({
|
||||
type: 'image',
|
||||
to: 'tech@chatroom',
|
||||
isGroup: true,
|
||||
filePath: path.join(root, 'scheduled-report.png')
|
||||
})
|
||||
|
||||
const record = chain.log.list()[0]
|
||||
expect(record.trigger).toBe('schedule')
|
||||
expect(record.status).toBe('success')
|
||||
expect(record.steps.map((step) => `${step.key}:${step.status}`)).toEqual([
|
||||
'schedule_triggered:success',
|
||||
'report_generating:success',
|
||||
'report_generated:success',
|
||||
'send_resolved:success',
|
||||
'report_sent:success'
|
||||
])
|
||||
// `send_resolved` 的 detail 是**用户可读的目标名**,不是 roomId。
|
||||
expect(record.steps.find((step) => step.key === 'send_resolved')?.detail).toBe('技术交流群')
|
||||
})
|
||||
|
||||
it('配置了后置词 ⇒ 图片 sent 之后补发一条文本,两条各自独立', async () => {
|
||||
const chain = buildChain({ postfixText: '今日日报' })
|
||||
|
||||
await chain.scheduler.tick(AFTER_SLOT)
|
||||
await chain.scheduler.settle()
|
||||
|
||||
/*
|
||||
* 两条:先图后文。
|
||||
*
|
||||
* 后置词走的是**另一个 purpose**(`automation_scheduled_report_postfix`)——
|
||||
* 只要它在 `AUTOMATION_PURPOSE_ALLOWLIST` 里漏登记,第二次发送就会被策略层
|
||||
* 以 ACTION_NOT_ALLOWED 静默拦下,这里会只剩 1 条。这正是本用例存在的理由。
|
||||
*/
|
||||
expect(chain.sends.map((send) => send.type)).toEqual(['image', 'text'])
|
||||
expect(chain.sends[0]).toMatchObject({
|
||||
type: 'image',
|
||||
to: 'tech@chatroom',
|
||||
isGroup: true,
|
||||
filePath: path.join(root, 'scheduled-report.png')
|
||||
})
|
||||
expect(chain.sends[1]).toMatchObject({
|
||||
type: 'text',
|
||||
to: 'tech@chatroom',
|
||||
isGroup: true,
|
||||
text: '今日日报'
|
||||
})
|
||||
// 两条都成功,整次执行才是 success(后置词失败会拖垮整次判定,见下一条)。
|
||||
expect(chain.log.list()[0].status).toBe('success')
|
||||
})
|
||||
|
||||
it('未配置后置词 ⇒ 只发图片,绝不"顺手补一句"', async () => {
|
||||
const chain = buildChain()
|
||||
|
||||
await chain.scheduler.tick(AFTER_SLOT)
|
||||
await chain.scheduler.settle()
|
||||
|
||||
expect(chain.sends.map((send) => send.type)).toEqual(['image'])
|
||||
expect(chain.log.list()[0].status).toBe('success')
|
||||
})
|
||||
|
||||
it('图片能力缺失 ⇒ 后置词绝不发送(图片没发出去,就没有"之后")', async () => {
|
||||
const capability = readyCapability()
|
||||
const chain = buildChain({
|
||||
postfixText: '今日日报',
|
||||
capability: { ...capability, capabilities: { ...capability.capabilities, image: false } }
|
||||
})
|
||||
|
||||
await chain.scheduler.tick(AFTER_SLOT)
|
||||
await chain.scheduler.settle()
|
||||
|
||||
// 图片都没发出去,后置词更不可能发 —— 传输层零调用。
|
||||
expect(chain.sends).toHaveLength(0)
|
||||
expect(chain.log.list()[0].status).toBe('failed')
|
||||
})
|
||||
|
||||
it('图片已发出但后置词失败 ⇒ 如实判失败,不吞掉也不回滚', async () => {
|
||||
const capability = readyCapability()
|
||||
// 文字能力缺失:图片照发,但后置词那一步会被 SEND_NOT_READY 拦下。
|
||||
const chain = buildChain({
|
||||
postfixText: '今日日报',
|
||||
capability: { ...capability, capabilities: { ...capability.capabilities, text: false } }
|
||||
})
|
||||
|
||||
await chain.scheduler.tick(AFTER_SLOT)
|
||||
await chain.scheduler.settle()
|
||||
|
||||
// 图片确实发出去了 —— 微信发送不支持回滚,所以**必须留痕**,不能假装没发生。
|
||||
expect(chain.sends.map((send) => send.type)).toEqual(['image'])
|
||||
const record = chain.log.list()[0]
|
||||
expect(record.status).toBe('failed')
|
||||
// 失败点必须精确落在"后置词",而不是伪装成"日报没生成/没发送"。
|
||||
expect(record.steps.find((step) => step.key === 'report_sent')?.error).toContain(
|
||||
'后置词发送失败'
|
||||
)
|
||||
})
|
||||
|
||||
it('同一槽位重放 ⇒ 图片与后置词各自幂等,都不会重复发', async () => {
|
||||
const chain = buildChain({ postfixText: '今日日报' })
|
||||
|
||||
await chain.service.executeScheduledRule(chain.ruleId, {
|
||||
trigger: 'schedule',
|
||||
scheduledSlot: SLOT_ISO
|
||||
})
|
||||
await chain.service.executeScheduledRule(chain.ruleId, {
|
||||
trigger: 'schedule',
|
||||
scheduledSlot: SLOT_ISO
|
||||
})
|
||||
|
||||
// 两次重放合起来仍然只有一条图 + 一条文:两条各有独立幂等位,
|
||||
// 少给后置词一个 key 会让它在第二次重放时"被图片的幂等位短路掉"。
|
||||
expect(chain.sends.map((send) => send.type)).toEqual(['image', 'text'])
|
||||
})
|
||||
|
||||
it('四个发送目标各自解析正确(同一个 Gateway,不绑作用域锁)', async () => {
|
||||
const cases: Array<{ target: ScheduledReportTargetType; to: string; isGroup: boolean }> = [
|
||||
{ target: 'source_chat', to: 'tech@chatroom', isGroup: true },
|
||||
{ target: 'file_transfer', to: 'filehelper', isGroup: false },
|
||||
{ target: 'self', to: SELF, isGroup: false },
|
||||
{ target: 'contact', to: 'wxid_friend', isGroup: false }
|
||||
]
|
||||
|
||||
for (const item of cases) {
|
||||
const chain = buildChain({ target: item.target })
|
||||
|
||||
await chain.scheduler.tick(AFTER_SLOT)
|
||||
await chain.scheduler.settle()
|
||||
|
||||
expect(
|
||||
chain.sends.map((send) => ({ type: send.type, to: send.to, isGroup: send.isGroup }))
|
||||
).toEqual([{ type: 'image', to: item.to, isGroup: item.isGroup }])
|
||||
expect(chain.log.list()[0].status).toBe('success')
|
||||
}
|
||||
})
|
||||
|
||||
it('目标失效 ⇒ 生成成功但**绝不** fallback 到别处,且如实判失败', async () => {
|
||||
const chain = buildChain({ target: 'contact', contactId: 'wxid_gone' })
|
||||
|
||||
await chain.scheduler.tick(AFTER_SLOT)
|
||||
await chain.scheduler.settle()
|
||||
|
||||
expect(chain.sends).toHaveLength(0)
|
||||
const record = chain.log.list()[0]
|
||||
expect(record.status).toBe('failed')
|
||||
// 失败点在"发到哪",不能伪装成"生成失败"。
|
||||
expect(record.steps.find((step) => step.key === 'report_generating')?.status).toBe('success')
|
||||
expect(record.steps.find((step) => step.key === 'send_resolved')?.status).toBe('failed')
|
||||
expect(record.steps.find((step) => step.key === 'report_sent')?.status).toBe('skipped')
|
||||
})
|
||||
|
||||
it('停用的规则完全不执行(连日报都不生成)', async () => {
|
||||
const chain = buildChain({ enabled: false })
|
||||
|
||||
await chain.scheduler.tick(AFTER_SLOT)
|
||||
await chain.scheduler.settle()
|
||||
|
||||
expect(chain.sends).toHaveLength(0)
|
||||
expect(chain.log.list()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('图片能力缺失 ⇒ 日报照生成,但不发送,并说明原因', async () => {
|
||||
const capability = readyCapability()
|
||||
const chain = buildChain({
|
||||
capability: { ...capability, capabilities: { ...capability.capabilities, image: false } }
|
||||
})
|
||||
|
||||
await chain.scheduler.tick(AFTER_SLOT)
|
||||
await chain.scheduler.settle()
|
||||
|
||||
expect(chain.sends).toHaveLength(0)
|
||||
const record = chain.log.list()[0]
|
||||
expect(record.status).toBe('failed')
|
||||
expect(record.steps.find((step) => step.key === 'report_generating')?.status).toBe('success')
|
||||
expect(record.steps.find((step) => step.key === 'report_sent')?.error).toContain('无法发送图片')
|
||||
})
|
||||
|
||||
it('手动执行与定时执行**共用同一条链路**,只有 trigger 不同', async () => {
|
||||
const chain = buildChain()
|
||||
|
||||
const outcome = await chain.service.executeScheduledRule(chain.ruleId, { trigger: 'manual' })
|
||||
|
||||
expect(outcome.executed).toBe(true)
|
||||
expect(chain.sends).toHaveLength(1)
|
||||
expect(chain.log.list()[0].trigger).toBe('manual')
|
||||
})
|
||||
|
||||
it('同一条规则并发触发 ⇒ 第二个被 inFlight 挡掉,只发一次', async () => {
|
||||
const chain = buildChain()
|
||||
|
||||
const [first, second] = await Promise.all([
|
||||
chain.service.executeScheduledRule(chain.ruleId, {
|
||||
trigger: 'schedule',
|
||||
scheduledSlot: SLOT_ISO
|
||||
}),
|
||||
chain.service.executeScheduledRule(chain.ruleId, {
|
||||
trigger: 'schedule',
|
||||
scheduledSlot: SLOT_ISO
|
||||
})
|
||||
])
|
||||
|
||||
const executed = [first, second].filter((item) => item.executed)
|
||||
const blocked = [first, second].filter((item) => !item.executed)
|
||||
expect(executed).toHaveLength(1)
|
||||
expect(blocked).toHaveLength(1)
|
||||
expect(blocked[0].reason).toBe('in_flight')
|
||||
expect(chain.sends).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('同一槽位被重复触发 ⇒ 确定性 executionId 让 Gateway 去重,不会重复发', async () => {
|
||||
const chain = buildChain()
|
||||
|
||||
const first = await chain.service.executeScheduledRule(chain.ruleId, {
|
||||
trigger: 'schedule',
|
||||
scheduledSlot: SLOT_ISO
|
||||
})
|
||||
const second = await chain.service.executeScheduledRule(chain.ruleId, {
|
||||
trigger: 'schedule',
|
||||
scheduledSlot: SLOT_ISO
|
||||
})
|
||||
|
||||
// executionId 由 ruleId + slot 确定性派生,所以两次完全一致。
|
||||
expect(first.executionId).toBeTruthy()
|
||||
expect(second.executionId).toBe(first.executionId)
|
||||
// 传输层只被调用一次 —— 幂等拦在 Gateway,不是靠上层自觉。
|
||||
expect(chain.sends).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('槽位游标:定时触发消费槽位,手动执行不消费', async () => {
|
||||
const chain = buildChain()
|
||||
|
||||
await chain.service.executeScheduledRule(chain.ruleId, { trigger: 'manual' })
|
||||
const afterManual = chain.store.getRule(chain.ruleId)?.scheduledReport
|
||||
expect(afterManual?.lastRunAt).toBeTruthy()
|
||||
// 手动执行若写了槽位,就会把今天安排好的那次定时执行一起吞掉。
|
||||
expect(afterManual?.lastScheduledSlot).toBeUndefined()
|
||||
|
||||
await chain.service.executeScheduledRule(chain.ruleId, {
|
||||
trigger: 'schedule',
|
||||
scheduledSlot: SLOT_ISO
|
||||
})
|
||||
expect(chain.store.getRule(chain.ruleId)?.scheduledReport?.lastScheduledSlot).toBe(SLOT_ISO)
|
||||
})
|
||||
|
||||
it('调度器只读规则:槽位已消费后重启不再补跑', async () => {
|
||||
const chain = buildChain()
|
||||
|
||||
await chain.scheduler.tick(AFTER_SLOT)
|
||||
await chain.scheduler.settle()
|
||||
expect(chain.sends).toHaveLength(1)
|
||||
|
||||
// 新建一个调度器实例(= 应用重启):规则与游标都从盘上读。
|
||||
const restarted = new ScheduledReportService({
|
||||
storageDir: path.join(root, 'scheduled-reports-legacy'),
|
||||
executeRule: (id, runOptions) => chain.service.executeScheduledRule(id, runOptions),
|
||||
listRules: () => chain.store.listRules(),
|
||||
listExecutions: (input) => chain.log.list(input),
|
||||
isDatabaseReady: () => true,
|
||||
now: () => AFTER_SLOT
|
||||
})
|
||||
await restarted.tick(AFTER_SLOT)
|
||||
await restarted.settle()
|
||||
|
||||
expect(chain.sends).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('执行日志与步骤 detail 不泄露 roomId / wxid / 文件路径', async () => {
|
||||
const chain = buildChain({ target: 'contact' })
|
||||
|
||||
await chain.scheduler.tick(AFTER_SLOT)
|
||||
await chain.scheduler.settle()
|
||||
|
||||
const record = chain.log.list()[0]
|
||||
const serialized = JSON.stringify(record)
|
||||
expect(serialized).not.toContain('@chatroom')
|
||||
expect(serialized).not.toContain('wxid_friend')
|
||||
expect(serialized).not.toContain(root)
|
||||
// 但必须留下可读的目标名(备注优先)。
|
||||
expect(record.steps.find((step) => step.key === 'send_resolved')?.detail).toBe('好友备注')
|
||||
})
|
||||
|
||||
it('定时失败 ⇒ 推一条微信异常通知,且"今天执行"统计与那条日志是同一份', async () => {
|
||||
const store = new AutomationRuleStore({ userDataPath: () => root, now: () => CREATED_AT })
|
||||
const log = new AutomationExecutionLogService({ userDataPath: () => root })
|
||||
const runner = new AutomationActionRunner({
|
||||
// 生成直接失败 ⇒ 定时执行判 failed。
|
||||
generateReport: async () => ({
|
||||
success: false,
|
||||
error: '模型调用超时',
|
||||
errorCode: 'AI_TIMEOUT'
|
||||
})
|
||||
})
|
||||
const service = new AutomationService(
|
||||
{
|
||||
getMyUsernameCandidates: () => [SELF],
|
||||
getSessions: () => [{ username: 'tech@chatroom', nickname: '技术交流群' }]
|
||||
} as unknown as Wcdb4Client,
|
||||
{
|
||||
ruleStore: store,
|
||||
executionLog: log,
|
||||
runner,
|
||||
getCapability: async () => readyCapability(),
|
||||
now: () => AFTER_SLOT_MS
|
||||
}
|
||||
)
|
||||
const ruleId = store.createRule(scheduledDraft('source_chat')).id
|
||||
|
||||
const notifications: string[] = []
|
||||
const scheduler = new ScheduledReportService({
|
||||
storageDir: path.join(root, 'scheduled-reports-legacy'),
|
||||
executeRule: (id, runOptions) => service.executeScheduledRule(id, runOptions),
|
||||
listRules: () => store.listRules(),
|
||||
listExecutions: (input) => log.list(input),
|
||||
sendNotification: async (request) => {
|
||||
notifications.push(request.text)
|
||||
return { success: true, status: 'sent' }
|
||||
},
|
||||
getNotificationRecipient: () => SELF,
|
||||
getAgentHubStatus: () => ({ hub: 'online', connector: 'online', updatedAt: 0 }),
|
||||
isDatabaseReady: () => true,
|
||||
now: () => AFTER_SLOT
|
||||
})
|
||||
|
||||
// 开启异常通知会先试发一条,把它排掉,后面只看执行失败那一条。
|
||||
await scheduler.setNotificationEnabled(true)
|
||||
expect(notifications).toHaveLength(1)
|
||||
notifications.length = 0
|
||||
|
||||
await scheduler.tick(AFTER_SLOT)
|
||||
await scheduler.settle()
|
||||
|
||||
expect(log.list()[0].status).toBe('failed')
|
||||
expect(log.list()[0].ruleId).toBe(ruleId)
|
||||
expect(notifications).toHaveLength(1)
|
||||
expect(notifications[0]).toContain(RULE_NAME)
|
||||
expect(notifications[0]).toContain('模型调用超时')
|
||||
})
|
||||
|
||||
it('规则不存在 / 目标待重选 ⇒ 明确拒绝,不执行也不发送', async () => {
|
||||
const chain = buildChain()
|
||||
|
||||
const missing = await chain.service.executeScheduledRule('rule_does_not_exist', {
|
||||
trigger: 'manual'
|
||||
})
|
||||
expect(missing.executed).toBe(false)
|
||||
expect(missing.reason).toBe('rule_not_found')
|
||||
|
||||
// 把规则改成「目标待重选」:迁移遗留态,执行必然失败 ⇒ 直接拒绝。
|
||||
const current = chain.store.getRule(chain.ruleId) as AutomationRule
|
||||
chain.store.updateRule(chain.ruleId, {
|
||||
...(current as unknown as AutomationRuleDraft),
|
||||
scheduledReport: { ...current.scheduledReport!, targetNeedsReview: true }
|
||||
})
|
||||
const needsReview = await chain.service.executeScheduledRule(chain.ruleId, {
|
||||
trigger: 'manual'
|
||||
})
|
||||
expect(needsReview.executed).toBe(false)
|
||||
expect(needsReview.reason).toBe('target_needs_review')
|
||||
expect(chain.sends).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user