feat: 退群监控接入自动化能力统一管理, 支持发送个人/文件传输助手/指定群聊

This commit is contained in:
Wxw-Gu
2026-09-24 16:31:07 +08:00
parent 3bd3935042
commit 59872daf52
43 changed files with 5661 additions and 1132 deletions
+337 -18
View File
@@ -14,7 +14,7 @@ const root = vi.hoisted(() => {
vi.mock('electron', () => ({ app: { getPath: () => root } }))
import { BUILTIN_DAILY_REPORT_RULE_ID } from '../../src/shared/automation'
import { BUILTIN_DAILY_REPORT_RULE_ID, BUILTIN_LEAVE_NOTIFICATION_RULE_ID } from '../../src/shared/automation'
import {
AutomationRuleStore,
type AutomationRuleStoreDependencies
@@ -31,6 +31,15 @@ const deps = (dir: string, now?: () => number): AutomationRuleStoreDependencies
...(now ? { now } : {})
})
/** 写旧退群监控状态文件(退群通知迁移的唯一输入)。 */
function writeLegacyMonitorState(input: {
monitoredRoomIds?: string[]
notificationRoomIds?: string[]
notificationTemplate?: string
}): void {
fs.writeJsonSync(path.join(root, 'group-exit-monitor.json'), input)
}
describe('AutomationRuleStore', () => {
beforeEach(() => {
fs.removeSync(path.join(root, 'automation'))
@@ -38,21 +47,22 @@ describe('AutomationRuleStore', () => {
afterAll(() => fs.removeSync(root))
it('首次加载会播种内置规则「@我生成日报」并落盘', () => {
it('首次加载会播种内置规则「@我生成日报」与「退群通知」并落盘', () => {
const store = new AutomationRuleStore(deps(root))
const rules = store.listRules()
expect(rules).toHaveLength(1)
const builtin = rules[0]
expect(builtin.id).toBe(BUILTIN_DAILY_REPORT_RULE_ID)
expect(builtin.name).toBe('@我生成日报')
expect(builtin.enabled).toBe(true)
expect(builtin.scope).toBe('group')
expect(builtin.conditions.requireMentionMe).toBe(true)
expect(builtin.conditions.keyword).toBe('日报')
expect(builtin.conditions.ignoreSelf).toBe(true)
expect(builtin.cooldownSeconds).toBe(60)
expect(builtin.actions.map((action) => action.type)).toEqual([
expect(rules).toHaveLength(2)
const builtin = rules.find((rule) => rule.id === BUILTIN_DAILY_REPORT_RULE_ID)
expect(builtin).toBeDefined()
expect(builtin?.name).toBe('@我生成日报')
expect(builtin?.ruleType).toBe('daily_report')
expect(builtin?.enabled).toBe(true)
expect(builtin?.scope).toBe('group')
expect(builtin?.conditions.requireMentionMe).toBe(true)
expect(builtin?.conditions.keyword).toBe('日报')
expect(builtin?.conditions.ignoreSelf).toBe(true)
expect(builtin?.cooldownSeconds).toBe(60)
expect(builtin?.actions.map((action) => action.type)).toEqual([
'replyText',
'generateReport',
'sendReportImage'
@@ -60,9 +70,25 @@ describe('AutomationRuleStore', () => {
expect(fs.pathExistsSync(rulesFile())).toBe(true)
})
it('全新安装的退群通知规则:默认目标是「当前群聊」', () => {
const store = new AutomationRuleStore(deps(root))
const leave = store.listRules().find((rule) => rule.id === BUILTIN_LEAVE_NOTIFICATION_RULE_ID)
expect(leave).toBeDefined()
expect(leave?.ruleType).toBe('leave_notification')
expect(leave?.name).toBe('退群通知')
// 「当前群聊」= 旧退群监控通知的原始行为,所以既是默认值也是第一项。
expect(leave?.leaveNotification?.target).toEqual({ type: 'source_chat' })
expect(leave?.leaveNotification?.targetNeedsReview).toBeUndefined()
// 退群通知不套用消息型规则的冷却,避免把两次退群合并成一次。
expect(leave?.cooldownSeconds).toBe(0)
expect(leave?.actions).toEqual([])
})
it('删掉内置规则后不会在下次启动时被重新塞回来', () => {
const store = new AutomationRuleStore(deps(root))
expect(store.deleteRule(BUILTIN_DAILY_REPORT_RULE_ID)).toBe(true)
expect(store.deleteRule(BUILTIN_LEAVE_NOTIFICATION_RULE_ID)).toBe(true)
expect(store.listRules()).toHaveLength(0)
// 关键:新实例(等于重启)不应该重新播种。
@@ -83,12 +109,13 @@ describe('AutomationRuleStore', () => {
expect(created.id).not.toBe(BUILTIN_DAILY_REPORT_RULE_ID)
expect(created.name).toBe('关键词回复')
expect(created.ruleType).toBe('daily_report')
expect(created.scope).toBe('group')
expect(created.conditions.keywordMatchMode).toBe('contains')
expect(created.conditions.conversationIds).toEqual(['a'])
expect(created.createdAt).toBe(now)
expect(created.updatedAt).toBe(now)
expect(store.listRules()).toHaveLength(2)
expect(store.listRules()).toHaveLength(3)
})
it('updateRule 保留 id 与 createdAt,刷新 updatedAt', () => {
@@ -137,13 +164,20 @@ describe('AutomationRuleStore', () => {
fs.writeFileSync(rulesFile(), '{ 这不是 JSON', 'utf8')
const store = new AutomationRuleStore(deps(root))
expect(store.listRules()).toHaveLength(1)
expect(store.listRules()[0].id).toBe(BUILTIN_DAILY_REPORT_RULE_ID)
expect(store.listRules()).toHaveLength(2)
expect(store.listRules().map((rule) => rule.id)).toEqual(
expect.arrayContaining([BUILTIN_DAILY_REPORT_RULE_ID, BUILTIN_LEAVE_NOTIFICATION_RULE_ID])
)
})
it('已播种但规则列表为空的存档不会被再次播种', () => {
it('已播种但规则列表为空的存档不会被再次播种「@我生成日报」', () => {
fs.ensureDirSync(path.dirname(rulesFile()))
fs.writeJsonSync(rulesFile(), { version: 1, builtinSeeded: true, rules: [] })
fs.writeJsonSync(rulesFile(), {
version: 2,
builtinSeeded: true,
leaveNotificationMigrated: true,
rules: []
})
const store = new AutomationRuleStore(deps(root))
expect(store.listRules()).toHaveLength(0)
@@ -156,6 +190,291 @@ describe('AutomationRuleStore', () => {
const reopened = new AutomationRuleStore(deps(root))
expect(reopened.listRules().map((rule) => rule.name)).toContain('持久化检查')
})
it('saveLeaveNotificationRule 是 singleton upsert:保存多次也只有一条', () => {
const store = new AutomationRuleStore(deps(root))
for (let index = 0; index < 3; index += 1) {
store.saveLeaveNotificationRule({
name: '退群通知',
enabled: true,
ruleType: 'leave_notification',
scope: 'group',
conditions: {},
actions: [],
cooldownSeconds: 0,
replyDelaySeconds: 2,
leaveNotification: {
target: { type: 'contact', contactId: `wxid_friend_${index}` },
template: '[退群监测]\n用户: {user}'
}
})
}
const leaveRules = store
.listRules()
.filter((rule) => rule.id === BUILTIN_LEAVE_NOTIFICATION_RULE_ID)
expect(leaveRules).toHaveLength(1)
expect(leaveRules[0].leaveNotification?.target).toEqual({
type: 'contact',
contactId: 'wxid_friend_2'
})
})
it('保存退群通知会清掉迁移遗留的「目标待重选」标记', () => {
writeLegacyMonitorState({
monitoredRoomIds: ['a@chatroom', 'b@chatroom'],
notificationRoomIds: ['a@chatroom']
})
const store = new AutomationRuleStore(deps(root))
expect(
store.getRule(BUILTIN_LEAVE_NOTIFICATION_RULE_ID)?.leaveNotification?.targetNeedsReview
).toBe(true)
store.saveLeaveNotificationRule({
...store.getRule(BUILTIN_LEAVE_NOTIFICATION_RULE_ID),
enabled: true,
leaveNotification: { target: { type: 'source_chat' }, template: '[退群监测]' }
})
const saved = store.getRule(BUILTIN_LEAVE_NOTIFICATION_RULE_ID)
expect(saved?.leaveNotification?.targetNeedsReview).toBeUndefined()
expect(saved?.leaveNotification?.target).toEqual({ type: 'source_chat' })
})
/*
* 加上群名变量之前的默认模板(有「人数」行、没有「群聊」行)。
* 这正是老用户手上那份 —— 模板是他们的已保存数据。
*/
const PREVIOUS_DEFAULT_TEMPLATE = [
'[退群监测]',
'',
'用户: {user}',
'',
'群备注: {groupRemark}',
'',
'微信号: {wxid}',
'',
'人数: {previousCount} -> {currentCount}',
'',
'退群时间: {time}'
].join('\n')
/** 写一份"老用户"的规则文件(退群通知模板里没有群名变量)。 */
function writeRulesWithTemplate(
template: string,
flags: { notificationTemplateUpgraded?: boolean } = {}
): void {
fs.ensureDirSync(path.dirname(rulesFile()))
fs.writeJsonSync(rulesFile(), {
version: 2,
builtinSeeded: true,
leaveNotificationMigrated: true,
notificationTemplateUpgraded: flags.notificationTemplateUpgraded === true,
rules: [
{
id: BUILTIN_DAILY_REPORT_RULE_ID,
name: '@我生成日报',
enabled: true,
ruleType: 'daily_report',
trigger: 'message',
scope: 'group',
conditions: { requireMentionMe: true, keyword: '日报', keywordMatchMode: 'contains' },
actions: [{ type: 'generateReplyText', enabled: true }],
cooldownSeconds: 60,
replyDelaySeconds: 2,
createdAt: 1,
updatedAt: 1
},
{
id: BUILTIN_LEAVE_NOTIFICATION_RULE_ID,
name: '退群通知',
enabled: true,
ruleType: 'leave_notification',
trigger: 'message',
scope: 'group',
conditions: { requireMentionMe: false, keyword: '', keywordMatchMode: 'contains' },
actions: [],
cooldownSeconds: 0,
replyDelaySeconds: 2,
leaveNotification: { target: { type: 'source_chat' }, template },
createdAt: 1,
updatedAt: 1
}
]
})
}
describe('替用户把群名变量补进模板(一次性)', () => {
it('老用户首次读盘时自动补上「群聊」这一行,其余内容保留', () => {
writeRulesWithTemplate(PREVIOUS_DEFAULT_TEMPLATE)
const rule = new AutomationRuleStore(deps(root)).getRule(BUILTIN_LEAVE_NOTIFICATION_RULE_ID)
const template = rule?.leaveNotification?.template ?? ''
expect(template).toContain('群聊: {groupName}')
// 原来每一行都还在。
for (const line of PREVIOUS_DEFAULT_TEMPLATE.split('\n')) {
if (line.trim()) expect(template).toContain(line)
}
})
it('只补一次:之后用户删掉这一行,重启不会被加回来', () => {
writeRulesWithTemplate(PREVIOUS_DEFAULT_TEMPLATE)
// 第一轮:自动补上,并落盘标记。
const first = new AutomationRuleStore(deps(root))
expect(first.getRule(BUILTIN_LEAVE_NOTIFICATION_RULE_ID)?.leaveNotification?.template).toContain(
'群聊: {groupName}'
)
// 用户主动把这一行删掉并保存。
const stripped = PREVIOUS_DEFAULT_TEMPLATE
first.saveLeaveNotificationRule({
...first.getRule(BUILTIN_LEAVE_NOTIFICATION_RULE_ID),
leaveNotification: { target: { type: 'source_chat' }, template: stripped }
})
// 重启:不能再替他补 —— "删不掉"比缺信息更讨厌。
const reopened = new AutomationRuleStore(deps(root))
expect(
reopened.getRule(BUILTIN_LEAVE_NOTIFICATION_RULE_ID)?.leaveNotification?.template
).toBe(stripped)
})
it('已经含 {groupName} 的模板原样不动', () => {
const already = '[退群监测]\n\n群聊: {groupName}\n\n用户: {user}'
writeRulesWithTemplate(already)
const rule = new AutomationRuleStore(deps(root)).getRule(BUILTIN_LEAVE_NOTIFICATION_RULE_ID)
expect(rule?.leaveNotification?.template).toBe(already)
})
it('用户完全自定义的模板也不会被覆盖内容', () => {
writeRulesWithTemplate('退群: {user}')
const rule = new AutomationRuleStore(deps(root)).getRule(BUILTIN_LEAVE_NOTIFICATION_RULE_ID)
const template = rule?.leaveNotification?.template ?? ''
expect(template).toContain('退群: {user}')
expect(template).toContain('群聊: {groupName}')
})
it('标记已是 true 时不再动模板', () => {
writeRulesWithTemplate(PREVIOUS_DEFAULT_TEMPLATE, { notificationTemplateUpgraded: true })
const rule = new AutomationRuleStore(deps(root)).getRule(BUILTIN_LEAVE_NOTIFICATION_RULE_ID)
expect(rule?.leaveNotification?.template).toBe(PREVIOUS_DEFAULT_TEMPLATE)
})
it('全新安装的模板本来就带群名,不需要补', () => {
const rule = new AutomationRuleStore(deps(root)).getRule(BUILTIN_LEAVE_NOTIFICATION_RULE_ID)
expect(rule?.leaveNotification?.template).toContain('群聊: {groupName}')
})
})
describe('旧退群通知配置迁移', () => {
it('旧配置从不通知任何人 → 迁移成关闭,且保留自定义模板', () => {
writeLegacyMonitorState({
monitoredRoomIds: ['a@chatroom', 'b@chatroom'],
notificationRoomIds: [],
notificationTemplate: '退群: {user}'
})
const leave = new AutomationRuleStore(deps(root)).getRule(
BUILTIN_LEAVE_NOTIFICATION_RULE_ID
)
expect(leave?.enabled).toBe(false)
// 关闭状态下 target 只是占位值(= 默认目标),不会发送任何东西。
expect(leave?.leaveNotification?.target).toEqual({ type: 'source_chat' })
// 用户写的模板内容保留;同一次启动的"补群名"升级会另加一行。
expect(leave?.leaveNotification?.template).toContain('退群: {user}')
expect(leave?.leaveNotification?.template).toContain('群聊: {groupName}')
})
it('旧配置在每个被监控群都通知 → 无损映射为「当前群聊」且保持启用', () => {
writeLegacyMonitorState({
monitoredRoomIds: ['a@chatroom', 'b@chatroom'],
notificationRoomIds: ['a@chatroom', 'b@chatroom']
})
const leave = new AutomationRuleStore(deps(root)).getRule(
BUILTIN_LEAVE_NOTIFICATION_RULE_ID
)
expect(leave?.enabled).toBe(true)
expect(leave?.leaveNotification?.target).toEqual({ type: 'source_chat' })
expect(leave?.leaveNotification?.targetNeedsReview).toBeUndefined()
})
/*
* 关键的一条:旧「通知群聊」是**逐群**的多值配置,新目标是**单值**。
* 只勾了一部分时无法无损映射 —— 这时候:
* 不取第一个群、不改写成当前群聊、保持关闭、要求用户重选,并留一份备份。
*/
it('旧配置只勾了部分群 → 标记待重选、不发送、留下备份,绝不猜', () => {
writeLegacyMonitorState({
monitoredRoomIds: ['a@chatroom', 'b@chatroom', 'c@chatroom'],
notificationRoomIds: ['b@chatroom'],
notificationTemplate: '退群: {user}'
})
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?.leaveNotification?.target).toEqual({ type: 'source_chat' })
// 用户写的模板内容保留;同一次启动的"补群名"升级会另加一行。
expect(leave?.leaveNotification?.template).toContain('退群: {user}')
expect(leave?.leaveNotification?.template).toContain('群聊: {groupName}')
const backup = fs.readJsonSync(
path.join(root, 'automation', 'leave-notification-migration-backup.json')
)
expect(backup).toMatchObject({
outcome: 'needs_review',
legacy: { notificationRoomIds: ['b@chatroom'] }
})
expect(store.getLastLeaveNotificationMigration()?.outcome).toBe('needs_review')
})
it('迁移只跑一次:重启两次也只有一条退群通知规则', () => {
writeLegacyMonitorState({
monitoredRoomIds: ['a@chatroom', 'b@chatroom'],
notificationRoomIds: ['a@chatroom', 'b@chatroom']
})
const first = new AutomationRuleStore(deps(root))
const second = new AutomationRuleStore(deps(root))
for (const store of [first, second]) {
expect(
store.listRules().filter((rule) => rule.id === BUILTIN_LEAVE_NOTIFICATION_RULE_ID)
).toHaveLength(1)
}
})
it('迁移完成标记会被持久化,删掉退群通知规则后重启不会再被塞回来', () => {
const store = new AutomationRuleStore(deps(root))
expect(store.deleteRule(BUILTIN_LEAVE_NOTIFICATION_RULE_ID)).toBe(true)
const reopened = new AutomationRuleStore(deps(root))
expect(reopened.getRule(BUILTIN_LEAVE_NOTIFICATION_RULE_ID)).toBeUndefined()
})
it('旧状态文件在迁移后仍留在磁盘上(不做不可逆删除)', () => {
writeLegacyMonitorState({
monitoredRoomIds: ['a@chatroom'],
notificationRoomIds: ['a@chatroom']
})
void new AutomationRuleStore(deps(root))
expect(fs.pathExistsSync(path.join(root, 'group-exit-monitor.json'))).toBe(true)
})
})
})
describe('AutomationExecutionLogService', () => {
@@ -0,0 +1,394 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
chat: {
listContacts: vi.fn(),
getSelfAccountInfo: vi.fn()
}
}))
vi.mock('electron', () => ({ app: { getPath: () => '/tmp/tracememo-automation-group-exit' } }))
vi.mock('../../src/main/services/chat-service', () => mocks.chat)
import {
AutomationService,
leaveNotificationExecutionId,
leaveNotificationIdempotencyKey
} from '../../src/main/services/automation-service'
import { AutomationActionRunner } from '../../src/main/services/automation-action-runner'
import type { AutomationExecutionLogService } from '../../src/main/services/automation-execution-log-service'
import type { AutomationRuleStore } from '../../src/main/services/automation-rule-store'
import {
BUILTIN_LEAVE_NOTIFICATION_RULE_ID,
createDefaultLeaveNotificationRule,
type AutomationExecution,
type AutomationRule,
type LeaveNotificationConfig
} from '../../src/shared/automation'
import type { GroupMemberExitedEvent } from '../../src/shared/group-exit-event'
import type { PersonalWechatSendCapability } from '../../src/shared/personal-wechat'
import type { WechatActionRequest, WechatActionResult } from '../../src/shared/wechat-action'
/**
* 退群事件 → 自动化执行。
*
* 这是迁移后的**唯一发送链路**,所以这里锁的是行为契约而不是实现细节:
* 关闭不执行、四种目标各自解析正确、同一事件只发一次、失败不影响退群事实。
*/
const readyCapability: PersonalWechatSendCapability = {
supported: true,
ready: true,
status: 'ready',
capabilities: { text: true, image: true, voice: true },
senderStatus: {} as never,
message: 'ready'
}
const NOW = 1_700_000_000_000
function exitEvent(overrides: Partial<GroupMemberExitedEvent> = {}): GroupMemberExitedEvent {
return {
eventId: 'room@chatroom:wxid_member:1700000000000:1',
conversationId: 'room@chatroom',
groupName: '产品测试群',
memberId: 'wxid_member',
memberName: '张三',
wechatNickname: '张三',
previousCount: 243,
currentCount: 242,
occurredAt: NOW,
...overrides
}
}
function sentAction(): WechatActionResult {
return {
actionId: 'action-1',
status: 'sent',
decision: 'allow',
startedAt: new Date(NOW).toISOString(),
finishedAt: new Date(NOW).toISOString()
}
}
interface Harness {
service: AutomationService
requests: WechatActionRequest[]
executions: AutomationExecution[]
setRule: (rule: AutomationRule | undefined) => void
setCapability: (capability: PersonalWechatSendCapability) => void
}
function createHarness(
options: {
config?: Partial<LeaveNotificationConfig>
rule?: Partial<AutomationRule>
actionResult?: () => Promise<WechatActionResult> | WechatActionResult
} = {}
): Harness {
const base = createDefaultLeaveNotificationRule(NOW)
let rule: AutomationRule | undefined = {
...base,
...options.rule,
leaveNotification: { ...base.leaveNotification!, ...options.config }
}
let capability: PersonalWechatSendCapability = readyCapability
const requests: WechatActionRequest[] = []
const executions: AutomationExecution[] = []
const ruleStore = {
listRules: () => (rule ? [rule] : []),
getRule: (id: string) => (rule && rule.id === id ? rule : undefined)
} as unknown as AutomationRuleStore
const executionLog = {
record: (execution: AutomationExecution) => {
const index = executions.findIndex((item) => item.executionId === execution.executionId)
if (index >= 0) executions[index] = execution
else executions.push(execution)
},
list: () => executions,
clear: () => true,
countSince: () => ({ total: executions.length, success: 0 })
} as unknown as AutomationExecutionLogService
const runner = new AutomationActionRunner({
executeAction: async (request) => {
requests.push(request)
return options.actionResult ? options.actionResult() : sentAction()
},
now: () => NOW
})
const service = new AutomationService({} as never, {
ruleStore,
executionLog,
runner,
getCapability: async () => capability,
now: () => NOW
})
return {
service,
requests,
executions,
setRule: (next) => {
rule = next
},
setCapability: (next) => {
capability = next
}
}
}
beforeEach(() => {
mocks.chat.listContacts.mockReset().mockReturnValue([
{ m_nsUsrName: 'wxid_friend', m_nsNickName: '好友', remark: '好友备注', type: 'user' },
{ m_nsUsrName: 'room@chatroom', m_nsNickName: '产品测试群', type: 'group' }
])
mocks.chat.getSelfAccountInfo.mockReset().mockReturnValue({ wxid: 'wxid_self' })
})
describe('AutomationService.handleGroupExit', () => {
it('退群事件 → 命中规则 → 解析目标 → Gateway 调用一次 → execution 成功', async () => {
const harness = createHarness({ config: { target: { type: 'file_transfer' } } })
await harness.service.handleGroupExit(exitEvent())
expect(harness.requests).toHaveLength(1)
expect(harness.requests[0]).toMatchObject({
origin: 'automation',
purpose: 'automation_leave_notification',
triggerType: 'automation',
recipient: { type: 'contact', id: 'filehelper', name: '文件传输助手' },
content: { type: 'text' }
})
expect(harness.executions).toHaveLength(1)
expect(harness.executions[0]).toMatchObject({
ruleId: BUILTIN_LEAVE_NOTIFICATION_RULE_ID,
ruleName: '退群通知',
sourceDisplayName: '产品测试群',
status: 'success'
})
// 步骤序列:触发 → 匹配 → 目标 → 发送
expect(harness.executions[0].steps.map((step) => step.key)).toEqual([
'exit_received',
'exit_matched',
'exit_target',
'exit_send'
])
// 用户可见的「目标」是显示名,不是 wxid。
const targetStep = harness.executions[0].steps.find((step) => step.key === 'exit_target')
expect(targetStep?.detail).toBe('文件传输助手')
})
it('规则关闭时不执行、也不创建 execution', async () => {
const harness = createHarness({ rule: { enabled: false } })
await harness.service.handleGroupExit(exitEvent())
expect(harness.requests).toHaveLength(0)
expect(harness.executions).toHaveLength(0)
})
it('目标待重选(迁移遗留)时不执行、也不创建 execution', async () => {
const harness = createHarness({
config: { targetNeedsReview: true, target: { type: 'file_transfer' } }
})
await harness.service.handleGroupExit(exitEvent())
expect(harness.requests).toHaveLength(0)
expect(harness.executions).toHaveLength(0)
})
it('缺少 eventId 时直接跳过,绝不发送', async () => {
const harness = createHarness()
await harness.service.handleGroupExit(exitEvent({ eventId: '' }))
expect(harness.requests).toHaveLength(0)
})
it('当前群聊 → 发送目标是事件所在群', async () => {
const harness = createHarness({ config: { target: { type: 'source_chat' } } })
await harness.service.handleGroupExit(
exitEvent({ conversationId: 'A@chatroom', groupName: 'A 群' })
)
expect(harness.requests[0].recipient).toEqual({
type: 'group',
id: 'A@chatroom',
name: 'A 群'
})
})
it('发给自己 → 发送目标是当前账号 wxid', async () => {
mocks.chat.getSelfAccountInfo.mockReturnValue({ wxid: 'wxid_realme' })
const harness = createHarness({ config: { target: { type: 'self' } } })
await harness.service.handleGroupExit(exitEvent())
expect(harness.requests[0].recipient).toEqual({
type: 'contact',
id: 'wxid_realme',
name: '我'
})
})
it('文件传输助手 → 稳定 identity,而不是昵称', async () => {
const harness = createHarness({ config: { target: { type: 'file_transfer' } } })
await harness.service.handleGroupExit(exitEvent())
expect(harness.requests[0].recipient.id).toBe('filehelper')
expect(harness.requests[0].recipient.id).not.toBe('文件传输助手')
})
it('指定好友 → 发送目标是保存的稳定 id', async () => {
const harness = createHarness({
config: { target: { type: 'contact', contactId: 'wxid_friend' } }
})
await harness.service.handleGroupExit(exitEvent())
expect(harness.requests[0].recipient).toEqual({
type: 'contact',
id: 'wxid_friend',
name: '好友备注'
})
})
/*
* §「旧联系人失效」:不偷偷发给别人,execution 记 failed,Gateway 一次都不调。
*/
it('指定好友已不存在 → execution failed 且不调用 Gateway', async () => {
const harness = createHarness({
config: { target: { type: 'contact', contactId: 'wxid_gone' } }
})
await harness.service.handleGroupExit(exitEvent())
expect(harness.requests).toHaveLength(0)
expect(harness.executions).toHaveLength(1)
expect(harness.executions[0].status).toBe('failed')
expect(harness.executions[0].errorSummary).toContain('已不存在或当前无法发送')
const sendStep = harness.executions[0].steps.find((step) => step.key === 'exit_send')
expect(sendStep?.status).toBe('skipped')
})
it('发送能力缺失 → execution failed(退群事实不受影响)', async () => {
const harness = createHarness()
harness.setCapability({ ...readyCapability, capabilities: { text: false, image: false, voice: false } })
await harness.service.handleGroupExit(exitEvent())
expect(harness.requests).toHaveLength(0)
expect(harness.executions[0].status).toBe('failed')
expect(harness.executions[0].errorSummary).toContain('无法发送文字')
})
it('Gateway 抛错 → execution failed,且 handleGroupExit 自己绝不抛', async () => {
const harness = createHarness({
actionResult: () => {
throw new Error('connector timeout')
}
})
await expect(harness.service.handleGroupExit(exitEvent())).resolves.toBeUndefined()
expect(harness.executions).toHaveLength(1)
expect(harness.executions[0].status).toBe('failed')
})
it('Gateway 返回失败状态 → execution failed', async () => {
const harness = createHarness({
actionResult: () => ({
actionId: 'action-x',
status: 'failed',
decision: 'allow',
errorCode: 'SEND_FAILED',
reason: '微信发送失败',
startedAt: new Date(NOW).toISOString(),
finishedAt: new Date(NOW).toISOString()
})
})
await harness.service.handleGroupExit(exitEvent())
expect(harness.executions[0].status).toBe('failed')
expect(harness.executions[0].errorSummary).toBe('微信发送失败')
})
it('模板里所有变量都被替换(预览与实发共用同一套规则)', async () => {
const harness = createHarness({
config: {
target: { type: 'file_transfer' },
template: '{groupName}|{user}|{groupRemark}|{wxid}|{previousCount}->{currentCount}|{time}'
}
})
await harness.service.handleGroupExit(
exitEvent({ groupName: '产品测试群', groupRemark: '群内备注', wechatNickname: '微信昵称' })
)
const content = harness.requests[0].content
expect(content.type).toBe('text')
if (content.type === 'text') {
// 群名({groupName})与成员群昵称({groupRemark})是两个值,不能混。
expect(content.text).toBe(
'产品测试群|微信昵称|群内备注|wxid_member|243->242|2023-11-15 06:13:20'
)
}
})
it('同一个 eventId 投递两次 → 只发一次、只产生一条 execution', async () => {
const harness = createHarness()
const event = exitEvent()
await harness.service.handleGroupExit(event)
await harness.service.handleGroupExit(event)
expect(harness.requests).toHaveLength(1)
expect(harness.executions).toHaveLength(1)
})
/*
* §「不同事件」:同群两个成员先后退出就是两条通知。
* 退群通知**不复用**消息型规则的 cooldown,否则第二次会被时间窗吞掉。
*/
it('不同 eventId 各自执行一次,不被合并', async () => {
const harness = createHarness()
await harness.service.handleGroupExit(exitEvent({ eventId: 'event-a' }))
await harness.service.handleGroupExit(exitEvent({ eventId: 'event-b' }))
expect(harness.requests).toHaveLength(2)
expect(harness.executions).toHaveLength(2)
expect(new Set(harness.executions.map((item) => item.executionId)).size).toBe(2)
})
it('幂等键与 executionId 都由 eventId 派生(跨重启也稳定)', () => {
const eventId = 'room@chatroom:wxid_member:1700000000000:1'
expect(leaveNotificationIdempotencyKey(eventId)).toBe(
`automation_leave_notification:${eventId}`
)
// 同一个 (ruleId, eventId) 永远算出同一个 executionId —— 重启后重复投递会覆盖同一条记录。
expect(leaveNotificationExecutionId('rule-1', eventId)).toBe(
leaveNotificationExecutionId('rule-1', eventId)
)
expect(leaveNotificationExecutionId('rule-1', eventId)).not.toBe(
leaveNotificationExecutionId('rule-1', 'another-event')
)
})
it('规则不存在或不是退群通知类型时静默跳过', async () => {
const harness = createHarness()
harness.setRule(undefined)
await harness.service.handleGroupExit(exitEvent())
expect(harness.requests).toHaveLength(0)
expect(harness.executions).toHaveLength(0)
})
})
+89 -131
View File
@@ -44,7 +44,7 @@ vi.mock('../../src/main/services/personal-wechat-send-service', () => ({
}))
import { GroupExitMonitorService } from '../../src/main/services/group-exit-monitor-service'
import { GROUP_EXIT_NOTIFICATION_TEMPLATE } from '../../src/shared/group-exit-monitor'
import type { GroupMemberExitedEvent } from '../../src/shared/group-exit-event'
const member = { wxid: 'wxid_member', wechatNickname: '微信名', groupNickname: '群内名' }
const otherMember = { wxid: 'wxid_other', wechatNickname: '另一个人' }
@@ -93,14 +93,12 @@ function writeBaseline(
roomId: string
groupName: string
members: Array<{ wxid: string; [key: string]: string }>
}>,
notificationRoomIds: string[] = []
}>
): void {
writeJsonSync(join(mocks.userData, 'group-exit-monitor.json'), {
accountRoot: 'fixture-account',
events: [],
monitoredRoomIds: snapshots.map((snapshot) => snapshot.roomId),
notificationRoomIds,
snapshots: snapshots.map((snapshot) => ({
contactId: `${snapshot.roomId}-md5`,
roomId: snapshot.roomId,
@@ -135,36 +133,16 @@ describe('GroupExitMonitorService', () => {
}
})
it('persists a validated custom template in the monitor state file', () => {
const service = new GroupExitMonitorService()
const template = '[退群监测]\n用户: {user}'
const state = service.setNotificationTemplate(template)
expect(state.notificationTemplate).toBe(template)
expect(readJsonSync(join(mocks.userData, 'group-exit-monitor.json')).notificationTemplate).toBe(
template
)
expect(() => service.setNotificationTemplate('用户: {unknown}')).toThrow('不支持的占位符')
})
it('migrates the previous default notification template', () => {
writeJsonSync(join(mocks.userData, 'group-exit-monitor.json'), {
notificationTemplate:
'[退群监测]\n\n用户: {user}\n\n群备注: {groupRemark}\n\n微信号: {wxid}\n\n退群时间: {time}'
})
const service = new GroupExitMonitorService()
expect(service.getState().notificationTemplate).toBe(GROUP_EXIT_NOTIFICATION_TEMPLATE)
})
/*
* 模板相关的用例(保存 / 归一化 / 渲染)**已随迁移移出本文件**:
* 模板现在属于自动化规则,由 `leave-notification-*` 那组测试覆盖。
* 退群监控不再持有模板,所以这里没有任何模板可测。
*/
it('persists OFF without checking contact changes or deleting configuration', async () => {
vi.useFakeTimers()
installGroupDb()
writeBaseline(
[{ roomId: 'room@chatroom', groupName: '测试群', members: [member, otherMember] }],
['room@chatroom']
)
writeBaseline([{ roomId: 'room@chatroom', groupName: '测试群', members: [member, otherMember] }])
const service = new GroupExitMonitorService()
await service.setEnabled(false)
await service.start(true)
@@ -176,8 +154,7 @@ describe('GroupExitMonitorService', () => {
const stored = readJsonSync(join(mocks.userData, 'group-exit-monitor.json'))
expect(stored).toMatchObject({
enabled: false,
monitoredRoomIds: ['room@chatroom'],
notificationRoomIds: ['room@chatroom']
monitoredRoomIds: ['room@chatroom']
})
expect(stored.snapshots[0].members).toEqual([member, otherMember])
vi.useRealTimers()
@@ -186,8 +163,7 @@ describe('GroupExitMonitorService', () => {
it('rebuilds the current baseline when re-enabled without reporting paused exits', async () => {
installGroupDb()
writeBaseline(
[{ roomId: 'room@chatroom', groupName: '测试群', members: [member, otherMember] }],
['room@chatroom']
[{ roomId: 'room@chatroom', groupName: '测试群', members: [member, otherMember] }]
)
const service = new GroupExitMonitorService()
await service.setEnabled(false)
@@ -344,33 +320,26 @@ describe('GroupExitMonitorService', () => {
).toMatchObject({ roomId: 'A@chatroom', groupName: '测试群A' })
})
it('uses one resolved group name for both 3-to-1 events and Gateway notification recipients', async () => {
it('uses one resolved group name for both 3-to-1 events and the emitted leave events', async () => {
installGroupDb()
const departed = { wxid: 'wxid_departed', groupNickname: '离群一' }
const secondDeparted = { wxid: 'wxid_second_departed', groupNickname: '离群二' }
writeBaseline(
[
{
roomId: 'A@chatroom',
groupName: 'A@chatroom',
members: [member, departed, secondDeparted]
}
],
['A@chatroom']
)
writeBaseline([
{
roomId: 'A@chatroom',
groupName: 'A@chatroom',
members: [member, departed, secondDeparted]
}
])
mocks.chat.getGroupNamesAsync.mockResolvedValue({ 'A@chatroom': '测试群A' })
mocks.chat.getGroupMemberIdsBatchAsync.mockResolvedValue([
{ roomId: 'A@chatroom', status: 'ok', memberIds: [member.wxid] }
])
const requests: Array<{ recipient?: { name?: string } }> = []
const actionGateway = {
execute: vi.fn(async (request: { recipient?: { name?: string } }) => {
requests.push(request)
return { status: 'sent', actionId: 'action-1', decision: 'allow' }
}),
registerMemberEvent: vi.fn()
}
const service = new GroupExitMonitorService({ actionGateway })
const emitted: GroupMemberExitedEvent[] = []
const service = new GroupExitMonitorService()
service.setGroupExitHandler((event) => {
emitted.push(event)
})
await service.start(true)
@@ -393,8 +362,10 @@ describe('GroupExitMonitorService', () => {
})
])
)
expect(requests).toHaveLength(2)
expect(requests.every((request) => request.recipient?.name === '测试群A')).toBe(true)
// 事件里的群名与存储里的一致 —— 通知目标要显示的就是这个名字。
expect(emitted).toHaveLength(2)
expect(emitted.every((event) => event.groupName === '测试群A')).toBe(true)
expect(emitted.every((event) => event.conversationId === 'A@chatroom')).toBe(true)
expect(mocks.chat.getGroupSnapshotAsync).not.toHaveBeenCalled()
})
@@ -559,15 +530,18 @@ describe('GroupExitMonitorService', () => {
warn.mockRestore()
})
it('keeps snapshots when only notification settings change', async () => {
it('keeps snapshots when the monitor scope is re-saved', async () => {
writeBaseline([{ roomId: 'room@chatroom', groupName: '测试群', members: [member] }])
const service = new GroupExitMonitorService()
service.getState()
await service.setMonitoredRoomIds(['room@chatroom'], ['room@chatroom'])
await service.setMonitoredRoomIds(['room@chatroom'])
const stored = readJsonSync(join(mocks.userData, 'group-exit-monitor.json'))
expect(stored.notificationRoomIds).toEqual(['room@chatroom'])
expect(stored.monitoredRoomIds).toEqual(['room@chatroom'])
// 通知配置已经不在这个文件里了 —— 迁到自动化规则后不允许双写。
expect(stored.notificationRoomIds).toBeUndefined()
expect(stored.notificationTemplate).toBeUndefined()
expect(stored.snapshots).toHaveLength(1)
expect(stored.snapshots[0].members).toEqual([member])
})
@@ -756,33 +730,50 @@ describe('GroupExitMonitorService', () => {
vi.useRealTimers()
})
it('renders the saved template before sending a group notification', async () => {
/**
* 迁移后的分工:**退群监控只产生事件,发送完全归自动化**。
*
* 它不再读发送能力、不再调 `WechatActionGateway`、也不在事件上记发送状态。
*/
it('hands the exit event to automation and never touches the send path itself', async () => {
installGroupDb()
writeBaseline(
[{ roomId: 'room@chatroom', groupName: '测试群', members: [member, otherMember] }],
['room@chatroom']
)
mocks.capability.getPersonalWechatSendCapability.mockResolvedValue({
ready: true,
capabilities: { text: true }
})
mocks.sender.send.mockResolvedValue({ success: true })
writeBaseline([
{ roomId: 'room@chatroom', groupName: '测试群', members: [member, otherMember] }
])
mocks.chat.getGroupMemberIdsBatchAsync.mockResolvedValue([
{ roomId: 'room@chatroom', status: 'ok', memberIds: [member.wxid] }
])
const emitted: GroupMemberExitedEvent[] = []
const service = new GroupExitMonitorService()
service.setNotificationTemplate('退群: {user}/{groupRemark}/{wxid}')
await service.start(true)
expect(mocks.sender.send).toHaveBeenCalledWith({
type: 'text',
to: 'room@chatroom',
isGroup: true,
text: '退群: 另一个人/未设置/wxid_other'
service.setGroupExitHandler((event) => {
emitted.push(event)
})
await service.start(true)
const state = service.getState()
expect(state.events[0]).toMatchObject({ memberWxid: 'wxid_other' })
// 事件上不再有发送状态字段(发送结果归自动化执行日志)。
expect(state.events[0]).not.toHaveProperty('notificationStatus')
expect(state.events[0]).not.toHaveProperty('notification')
expect(emitted).toHaveLength(1)
expect(emitted[0]).toMatchObject({
eventId: state.events[0].id,
conversationId: 'room@chatroom',
memberId: 'wxid_other',
previousCount: 2,
currentCount: 1
})
// 监控自己不发送、也不去问发送能力。
expect(mocks.sender.send).not.toHaveBeenCalled()
expect(mocks.capability.getPersonalWechatSendCapability).not.toHaveBeenCalled()
})
it('records an exit without creating a send action when notification is disabled', async () => {
/*
* 处理器抛错**不能**污染监控循环:退群事实已经落盘,
* 通知失败是自动化那边的事(§「退群事实已检测成功 ≠ 通知发送成功」)。
* 未捕获的 rejection 也会让整个 diff 循环挂掉。
*/
it('keeps recording exits when the automation handler rejects', async () => {
installGroupDb()
writeBaseline([
{ roomId: 'room@chatroom', groupName: '测试群', members: [member, otherMember] }
@@ -791,72 +782,39 @@ describe('GroupExitMonitorService', () => {
{ roomId: 'room@chatroom', status: 'ok', memberIds: [member.wxid] }
])
const service = new GroupExitMonitorService()
await service.start(true)
const state = service.getState()
expect(state.events[0]).toMatchObject({
memberWxid: 'wxid_other',
notificationStatus: 'not_requested'
service.setGroupExitHandler(async () => {
throw new Error('通知发送失败')
})
expect(mocks.sender.send).not.toHaveBeenCalled()
expect(mocks.capability.getPersonalWechatSendCapability).not.toHaveBeenCalled()
await service.start(true)
// 让 handler 的 rejection 落地。
await Promise.resolve()
const state = service.getState()
expect(state.events).toHaveLength(1)
expect(state.events[0]).toMatchObject({ memberWxid: 'wxid_other' })
expect(state.lastCheckedAt).toBeDefined()
})
it('微信发送能力不可用时仍保留退群事件', async () => {
it('does not re-emit the same snapshot diff when checked again', async () => {
installGroupDb()
writeBaseline(
[{ roomId: 'room@chatroom', groupName: '测试群', members: [member, otherMember] }],
['room@chatroom']
)
mocks.capability.getPersonalWechatSendCapability.mockResolvedValue({
ready: false,
supported: true,
status: 'needs_binding',
capabilities: { text: false, image: false, voice: false },
senderStatus: {},
message: '当前微信发送能力不可用'
})
writeBaseline([
{ roomId: 'room@chatroom', groupName: '测试群', members: [member, otherMember] }
])
mocks.chat.getGroupMemberIdsBatchAsync.mockResolvedValue([
{ roomId: 'room@chatroom', status: 'ok', memberIds: [member.wxid] }
])
const emitted: GroupMemberExitedEvent[] = []
const service = new GroupExitMonitorService()
await service.start(true)
const state = service.getState()
expect(state.events[0]).toMatchObject({
notificationStatus: 'failed',
notification: {
status: 'failed',
errorCode: 'SEND_CAPABILITY_UNAVAILABLE'
}
service.setGroupExitHandler((event) => {
emitted.push(event)
})
expect(mocks.sender.send).not.toHaveBeenCalled()
})
it('does not send twice when the same snapshot diff is checked again', async () => {
installGroupDb()
writeBaseline(
[{ roomId: 'room@chatroom', groupName: '测试群', members: [member, otherMember] }],
['room@chatroom']
)
mocks.capability.getPersonalWechatSendCapability.mockResolvedValue({
ready: true,
capabilities: { text: true, image: false, voice: false },
supported: true,
status: 'ready',
senderStatus: {},
message: 'ready'
})
mocks.sender.send.mockResolvedValue({ success: true })
mocks.chat.getGroupMemberIdsBatchAsync.mockResolvedValue([
{ roomId: 'room@chatroom', status: 'ok', memberIds: [member.wxid] }
])
const service = new GroupExitMonitorService()
await service.start(true)
await service.checkNow()
await service.checkNow()
expect(mocks.sender.send).toHaveBeenCalledOnce()
// 快照已更新 ⇒ 后续检查没有新的 diff,也就不会重复投递事件。
expect(emitted).toHaveLength(1)
expect(service.getState().events).toHaveLength(1)
})
+133 -2
View File
@@ -1,7 +1,9 @@
import { describe, expect, it } from 'vitest'
import {
GROUP_EXIT_NOTIFICATION_TEMPLATE,
findRemovedGroupMembers,
groupExitMemberName,
insertGroupNamePlaceholder,
normalizeGroupExitNotificationTemplate,
renderGroupExitMonitorNotification,
validateGroupExitNotificationTemplate
@@ -62,6 +64,8 @@ describe('group exit monitor member diff', () => {
})
expect(message).toContain('[退群监测]')
// 群名是**独立变量**,且默认模板第一行就是它 —— 通知发给别人时才认得出是哪个群。
expect(message).toContain('群聊: 测试群')
expect(message).toContain('用户: 微信名')
expect(message).toContain('群备注: 未设置')
expect(message).toContain('微信号: wxid_fixture')
@@ -69,11 +73,69 @@ describe('group exit monitor member diff', () => {
expect(message).toContain('退群时间: 2026-08-31 00:19:07')
})
/*
* 回归:`{groupName}`(群名)与 `{groupRemark}`(成员在本群的昵称)是两个不同的东西。
*
* 起因是预览样本曾把群名填进 `groupRemark`,导致「群备注」看起来就是群名,
* 于是没人发现模板里**根本没有群名变量** —— 通知发给好友时收件人不知道是哪个群。
* 这条用例把两者钉死成不同的值。
*/
it('keeps the group name and the member group nickname as two separate variables', () => {
const message = renderGroupExitMonitorNotification(
{
id: 'event-3',
contactId: 'group-md5',
roomId: '测试群@chatroom',
groupName: '产品测试群',
memberWxid: 'wxid_fixture',
memberName: '群昵称',
wechatName: '微信名',
groupRemark: '小张',
previousCount: 3,
currentCount: 2,
delta: -1,
message: '小张退出了产品测试群',
detectedAt: new Date(2026, 7, 31, 0, 19, 7).getTime()
},
'{groupName} / {groupRemark}'
)
expect(message).toBe('产品测试群 / 小张')
expect(message).not.toContain('{')
})
it('falls back to 未读取到 when the group name is unavailable', () => {
const message = renderGroupExitMonitorNotification(
{
id: 'event-4',
contactId: 'group-md5',
roomId: '测试群@chatroom',
groupName: '',
memberWxid: 'wxid_fixture',
memberName: '群昵称',
groupRemark: '',
previousCount: 2,
currentCount: 1,
delta: -1,
message: '群昵称退出了测试群',
detectedAt: new Date(2026, 7, 31, 0, 19, 7).getTime()
},
'群聊: {groupName}'
)
expect(message).toBe('群聊: 未读取到')
})
it('validates custom notification templates and rejects unknown placeholders', () => {
expect(validateGroupExitNotificationTemplate(' 用户: {user} ')).toEqual({
valid: true,
template: '用户: {user}'
})
// 新增的群名变量必须被校验通过,否则用户存不下来。
expect(validateGroupExitNotificationTemplate('群聊: {groupName}')).toEqual({
valid: true,
template: '群聊: {groupName}'
})
expect(validateGroupExitNotificationTemplate('用户: {unknown}')).toEqual({
valid: false,
error: '不支持的占位符: unknown'
@@ -98,8 +160,77 @@ describe('group exit monitor member diff', () => {
message: '群昵称退出了测试群',
detectedAt: new Date(2026, 7, 31, 0, 19, 7).getTime()
},
'{user}|{groupRemark}|{wxid}|{previousCount}|{currentCount}|{time}'
'{groupName}|{user}|{groupRemark}|{wxid}|{previousCount}|{currentCount}|{time}'
)
expect(message).toBe('微信名|群内昵称|wxid_fixture|2|1|2026-08-31 00:19:07')
expect(message).toBe('测试群|微信名|群内昵称|wxid_fixture|2|1|2026-08-31 00:19:07')
})
})
describe('把群聊名补进已有模板', () => {
/** 加上群名变量之前的默认模板(有「人数」行、没有「群聊」行)。 */
const previousDefault = [
'[退群监测]',
'',
'用户: {user}',
'',
'群备注: {groupRemark}',
'',
'微信号: {wxid}',
'',
'人数: {previousCount} -> {currentCount}',
'',
'退群时间: {time}'
].join('\n')
it('插在标题行之后,其余内容一字不动', () => {
const result = insertGroupNamePlaceholder(previousDefault)
expect(result).toBe(
[
'[退群监测]',
'',
'群聊: {groupName}',
'',
'用户: {user}',
'',
'群备注: {groupRemark}',
'',
'微信号: {wxid}',
'',
'人数: {previousCount} -> {currentCount}',
'',
'退群时间: {time}'
].join('\n')
)
// 用户原有内容全部保留。
for (const line of previousDefault.split('\n')) {
if (line.trim()) expect(result).toContain(line)
}
})
it('补完就是当前的默认模板形状', () => {
expect(insertGroupNamePlaceholder(previousDefault)).toBe(GROUP_EXIT_NOTIFICATION_TEMPLATE)
})
it('已经含 {groupName} 时原样返回(幂等)', () => {
const template = '[退群监测]\n\n群聊: {groupName}\n\n用户: {user}'
expect(insertGroupNamePlaceholder(template)).toBe(template)
// 连续补两次结果不变。
expect(insertGroupNamePlaceholder(insertGroupNamePlaceholder(previousDefault))).toBe(
insertGroupNamePlaceholder(previousDefault)
)
})
it('没有标题行时插到最前面', () => {
expect(insertGroupNamePlaceholder('用户: {user}')).toBe('群聊: {groupName}\n\n用户: {user}')
})
it('空模板原样返回,不硬造内容', () => {
expect(insertGroupNamePlaceholder('')).toBe('')
expect(insertGroupNamePlaceholder(' ')).toBe(' ')
})
it('不会留下连续空行', () => {
expect(insertGroupNamePlaceholder('[退群监测]\n\n\n\n用户: {user}')).not.toMatch(/\n{3,}/)
})
})
@@ -0,0 +1,222 @@
import { describe, expect, it } from 'vitest'
import {
filterSendableFriendContacts,
isSendableFriendContact,
leaveNotificationContactDisplayName,
resolveLeaveNotificationTarget,
type LeaveNotificationCandidateContact
} from '../../src/main/services/leave-notification-target'
import type { GroupMemberExitedEvent } from '../../src/shared/group-exit-event'
import { WECHAT_FILE_HELPER_USERNAME } from '../../src/shared/wechat-identities'
import type { LeaveNotificationConfig } from '../../src/shared/automation'
/**
* 退群通知的**目标解析**。
*
* 这是整轮迁移里最不能出错的一处:解析错就等于发错人,而发出去的消息撤不回来。
* 所以这里把四种目标、失效、不可发送、自身身份缺失全部穷举。
*/
const EVENT: GroupMemberExitedEvent = {
eventId: 'room@chatroom:wxid_member:1700000000000:1',
conversationId: 'room@chatroom',
groupName: '产品测试群',
groupRemark: '',
memberId: 'wxid_member',
memberName: '张三',
previousCount: 243,
currentCount: 242,
occurredAt: 1_700_000_000_000
}
const FRIEND: LeaveNotificationCandidateContact = {
m_nsUsrName: 'wxid_friend',
m_nsNickName: '好友昵称',
remark: '好友备注',
type: 'user'
}
const contacts: LeaveNotificationCandidateContact[] = [
FRIEND,
{ m_nsUsrName: 'room@chatroom', m_nsNickName: '测试群', type: 'group' },
{ m_nsUsrName: 'gh_official', m_nsNickName: '某公众号', type: 'user', isOfficialAccount: true },
{ m_nsUsrName: WECHAT_FILE_HELPER_USERNAME, m_nsNickName: '文件传输助手', type: 'user' },
{ m_nsUsrName: 'wxid_self', m_nsNickName: '我自己', type: 'user' }
]
function resolve(
config: LeaveNotificationConfig,
overrides: { selfWxid?: string; event?: GroupMemberExitedEvent } = {}
) {
return resolveLeaveNotificationTarget({
config,
event: overrides.event ?? EVENT,
contacts,
selfWxid: overrides.selfWxid ?? 'wxid_self'
})
}
describe('leaveNotificationTarget · 四种目标', () => {
it('当前群聊 = 事件所在群,不是"最后活跃会话"', () => {
const result = resolve({ target: { type: 'source_chat' }, template: '' })
expect(result).toEqual({
ok: true,
target: {
recipient: { type: 'group', id: 'room@chatroom', name: '产品测试群' },
displayName: '产品测试群'
}
})
})
it('当前群聊在群名缺失时用中性称呼,不回落成 roomId', () => {
const result = resolveLeaveNotificationTarget({
config: { target: { type: 'source_chat' }, template: '' },
event: { ...EVENT, groupName: undefined },
contacts,
selfWxid: 'wxid_self'
})
expect(result.ok).toBe(true)
if (result.ok) {
expect(result.target.recipient.name).toBe('当前群聊')
expect(result.target.displayName).toBe('发生退群事件的群聊')
}
})
it('当前群聊在缺少 conversationId 时直接失败', () => {
const result = resolveLeaveNotificationTarget({
config: { target: { type: 'source_chat' }, template: '' },
event: { ...EVENT, conversationId: '' },
contacts,
selfWxid: 'wxid_self'
})
expect(result.ok).toBe(false)
})
it('发给自己 = 当前登录账号的真实 wxid', () => {
const result = resolve({ target: { type: 'self' }, template: '' }, { selfWxid: 'wxid_me' })
expect(result).toEqual({
ok: true,
target: {
recipient: { type: 'contact', id: 'wxid_me', name: '我' },
displayName: '我'
}
})
})
/*
* 拿不到自身身份时必须**报 blocker**,绝不偷偷 fallback 到文件传输助手 ——
* 那会让用户以为"发给自己"生效了,实际消息去了别处。
*/
it('拿不到自身身份时失败,且不 fallback 文件传输助手', () => {
const result = resolve({ target: { type: 'self' }, template: '' }, { selfWxid: '' })
expect(result.ok).toBe(false)
if (!result.ok) {
expect(result.error).toContain('当前登录的微信账号')
expect(result.error).not.toContain('文件传输助手')
}
})
it('文件传输助手用协议级固定身份,不靠昵称搜索', () => {
const result = resolve({ target: { type: 'file_transfer' }, template: '' })
expect(result.ok).toBe(true)
if (result.ok) {
expect(result.target.recipient).toEqual({
type: 'contact',
id: WECHAT_FILE_HELPER_USERNAME,
name: '文件传输助手'
})
}
})
it('指定好友用稳定 id,显示名用备注', () => {
const result = resolve({
target: { type: 'contact', contactId: 'wxid_friend' },
template: ''
})
expect(result).toEqual({
ok: true,
target: {
recipient: { type: 'contact', id: 'wxid_friend', name: '好友备注' },
displayName: '好友备注'
}
})
})
})
describe('leaveNotificationTarget · 失效与非法', () => {
it('联系人不在了 → 失败,且不往别处发', () => {
const result = resolve({ target: { type: 'contact', contactId: 'wxid_gone' }, template: '' })
expect(result.ok).toBe(false)
if (!result.ok) expect(result.error).toContain('已不存在或当前无法发送')
})
it('还没选好友 → 失败并提示重新选择', () => {
const result = resolve({ target: { type: 'contact' }, template: '' })
expect(result.ok).toBe(false)
if (!result.ok) expect(result.error).toContain('还没有选择通知联系人')
})
it('把群 / 公众号 / 文件传输助手 / 自己填进「指定好友」也会被拒', () => {
for (const contactId of [
'room@chatroom',
'gh_official',
WECHAT_FILE_HELPER_USERNAME,
'wxid_self'
]) {
const result = resolve({ target: { type: 'contact', contactId }, template: '' })
expect(result.ok).toBe(false)
}
})
it('未配置配置项 → 失败', () => {
expect(resolveLeaveNotificationTarget({
config: undefined,
event: EVENT,
contacts,
selfWxid: 'wxid_self'
}).ok).toBe(false)
})
it('未知目标类型 → 失败(不允许静默当成默认值)', () => {
const result = resolve({
target: { type: 'webhook' as never },
template: ''
})
expect(result.ok).toBe(false)
})
})
describe('leaveNotificationTarget · 可选好友过滤', () => {
it('排除群 / 公众号 / 文件传输助手 / 自己', () => {
const sendable = filterSendableFriendContacts(contacts, 'wxid_self').map(
(contact) => contact.m_nsUsrName
)
expect(sendable).toEqual(['wxid_friend'])
})
it('UI 选择器与运行时判定共用同一个函数', () => {
for (const contact of contacts) {
expect(isSendableFriendContact(contact, 'wxid_self')).toBe(
filterSendableFriendContacts([contact], 'wxid_self').length === 1
)
}
})
it('显示名依次回落到备注 → 微信昵称 → 会话昵称 → 中性称呼,永不出现 wxid', () => {
expect(leaveNotificationContactDisplayName(FRIEND)).toBe('好友备注')
expect(
leaveNotificationContactDisplayName({ m_nsUsrName: 'wxid_x', type: 'user' })
).toBe('指定好友')
})
})
+81 -57
View File
@@ -51,26 +51,34 @@ describe('WechatActionGateway', () => {
return new WechatActionGateway({ getUserDataPath: () => userData })
}
function memberAction(
/**
* 退群通知 —— 迁移后由 **Automation** 发出(purpose `automation_leave_notification`)。
*
* 幂等键由 eventId 派生,与 `AutomationService.leaveNotificationIdempotencyKey` 同口径;
* 收件人**不再被锁死在事件所在群**(可以发给自己 / 文件传输助手 / 指定好友)。
*/
function leaveNotificationAction(
gateway: WechatActionGateway,
roomId = 'room@chatroom'
recipient: WechatActionRequest['recipient'] = { type: 'group', id: 'room@chatroom' },
eventId = 'event-1'
): Promise<WechatActionResult> {
gateway.registerMemberEvent({ id: 'event-1', roomId: 'room@chatroom' })
return gateway.execute({
origin: 'member_monitor',
purpose: 'member_left_notification',
idempotencyKey: `automation_leave_notification:${eventId}`,
origin: 'automation',
purpose: 'automation_leave_notification',
triggerType: 'automation',
sourceId: 'event-1',
recipient: { type: 'group', id: roomId },
executionId: `exec-${eventId}`,
sourceId: eventId,
recipient,
content: { type: 'text', text: '张三已退出群聊' }
})
}
it('allows a valid member action, sends once, and writes an audit record', async () => {
it('sends a leave notification once and writes an audit record', async () => {
const userData = mkdtempSync(join(tmpdir(), 'tracememo-wechat-action-'))
directories.push(userData)
const gateway = new WechatActionGateway({ getUserDataPath: () => userData })
const result = await memberAction(gateway)
const result = await leaveNotificationAction(gateway)
expect(result).toMatchObject({ status: 'sent', decision: 'allow' })
expect(mocks.sender.send).toHaveBeenCalledOnce()
@@ -83,7 +91,7 @@ describe('WechatActionGateway', () => {
const audit = readJsonSync(join(userData, 'actions', 'wechat-actions.json'))
expect(audit).toEqual([
expect.objectContaining({
purpose: 'member_left_notification',
purpose: 'automation_leave_notification',
recipientId: 'room@chatroom',
sendStatus: 'sent',
decision: 'allow',
@@ -92,23 +100,70 @@ describe('WechatActionGateway', () => {
])
})
it('deduplicates the same member event across repeated execution calls', async () => {
it('deduplicates the same leave event across repeated execution calls', async () => {
const gateway = createGateway()
const first = await memberAction(gateway)
const second = await memberAction(gateway)
const first = await leaveNotificationAction(gateway)
const second = await leaveNotificationAction(gateway)
expect(second.actionId).toBe(first.actionId)
expect(mocks.sender.send).toHaveBeenCalledOnce()
})
it('blocks a recipient outside the source group', async () => {
/**
* 迁移的核心:**不再有「只能发回原群」的作用域锁**。
* 四种目标必须都能发出去,否则「自己 / 文件传输助手 / 指定好友」形同虚设。
*/
it('allows every leave-notification recipient, not just the source group', async () => {
// 四条自动化发送串行且彼此间隔 3 秒 —— 用假定时器推过去,别真的等 9 秒。
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-09-24T10:00:00.000Z'))
const gateway = createGateway()
const result = await memberAction(gateway, 'other@chatroom')
const targets: Array<{ to: string; isGroup: boolean }> = []
mocks.sender.send.mockImplementation(async (request: { to: string; isGroup: boolean }) => {
targets.push({ to: request.to, isGroup: request.isGroup })
return { success: true, status: {} }
})
const pending = [
leaveNotificationAction(gateway, { type: 'group', id: 'room@chatroom' }, 'event-1'),
leaveNotificationAction(gateway, { type: 'contact', id: 'wxid_self' }, 'event-2'),
leaveNotificationAction(gateway, { type: 'contact', id: 'filehelper' }, 'event-3'),
leaveNotificationAction(gateway, { type: 'contact', id: 'wxid_friend' }, 'event-4')
]
await vi.advanceTimersByTimeAsync(AUTOMATION_SEND_INTERVAL_MS * 4)
const results = await Promise.all(pending)
for (const result of results) {
expect(result).toMatchObject({ status: 'sent', decision: 'allow' })
}
expect(targets).toEqual([
{ to: 'room@chatroom', isGroup: true },
{ to: 'wxid_self', isGroup: false },
{ to: 'filehelper', isGroup: false },
{ to: 'wxid_friend', isGroup: false }
])
vi.useRealTimers()
})
/**
* 回归闸门:旧的发送用途**已经在代码上不可能再发出去**。
* 退群监控不再产生它,allowlist 也把它摘了 —— 谁要是把它加回来,这里会红。
*/
it('blocks the legacy member_left_notification purpose', async () => {
const gateway = createGateway()
const result = await gateway.execute({
origin: 'member_monitor',
purpose: 'member_left_notification',
triggerType: 'automation',
sourceId: 'event-1',
recipient: { type: 'group', id: 'room@chatroom' },
content: { type: 'text', text: '张三已退出群聊' }
})
expect(result).toMatchObject({
status: 'blocked',
decision: 'block',
errorCode: 'RECIPIENT_SCOPE_VIOLATION'
errorCode: 'ACTION_NOT_ALLOWED'
})
expect(mocks.sender.send).not.toHaveBeenCalled()
})
@@ -135,8 +190,8 @@ describe('WechatActionGateway', () => {
it('returns INVALID_REQUEST for malformed input without throwing from audit handling', async () => {
const gateway = createGateway()
const result = await gateway.execute({
origin: 'member_monitor',
purpose: 'member_left_notification',
origin: 'automation',
purpose: 'automation_leave_notification',
triggerType: 'automation',
recipient: { type: 'group', id: 'room@chatroom' }
} as never)
@@ -151,8 +206,8 @@ describe('WechatActionGateway', () => {
it('returns INVALID_RECIPIENT when the recipient id is missing', async () => {
const gateway = createGateway()
const result = await gateway.execute({
origin: 'member_monitor',
purpose: 'member_left_notification',
origin: 'automation',
purpose: 'automation_leave_notification',
triggerType: 'automation',
sourceId: 'event-1',
recipient: { type: 'group', id: '' },
@@ -167,30 +222,6 @@ describe('WechatActionGateway', () => {
expect(mocks.sender.send).not.toHaveBeenCalled()
})
it('does not accept an event lookup for a different source id', async () => {
const userData = mkdtempSync(join(tmpdir(), 'tracememo-wechat-action-'))
directories.push(userData)
const gateway = new WechatActionGateway({
getUserDataPath: () => userData,
getMemberEvent: () => ({ id: 'different-event', roomId: 'room@chatroom' })
})
const result = await gateway.execute({
origin: 'member_monitor',
purpose: 'member_left_notification',
triggerType: 'automation',
sourceId: 'event-1',
recipient: { type: 'group', id: 'room@chatroom' },
content: { type: 'text', text: '张三已退出群聊' }
})
expect(result).toMatchObject({
status: 'blocked',
decision: 'block',
errorCode: 'INVALID_REQUEST'
})
expect(mocks.sender.send).not.toHaveBeenCalled()
})
it('returns a structured capability failure without sending', async () => {
const gateway = createGateway()
mocks.capability.getPersonalWechatSendCapability.mockResolvedValueOnce({
@@ -199,7 +230,7 @@ describe('WechatActionGateway', () => {
capabilities: { text: false, image: false, voice: false },
message: '当前微信发送能力不可用'
})
const result = await memberAction(gateway)
const result = await leaveNotificationAction(gateway)
expect(result).toMatchObject({
status: 'failed',
@@ -226,7 +257,7 @@ describe('WechatActionGateway', () => {
it('converts a transport throw into SEND_FAILED', async () => {
const gateway = createGateway()
mocks.sender.send.mockRejectedValueOnce(new Error('connector timeout'))
const result = await memberAction(gateway)
const result = await leaveNotificationAction(gateway)
expect(result).toMatchObject({
status: 'failed',
@@ -287,17 +318,10 @@ describe('WechatActionGateway', () => {
if (sentAt.length === 1) throw new Error('first failed')
return { success: true, status: {} }
})
const first = memberAction(gateway)
const first = leaveNotificationAction(gateway)
await vi.advanceTimersByTimeAsync(0)
gateway.registerMemberEvent({ id: 'event-2', roomId: 'room@chatroom' })
const second = gateway.execute({
origin: 'member_monitor',
purpose: 'member_left_notification',
triggerType: 'automation',
sourceId: 'event-2',
recipient: { type: 'group', id: 'room@chatroom' },
content: { type: 'text', text: '李四已退出群聊' }
})
// 第二条用不同的 eventId → 不同的幂等键,才会真的进队列。
const second = leaveNotificationAction(gateway, { type: 'group', id: 'room@chatroom' }, 'event-2')
await vi.advanceTimersByTimeAsync(AUTOMATION_SEND_INTERVAL_MS)
const results = await Promise.all([first, second])
@@ -407,7 +431,7 @@ describe('WechatActionGateway', () => {
.mockResolvedValueOnce({ ...readyCapability, ready: false })
.mockResolvedValueOnce(readyCapability)
await expect(memberAction(gateway)).resolves.toMatchObject({
await expect(leaveNotificationAction(gateway)).resolves.toMatchObject({
status: 'failed',
errorCode: 'SEND_CAPABILITY_UNAVAILABLE'
})