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
+216 -16
View File
@@ -220,16 +220,37 @@ describe('AutomationRuleStore', () => {
})
})
/*
* 存量安装:早期版本把"部分勾选"判成无法无损映射,于是落盘了
* `targetNeedsReview: true`。这个标记会拦发送(`handleGroupExit` 第②道闸门),
* 所以**用户重存一次必须能清掉它** —— 否则那条规则永远发不出去。
*
* 注意种的是"已经落盘的存量状态",不是靠迁移产出:迁移现在不再产生这个标记了。
*/
it('保存退群通知会清掉迁移遗留的「目标待重选」标记', () => {
writeLegacyMonitorState({
monitoredRoomIds: ['a@chatroom', 'b@chatroom'],
notificationRoomIds: ['a@chatroom']
})
const store = new AutomationRuleStore(deps(root))
store.saveLeaveNotificationRule({
name: '退群通知',
enabled: false,
ruleType: 'leave_notification',
scope: 'group',
conditions: {},
actions: [],
cooldownSeconds: 0,
replyDelaySeconds: 2,
leaveNotification: {
target: { type: 'source_chat' },
template: '[退群监测]',
notifyScope: 'all',
notifyRoomIds: [],
targetNeedsReview: true
}
})
expect(
store.getRule(BUILTIN_LEAVE_NOTIFICATION_RULE_ID)?.leaveNotification?.targetNeedsReview
).toBe(true)
// 用户来重存一次:草稿里不带这个标记 ⇒ 归一化后它就该消失。
store.saveLeaveNotificationRule({
...store.getRule(BUILTIN_LEAVE_NOTIFICATION_RULE_ID),
enabled: true,
@@ -384,14 +405,17 @@ describe('AutomationRuleStore', () => {
)
expect(leave?.enabled).toBe(false)
// 关闭状态下 target 只是占位值(= 默认目标),不会发送任何东西。
// 关闭状态下 target 只是占位值(= 「发回本群」),不会发送任何东西。
expect(leave?.leaveNotification?.target).toEqual({ type: 'source_chat' })
// 范围本身也精确:`selected` + 空集 = 「一个群都不通知」。
expect(leave?.leaveNotification?.notifyScope).toBe('selected')
expect(leave?.leaveNotification?.notifyRoomIds).toEqual([])
// 用户写的模板内容保留;同一次启动的"补群名"升级会另加一行。
expect(leave?.leaveNotification?.template).toContain('退群: {user}')
expect(leave?.leaveNotification?.template).toContain('群聊: {groupName}')
})
it('旧配置在每个被监控群都通知 → 无损映射为「当前群聊」且保持启用', () => {
it('旧配置在每个被监控群都通知 → 无损映射为「当前群聊」+ 全部范围,且保持启用', () => {
writeLegacyMonitorState({
monitoredRoomIds: ['a@chatroom', 'b@chatroom'],
notificationRoomIds: ['a@chatroom', 'b@chatroom']
@@ -403,15 +427,16 @@ describe('AutomationRuleStore', () => {
expect(leave?.enabled).toBe(true)
expect(leave?.leaveNotification?.target).toEqual({ type: 'source_chat' })
expect(leave?.leaveNotification?.notifyScope).toBe('all')
expect(leave?.leaveNotification?.targetNeedsReview).toBeUndefined()
})
/*
* 关键的一条:旧「通知群聊」是**逐群**的多值配置,新目标是**单值**。
* 只勾了一部分时无法无损映射 —— 这时候:
* 不取第一个群、不改写成当前群聊、保持关闭、要求用户重选,并留一份备份。
* 关键的一条:旧「通知群聊」是**逐群**的多值配置,恢复 `notifyScope` 之后
* 它就是一个正常可表达的字段 —— 所以这里**不再降级成 needs_review**:
* 那份勾选被原样保留、规则保持启用、不会再多一次"被无辜停用"的体验。
*/
it('旧配置只勾了部分群 → 标记待重选、不发送、留下备份,绝不猜', () => {
it('旧配置只勾了部分群 → 原样保留那份子集,保持启用,不做有损改写', () => {
writeLegacyMonitorState({
monitoredRoomIds: ['a@chatroom', 'b@chatroom', 'c@chatroom'],
notificationRoomIds: ['b@chatroom'],
@@ -421,11 +446,13 @@ describe('AutomationRuleStore', () => {
const store = new AutomationRuleStore(deps(root))
const leave = store.getRule(BUILTIN_LEAVE_NOTIFICATION_RULE_ID)
expect(leave?.enabled).toBe(false)
expect(leave?.leaveNotification?.targetNeedsReview).toBe(true)
// 不回落到「取第一个群」,也不改写成别的具体目标 —— 只留默认占位(当前群聊),
// 且因为 enabled=false + targetNeedsReview=true,在用户重选之前不会发送。
expect(leave?.enabled).toBe(true)
expect(leave?.leaveNotification?.targetNeedsReview).toBeUndefined()
// 收件人恒为事件所在群(旧版 notifyGroup 的事实)⇒ target 仍是 source_chat。
expect(leave?.leaveNotification?.target).toEqual({ type: 'source_chat' })
// 二次勾选逐字保留:只通知 b,不多不少。
expect(leave?.leaveNotification?.notifyScope).toBe('selected')
expect(leave?.leaveNotification?.notifyRoomIds).toEqual(['b@chatroom'])
// 用户写的模板内容保留;同一次启动的"补群名"升级会另加一行。
expect(leave?.leaveNotification?.template).toContain('退群: {user}')
expect(leave?.leaveNotification?.template).toContain('群聊: {groupName}')
@@ -434,10 +461,10 @@ describe('AutomationRuleStore', () => {
path.join(root, 'automation', 'leave-notification-migration-backup.json')
)
expect(backup).toMatchObject({
outcome: 'needs_review',
outcome: 'lossless_selected_groups',
legacy: { notificationRoomIds: ['b@chatroom'] }
})
expect(store.getLastLeaveNotificationMigration()?.outcome).toBe('needs_review')
expect(store.getLastLeaveNotificationMigration()?.outcome).toBe('lossless_selected_groups')
})
it('迁移只跑一次:重启两次也只有一条退群通知规则', () => {
@@ -477,6 +504,179 @@ describe('AutomationRuleStore', () => {
})
})
/**
* 旧「定时日报任务」→ `scheduled_report` 规则的 store 级迁移。
*
* 纯映射器的逐字段断言在 `scheduled-report-migration.test.ts`;
* 这里只守 store 负责的三件事:**插几条 / 幂等 / 解析器不可用时推迟**。
*/
describe('AutomationRuleStore · 定时日报迁移', () => {
const legacyTasksFile = (): string => path.join(root, 'scheduled-reports', 'tasks.json')
const backupFile = (): string =>
path.join(root, 'automation', 'scheduled-report-migration-backup.json')
/** 与 main 侧注入的真实解析器同口径(`@chatroom` 原样透传)。 */
const resolver = (raw: string): string | undefined => {
const key = raw.trim()
if (!key) return undefined
if (key.endsWith('@chatroom')) return key
return { 'tech-md5': 'tech@chatroom', 'product-md5': 'product@chatroom' }[key]
}
const writeLegacyTasks = (value: unknown): void => {
fs.ensureDirSync(path.dirname(legacyTasksFile()))
fs.writeJsonSync(legacyTasksFile(), value)
}
const legacyTasks = (): unknown[] => [
{
id: 'scheduled_report_a',
name: 'A 群日报',
group: 'tech-md5',
target: 'tech@chatroom',
scheduleTime: '09:00',
reportRange: 'yesterday',
enabled: true,
createdAt: '2026-08-01T01:00:00.000Z',
updatedAt: '2026-08-01T01:00:00.000Z'
},
{
id: 'scheduled_report_b',
name: 'B 群日报',
group: 'product-md5',
target: 'product@chatroom',
scheduleTime: '10:00',
reportRange: 'today',
enabled: false,
createdAt: '2026-08-02T01:00:00.000Z',
updatedAt: '2026-08-02T01:00:00.000Z'
},
{
id: 'scheduled_report_c',
name: '发到别的群',
group: 'tech-md5',
target: 'product@chatroom',
scheduleTime: '11:00',
reportRange: 'today',
enabled: true,
createdAt: '2026-08-03T01:00:00.000Z',
updatedAt: '2026-08-03T01:00:00.000Z'
}
]
beforeEach(() => {
fs.removeSync(path.join(root, 'automation'))
fs.removeSync(path.join(root, 'scheduled-reports'))
})
it('3 条旧任务 ⇒ 3 条 scheduled_report 规则(连同内置规则一起),且判定分布如实', () => {
writeLegacyTasks(legacyTasks())
const store = new AutomationRuleStore(deps(root))
store.setLegacyConversationResolver(resolver)
const scheduled = store.listRules().filter((rule) => rule.ruleType === 'scheduled_report')
expect(scheduled.map((rule) => rule.id).sort()).toEqual([
'scheduled_report_a',
'scheduled_report_b',
'scheduled_report_c'
])
const summary = store.getLastScheduledReportMigration()
expect(summary).toEqual({
total: 3,
migrated: 3,
lossless: 2,
needsReview: 1,
duplicatesSkipped: 0
})
// 「发到别的群」那条:不自动改写目标,且必须停用。
const needsReview = scheduled.find((rule) => rule.id === 'scheduled_report_c')
expect(needsReview?.enabled).toBe(false)
expect(needsReview?.scheduledReport?.targetNeedsReview).toBe(true)
// 旧任务是启用的,但迁移后必须停用 —— 不能替用户决定发到哪儿。
expect(store.getRule('scheduled_report_a')?.enabled).toBe(true)
})
it('二次启动不会重复迁移(仍只有 3 条,不是 6 条)', () => {
writeLegacyTasks(legacyTasks())
const first = new AutomationRuleStore(deps(root))
first.setLegacyConversationResolver(resolver)
expect(
first.listRules().filter((rule) => rule.ruleType === 'scheduled_report')
).toHaveLength(3)
// 第二个实例 = 重启:从盘上读,旧 tasks.json 仍在原处。
const second = new AutomationRuleStore(deps(root))
second.setLegacyConversationResolver(resolver)
const ids = second
.listRules()
.filter((rule) => rule.ruleType === 'scheduled_report')
.map((rule) => rule.id)
expect(ids).toHaveLength(3)
expect(new Set(ids).size).toBe(3)
// 已迁移标记落盘 ⇒ 不再有待迁任务。
expect(second.hasPendingScheduledReportMigration()).toBe(false)
})
it('解析器不可用时**推迟**迁移:不置标记、不插规则、如实报告待迁', () => {
writeLegacyTasks(legacyTasks())
const store = new AutomationRuleStore(deps(root))
expect(store.listRules().filter((rule) => rule.ruleType === 'scheduled_report')).toHaveLength(0)
expect(store.hasPendingScheduledReportMigration()).toBe(true)
expect(store.getLastScheduledReportMigration()).toBeNull()
// 数据库就绪后注入解析器 ⇒ 立刻补跑。
store.setLegacyConversationResolver(resolver)
expect(store.listRules().filter((rule) => rule.ruleType === 'scheduled_report')).toHaveLength(3)
expect(store.hasPendingScheduledReportMigration()).toBe(false)
})
it('迁移前把旧任务原样落一份 backup(用户数据可回溯)', () => {
const tasks = legacyTasks()
writeLegacyTasks(tasks)
const store = new AutomationRuleStore(deps(root))
// 先注入解析器、后首次加载:这正是 main 的真实顺序(构造函数不读盘)。
// `setLegacyConversationResolver` 在未加载时只记住 resolver,
// 迁移推迟到第一次 `ensureLoaded()`,注入顺序不影响结果。
store.setLegacyConversationResolver(resolver)
store.listRules()
expect(fs.pathExistsSync(backupFile())).toBe(true)
const backup = fs.readJsonSync(backupFile()) as {
summary: { migrated: number }
legacyTasks: unknown[]
}
expect(backup.summary.migrated).toBe(3)
// 备份里保留**原始字段**(包括旧目标原文),否则出了问题无法回溯。
expect(backup.legacyTasks).toEqual(tasks)
// 旧 tasks.json 不被删除或改写 —— 迁移必须是可逆的。
expect(fs.readJsonSync(legacyTasksFile())).toEqual(tasks)
})
it('没有旧任务时走全新安装路径:不插任何定时日报规则,也不报待迁', () => {
const store = new AutomationRuleStore(deps(root))
store.setLegacyConversationResolver(resolver)
expect(store.listRules().filter((rule) => rule.ruleType === 'scheduled_report')).toHaveLength(0)
expect(store.hasPendingScheduledReportMigration()).toBe(false)
expect(store.getLastScheduledReportMigration()).toBeNull()
expect(fs.pathExistsSync(backupFile())).toBe(false)
})
it('旧文件损坏时按「没有旧任务」处理,绝不抛异常挡住启动', () => {
fs.ensureDirSync(path.dirname(legacyTasksFile()))
fs.writeFileSync(legacyTasksFile(), 'not json at all', 'utf8')
const store = new AutomationRuleStore(deps(root))
store.setLegacyConversationResolver(resolver)
expect(store.listRules().filter((rule) => rule.ruleType === 'scheduled_report')).toHaveLength(0)
})
})
describe('AutomationExecutionLogService', () => {
beforeEach(() => {
fs.removeSync(path.join(root, 'automation'))