feat: 定时日报并入自动化,统一规则管理与发送链路

发送目标与退群通知统一为 来源群 / 自己 / 文件传输助手 / 指定好友
日报页补「定时日报已并入自动化」指引条
This commit is contained in:
电摇小子
2026-09-28 17:11:50 +07:00
parent c835d5c570
commit 4d5c6459fe
49 changed files with 6436 additions and 4863 deletions
+53 -1
View File
@@ -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'])
})
})
+53 -4
View File
@@ -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 () => {
+86 -51
View File
@@ -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)
})
})