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
@@ -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)
})
})