mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-05 21:05:39 +08:00
feat: 退群监控接入自动化能力统一管理, 支持发送个人/文件传输助手/指定群聊
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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('指定好友')
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user