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
+82 -18
View File
@@ -113,6 +113,10 @@ import { agentHubService } from './services/agent-hub-service'
import { WechatConnectorService } from './services/wechat-ilink'
import { wechatSendGateway } from './services/wechat-send-gateway'
import { groupExitMonitorService } from './services/group-exit-monitor-service'
import {
filterSendableFriendContacts,
leaveNotificationContactDisplayName
} from './services/leave-notification-target'
import { MessageListenerService } from './services/message-listener-service'
import { automationRuleStore } from './services/automation-rule-store'
import { automationExecutionLogService } from './services/automation-execution-log-service'
@@ -1043,6 +1047,32 @@ app.whenReady().then(async () => {
*/
let automationListening = false
initAutomationService(wcdb4Client, { isListening: () => automationListening })
/**
* 退群通知的唯一通路:**退群监控只负责产生事件,自动化负责发送**。
*
* `handleGroupExit` 自己吞掉异常,这里再兜一层 `.catch` 是防御性的 ——
* 未捕获的 rejection 会污染监控循环,而退群事实早就已经记录成功了。
*/
groupExitMonitorService.setGroupExitHandler((event) => {
try {
return getAutomationService()
.handleGroupExit(event)
.catch((error) => {
console.warn(
`[Automation] handleGroupExit failed: ${
error instanceof Error ? error.message : String(error)
}`
)
})
} catch (error) {
console.warn(
`[Automation] 退群通知未初始化,已跳过本次事件: ${
error instanceof Error ? error.message : String(error)
}`
)
return undefined
}
})
messageListener.onMessage((message) => {
// 日志只允许出现「类型 / 群或私聊 / 是否自己发 / @ 数量」这类不可逆标识,
// 不含 wxid、昵称、群名、正文与 source。
@@ -1623,16 +1653,14 @@ app.whenReady().then(async () => {
ipcMain.handle('group-exit-monitor:setEnabled', (_, enabled: boolean) =>
groupExitMonitorService.setEnabled(enabled === true)
)
ipcMain.handle(
'group-exit-monitor:setGroups',
(_, roomIds: string[], notificationRoomIds?: string[]) =>
groupExitMonitorService.setMonitoredRoomIds(
Array.isArray(roomIds) ? roomIds : [],
Array.isArray(notificationRoomIds) ? notificationRoomIds : []
)
)
ipcMain.handle('group-exit-monitor:setTemplate', (_, template: unknown) =>
groupExitMonitorService.setNotificationTemplate(template)
/**
* 保存**监控范围**。
*
* 只有一个参数:旧版第二个参数(通知群聊)已经迁到「自动化 → 退群通知」,
* 退群监控不再持有任何通知配置。
*/
ipcMain.handle('group-exit-monitor:setGroups', (_, roomIds: string[]) =>
groupExitMonitorService.setMonitoredRoomIds(Array.isArray(roomIds) ? roomIds : [])
)
ipcMain.handle('group-exit-monitor:checkNow', () => groupExitMonitorService.checkNow())
/**
@@ -1655,9 +1683,6 @@ app.whenReady().then(async () => {
})
})
ipcMain.handle('group-exit-monitor:clearEvents', () => groupExitMonitorService.clearEvents())
ipcMain.handle('group-exit-monitor:resendEvent', (_, eventId: string) =>
groupExitMonitorService.resendEvent(eventId)
)
ipcMain.handle('group-exit-monitor:markRead', (_, readAt?: number) =>
groupExitMonitorService.markRead(readAt)
)
@@ -1698,6 +1723,35 @@ app.whenReady().then(async () => {
})
ipcMain.handle('automation:clearExecutions', () => automationExecutionLogService.clear())
ipcMain.handle('automation:listGroups', () => tryAutomationService()?.listGroups() ?? [])
/**
* 保存「退群通知」规则(singleton upsert)。
*
* 单独一个通道而不是复用 `updateRule`:这条规则是系统规则,
* 保存语义是"存在即更新、不存在即创建",且 id 固定 —— 不允许渲染层自己拼 id。
*/
ipcMain.handle('automation:saveLeaveNotificationRule', (_, draft: unknown) =>
automationRuleStore.saveLeaveNotificationRule(draft)
)
/**
* 「指定好友」的可选项。
*
* 过滤(群聊 / 公众号 / 文件传输助手 / 自己)在 main 侧完成 ——
* 只有这里知道当前登录账号是谁,渲染层再筛一遍必然会漂移。
*/
ipcMain.handle('automation:listSendableContacts', () => {
try {
const selfWxid = String(chat.getSelfAccountInfo()?.wxid || '')
return filterSendableFriendContacts(chat.listContacts(), selfWxid).map((contact) => ({
id: String(contact.m_nsUsrName || ''),
name: leaveNotificationContactDisplayName(contact)
}))
} catch (error) {
console.warn(
`[Automation] 读取可选联系人失败: ${error instanceof Error ? error.message : String(error)}`
)
return []
}
})
ipcMain.handle('automation:getStatus', async () => {
const service = tryAutomationService()
if (service) return service.getStatus()
@@ -2715,11 +2769,21 @@ app.on('before-quit', (event) => {
chat.closeChatDbForQuit().catch(() => false),
voiceRecognition?.dispose().catch(() => undefined),
knowledgeSearchService?.dispose().catch(() => undefined),
process.platform === 'darwin'
? macWechatRuntimeManager.shutdown().catch((error) => {
console.warn('[Shutdown] macOS native WeChat runtime cleanup failed:', error)
})
: Promise.resolve()
/*
* Deliberately do NOT stop the macOS native runtime here.
*
* The host is designed to outlive TraceMemo ("保留发送能力进程"): keeping
* it alive preserves the bound frida session, so relaunching the app —
* including every dev-mode restart — re-adopts it and works immediately
* instead of forcing the user to bind WeChat again.
*
* Stopping it on quit threw that away and, worse, gave teardown a chance
* to hang mid-unload while the app was already exiting. The host has its
* own lifecycle instead: it exits itself once WeChat is gone ("stale" ->
* self-shutdown), and it can be restarted explicitly from the settings
* card ("重新加载组件").
*/
Promise.resolve()
])
if (!nativeCallsDrained) {
console.warn('[Shutdown] WCDB async calls did not fully drain before quit')
+126 -1
View File
@@ -8,8 +8,13 @@ import {
type AutomationAction,
type AutomationRule,
type AutomationStep,
type AutomationStepKey
type AutomationStepKey,
type LeaveNotificationConfig
} from '../../shared/automation'
import {
renderLeaveNotificationText,
type GroupMemberExitedEvent
} from '../../shared/group-exit-event'
import type { WechatActionContent, WechatActionRequest, WechatActionResult } from '../../shared/wechat-action'
import {
generateAgentGroupReport,
@@ -17,6 +22,7 @@ import {
type AgentGroupReportResult
} from './agent-group-report-service'
import { wechatActionGateway } from './wechat-action-gateway'
import type { LeaveNotificationTargetResolution } from './leave-notification-target'
/**
* AutomationActionRunner —— 把一次命中跑成**步骤序列**。
@@ -32,6 +38,14 @@ import { wechatActionGateway } from './wechat-action-gateway'
/** 一步执行完毕后,后续步骤的处置方式。 */
const STEP_ORDER: AutomationStepKey[] = ['received', 'matched', 'reply', 'report', 'send']
/** 退群通知的步骤顺序(与消息型规则**不共用**)。 */
const LEAVE_NOTIFICATION_STEP_ORDER: AutomationStepKey[] = [
'exit_received',
'exit_matched',
'exit_target',
'exit_send'
]
/**
* 前置步骤失败时,后续步骤的 `skipReason`。
*
@@ -65,6 +79,30 @@ export interface AutomationRunResult {
pngPath?: string
}
/**
* 退群通知的执行输入。
*
* 目标解析与发送能力预检都由 `AutomationService` 在调用前完成,
* 这里只负责"把它跑成步骤序列" —— 于是**所有步骤构造只在一处**,
* 不会出现"服务拼一半、runner 拼一半"的裂口。
*/
export interface LeaveNotificationRunInput {
executionId: string
event: GroupMemberExitedEvent
config: LeaveNotificationConfig
resolution: LeaveNotificationTargetResolution
/** 事件来源的显示名(群名 / 「群聊」)。**不含 wxid**。 */
sourceDisplayName: string
/** 发送能力缺失时的一句话说明;有值时 `exit_send` 直接判失败。 */
sendBlockedReason?: string
}
export interface LeaveNotificationRunResult {
steps: AutomationStep[]
status: 'success' | 'failed'
errorSummary?: string
}
export interface AutomationActionRunnerDependencies {
generateReport?: (request: AgentGroupReportRequest) => Promise<AgentGroupReportResult>
executeAction?: (request: WechatActionRequest) => Promise<WechatActionResult>
@@ -235,6 +273,93 @@ export class AutomationActionRunner {
return { steps, status: 'success', pngPath }
}
/**
* 退群通知:把「检测到成员退出」这件事跑成一次发送。
*
* 与 `run()` 的差别:没有动作链、没有回复等待、没有 cooldown
* (退群是低频事件,且**不能被时间窗合并** —— 两个成员先后退出就是两条通知)。
*/
async runLeaveNotification(input: LeaveNotificationRunInput): Promise<LeaveNotificationRunResult> {
const steps = LEAVE_NOTIFICATION_STEP_ORDER.map((key) => createStep(key))
const stepAt = (key: AutomationStepKey): AutomationStep =>
steps.find((step) => step.key === key) as AutomationStep
markSuccess(stepAt('exit_received'), this.now())
markSuccess(stepAt('exit_matched'), this.now())
// ---- 目标解析失败:不许 fallback 到任何地方,直接判失败 ----
if (!input.resolution.ok) {
markFailed(stepAt('exit_target'), this.now(), input.resolution.error)
markSkipped(stepAt('exit_send'), '没有可用的通知目标,未发送。')
return { steps, status: 'failed', errorSummary: input.resolution.error }
}
const targetStep = stepAt('exit_target')
targetStep.status = 'success'
targetStep.startedAt = this.now()
targetStep.finishedAt = targetStep.startedAt
targetStep.durationMs = 0
// 用户可读的目标名(例如「文件传输助手」「张三」「我」)—— 不带任何 id。
targetStep.detail = input.resolution.target.displayName
const sendStep = stepAt('exit_send')
// ---- 发送能力缺失:如实判失败(退群事实本身仍然是成功的) ----
if (input.sendBlockedReason) {
markFailed(sendStep, this.now(), input.sendBlockedReason)
return { steps, status: 'failed', errorSummary: input.sendBlockedReason }
}
const text = renderLeaveNotificationText(input.event, input.config.template).trim()
if (!text) {
const error = '通知内容为空,未发送。'
markFailed(sendStep, this.now(), error)
return { steps, status: 'failed', errorSummary: error }
}
sendStep.status = 'running'
sendStep.startedAt = this.now()
const sent = await this.sendLeaveNotification(input, text)
if (!sent.ok) {
markFailed(sendStep, this.now(), sent.error || '发送退群通知失败')
return { steps, status: 'failed', errorSummary: sendStep.error }
}
markSuccess(sendStep, this.now())
return { steps, status: 'success' }
}
/**
* 退群通知的统一发送出口。
*
* **必须走 `WechatActionGateway`**:幂等 + 3 秒发送间隔 + 审计落盘都在那里,
* 直接调个人微信发送会绕过全部三样。Automation 层也不允许知道
* OneBot / WCHook / native host / Windows hook 的存在。
*
* 幂等键由 **eventId** 派生(不是 executionId):审计是落盘的,
* 于是"重启后重复投递同一退群事件"会被持久层直接短路。
*/
private async sendLeaveNotification(
input: LeaveNotificationRunInput,
text: string
): Promise<{ ok: boolean; error?: string }> {
if (!input.resolution.ok) return { ok: false, error: input.resolution.error }
try {
const result = await this.executeAction({
idempotencyKey: `${AUTOMATION_SEND_PURPOSE.leaveNotification}:${input.event.eventId}`,
origin: AUTOMATION_SEND_ORIGIN,
purpose: AUTOMATION_SEND_PURPOSE.leaveNotification,
triggerType: 'automation',
executionId: input.executionId,
sourceId: input.event.eventId,
recipient: input.resolution.target.recipient,
content: { type: 'text', text }
})
if (result.status === 'sent') return { ok: true }
return { ok: false, error: actionErrorMessage(result.errorCode, result.reason) }
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : String(error) }
}
}
/**
* 统一发送出口。
*
+272 -9
View File
@@ -3,10 +3,22 @@ import path from 'node:path'
import { app } from 'electron'
import fs from 'fs-extra'
import {
BUILTIN_LEAVE_NOTIFICATION_RULE_ID,
createDefaultDailyReportRule,
createDefaultLeaveNotificationRule,
normalizeRuleDraft,
type AutomationRule
type AutomationRule,
type AutomationRuleType
} from '../../shared/automation'
import {
planLeaveNotificationMigration,
type LeaveNotificationMigrationPlan,
type LegacyLeaveNotificationState
} from './leave-notification-migration'
import {
GROUP_EXIT_NOTIFICATION_TEMPLATE_MAX_LENGTH,
insertGroupNamePlaceholder
} from '../../shared/group-exit-monitor'
/**
* AutomationRuleStore —— 自动化规则的持久化与增删改查。
@@ -17,15 +29,31 @@ import {
* **内置规则的播种标记(`builtinSeeded`)单独存**:
* 如果靠"文件里有没有那条内置规则"来判断是否播种,用户一旦删掉它,
* 下次启动就会被重新塞回来 —— 用户会认为删除功能坏了。
*
* 退群通知的迁移标记(`leaveNotificationMigrated`)同理:迁移**只允许跑一次**,
* 否则用户删掉退群通知规则后重启又被塞回来。
*/
const STORAGE_DIR = 'automation'
const RULES_FILE = 'rules.json'
const MIGRATION_BACKUP_FILE = 'leave-notification-migration-backup.json'
/** 旧退群监控的状态文件(迁移时读一次,之后运行期不再读)。 */
const LEGACY_MONITOR_FILE = 'group-exit-monitor.json'
const CURRENT_VERSION = 3
interface StoredRules {
version: number
/** 内置规则是否已经播种过。**即使随后被删除也保持 true**。 */
/** 内置「@我生成日报」是否已经播种过。**即使随后被删除也保持 true**。 */
builtinSeeded: boolean
/** 旧退群通知配置是否已经迁移过。**即使随后被删除也保持 true**。 */
leaveNotificationMigrated: boolean
/**
* 是否已经替用户往模板里补过 `{groupName}`。
*
* **必须有这个标记**:没有它就得靠"模板里有没有 `{groupName}`"来判断,
* 于是用户主动删掉那一行后、下次启动又会被补回来 —— 删不掉的东西最烦人。
*/
notificationTemplateUpgraded: boolean
rules: AutomationRule[]
}
@@ -35,22 +63,95 @@ export interface AutomationRuleStoreDependencies {
}
function emptyState(): StoredRules {
return { version: 1, builtinSeeded: false, rules: [] }
return {
version: CURRENT_VERSION,
builtinSeeded: false,
leaveNotificationMigrated: false,
notificationTemplateUpgraded: false,
rules: []
}
}
/** 读盘容错:任何字段可疑都降级成安全值,绝不因为一个坏文件让功能整体不可用。 */
function normalizeStored(value: unknown): StoredRules {
function normalizeRuleType(value: unknown): AutomationRuleType {
return value === 'leave_notification' ? 'leave_notification' : 'daily_report'
}
/**
* 单条规则的读盘归一化。
*
* 走 `normalizeRuleDraft` —— 它覆盖了 `AutomationRule` 除 id / 时间戳之外的**全部**字段,
* 所以这是无损的,同时自动补上历史 rules.json 缺失的 `ruleType` / `leaveNotification`。
*/
function normalizeStoredRule(value: unknown): AutomationRule | null {
if (!value || typeof value !== 'object') return null
const raw = value as Partial<AutomationRule>
const id = String(raw.id || '').trim()
if (!id) return null
const ruleType = normalizeRuleType(raw.ruleType)
const normalized = normalizeRuleDraft(
{
...raw,
ruleType,
// 历史 daily_report 规则没有 leaveNotification;反过来也要避免脏字段落盘。
...(ruleType === 'leave_notification' ? { leaveNotification: raw.leaveNotification } : {})
},
String(raw.name || '').trim() || '未命名自动化'
)
const createdAt = Number(raw.createdAt) || 0
const updatedAt = Number(raw.updatedAt) || createdAt
return {
...normalized,
id,
createdAt,
updatedAt,
...(normalized.ruleType === 'leave_notification' && normalized.leaveNotification
? { leaveNotification: normalized.leaveNotification }
: {})
}
}
/**
* 一次性替用户把 `{groupName}` 补进退群通知模板。
*
* **为什么替他填**:模板里原先根本没有群名变量(`{groupRemark}` 是成员在本群的昵称)。
* 目标一旦不是「当前群聊」,通知就等于「张三退群了」—— 收件人不知道是哪个群。
* 模板是用户已保存的数据,让他自己去发现"少一个变量"是不合理的。
*
* **为什么只跑一次**:靠"模板里有没有 `{groupName}`"判断会导致用户删掉后被反复补回来。
* 所以由 `notificationTemplateUpgraded` 标记保证一次性;用户之后删掉不会回来。
*/
function upgradeLeaveNotificationTemplate(rules: AutomationRule[]): {
rules: AutomationRule[]
count: number
} {
let count = 0
const next = rules.map((rule) => {
if (rule.ruleType !== 'leave_notification' || !rule.leaveNotification) return rule
const current = rule.leaveNotification.template
const upgraded = insertGroupNamePlaceholder(current)
// 补完不能超长:超了下次读盘会被校验拦下、整条模板被重置成默认,反而更糟。
if (upgraded === current || upgraded.length > GROUP_EXIT_NOTIFICATION_TEMPLATE_MAX_LENGTH) {
return rule
}
count += 1
return { ...rule, leaveNotification: { ...rule.leaveNotification, template: upgraded } }
})
return { rules: next, count }
}
/** 读盘容错:任何字段可疑都降级成安全值,绝不因为一个坏文件让功能整体不可用。 */function normalizeStored(value: unknown): StoredRules {
if (!value || typeof value !== 'object') return emptyState()
const input = value as Partial<StoredRules>
const rules = Array.isArray(input.rules)
? input.rules.filter(
(rule): rule is AutomationRule =>
Boolean(rule && typeof rule === 'object' && String(rule.id || '').trim())
)
? input.rules
.map((rule) => normalizeStoredRule(rule))
.filter((rule): rule is AutomationRule => rule !== null)
: []
return {
version: Number(input.version) || 1,
builtinSeeded: input.builtinSeeded === true,
leaveNotificationMigrated: input.leaveNotificationMigrated === true,
notificationTemplateUpgraded: input.notificationTemplateUpgraded === true,
rules
}
}
@@ -60,6 +161,8 @@ export class AutomationRuleStore {
private readonly now: () => number
private state: StoredRules = emptyState()
private loaded = false
/** 迁移结果,供启动日志/报告读取(不含任何 id)。 */
private lastMigrationPlan: LeaveNotificationMigrationPlan | null = null
constructor(dependencies: AutomationRuleStoreDependencies = {}) {
this.userDataPath = dependencies.userDataPath ?? (() => app.getPath('userData'))
@@ -78,6 +181,12 @@ export class AutomationRuleStore {
return found
}
/** 上一次迁移的判定结果(未迁移时为 null)。 */
getLastLeaveNotificationMigration(): LeaveNotificationMigrationPlan | null {
this.ensureLoaded()
return this.lastMigrationPlan ? { ...this.lastMigrationPlan } : null
}
createRule(draft: unknown): AutomationRule {
this.ensureLoaded()
const timestamp = this.now()
@@ -105,14 +214,51 @@ export class AutomationRuleStore {
...current,
...normalized,
id: current.id,
// ruleType 不允许被草稿改掉:它是规则的**身份**,不是可编辑字段。
ruleType: current.ruleType,
...(current.ruleType === 'leave_notification' && normalized.leaveNotification
? { leaveNotification: normalized.leaveNotification }
: {}),
createdAt: current.createdAt,
updatedAt: this.now()
}
// 切类型时不能留下另一种类型才认识的字段。
if (rule.ruleType === 'daily_report') delete rule.leaveNotification
this.state.rules = this.state.rules.map((item, at) => (at === index ? rule : item))
this.persist()
return structuredClone(rule)
}
/**
* 保存「退群通知」规则(**singleton upsert**)。
*
* 不存在则创建(固定 id),存在则更新 —— 所以点多少次保存都只有一条规则。
*/
saveLeaveNotificationRule(draft: unknown): AutomationRule {
this.ensureLoaded()
const existing = this.state.rules.find(
(rule) => rule.id === BUILTIN_LEAVE_NOTIFICATION_RULE_ID
)
if (existing) {
const updated = this.updateRule(existing.id, { ...(draft as object), ruleType: 'leave_notification' })
if (updated) return updated
}
const timestamp = this.now()
const normalized = normalizeRuleDraft({ ...(draft as object), ruleType: 'leave_notification' })
const rule: AutomationRule = {
...createDefaultLeaveNotificationRule(timestamp),
...normalized,
id: BUILTIN_LEAVE_NOTIFICATION_RULE_ID,
ruleType: 'leave_notification',
name: normalized.name || '退群通知',
createdAt: timestamp,
updatedAt: timestamp
}
this.state.rules = [...this.state.rules, rule]
this.persist()
return structuredClone(rule)
}
deleteRule(id: string): boolean {
this.ensureLoaded()
const key = String(id || '').trim()
@@ -142,10 +288,123 @@ export class AutomationRuleStore {
stored.builtinSeeded = true
stored.rules = [...stored.rules, createDefaultDailyReportRule(this.now())]
}
if (!stored.leaveNotificationMigrated) {
stored.leaveNotificationMigrated = true
stored.rules = this.migrateLeaveNotification(stored.rules)
}
if (!stored.notificationTemplateUpgraded) {
stored.notificationTemplateUpgraded = true
const upgraded = upgradeLeaveNotificationTemplate(stored.rules)
stored.rules = upgraded.rules
// 只记条数,**不记模板正文**。
if (upgraded.count > 0) {
console.log(`[Automation] 退群通知模板已补上群名变量(${upgraded.count} 条)`)
}
}
stored.version = CURRENT_VERSION
this.state = stored
this.persist()
}
/** 旧退群通知配置 → 内置退群通知规则。**只跑一次**,且先备份旧配置。 */
private migrateLeaveNotification(rules: AutomationRule[]): AutomationRule[] {
if (rules.some((rule) => rule.id === BUILTIN_LEAVE_NOTIFICATION_RULE_ID)) return rules
const timestamp = this.now()
const legacy = this.readLegacyLeaveNotificationState()
if (!legacy) {
// 全新安装:没有历史可迁,直接建默认规则(默认目标:文件传输助手)。
this.logMigration('fresh_install', null)
return [...rules, createDefaultLeaveNotificationRule(timestamp)]
}
const plan = planLeaveNotificationMigration(legacy)
this.lastMigrationPlan = plan
this.writeMigrationBackup(legacy, plan)
this.logMigration(plan.outcome, plan)
return [
...rules,
createDefaultLeaveNotificationRule(timestamp, {
template: plan.template,
target: plan.target,
enabled: plan.enabled,
targetNeedsReview: plan.targetNeedsReview
})
]
}
/**
* 读旧退群监控状态文件里的通知相关字段。
*
* 只读、只在这里读一次;运行期其余代码**不再读** legacy(避免双读)。
* 读失败视为"没有历史配置",不抛。
*/
private readLegacyLeaveNotificationState(): LegacyLeaveNotificationState | null {
try {
const raw = fs.readJsonSync(
path.join(this.userDataPath(), LEGACY_MONITOR_FILE)
) as Partial<LegacyLeaveNotificationState>
if (!raw || typeof raw !== 'object') return null
return {
monitoredRoomIds: Array.isArray(raw.monitoredRoomIds) ? raw.monitoredRoomIds : [],
notificationRoomIds: Array.isArray(raw.notificationRoomIds) ? raw.notificationRoomIds : [],
...(raw.notificationTemplate !== undefined
? { notificationTemplate: raw.notificationTemplate }
: {})
}
} catch {
return null
}
}
/**
* 迁移前把旧配置原样落一份 backup。
*
* §「不要不可逆直接覆盖」:旧状态文件本身也**不删**,只是不再被运行时代码读取。
*/
private writeMigrationBackup(
legacy: LegacyLeaveNotificationState,
plan: LeaveNotificationMigrationPlan
): void {
try {
const filePath = path.join(this.userDataPath(), STORAGE_DIR, MIGRATION_BACKUP_FILE)
fs.ensureDirSync(path.dirname(filePath))
fs.writeJsonSync(
filePath,
{
migratedAt: this.now(),
outcome: plan.outcome,
// 备份里保留原始 roomId:这是**用户数据**,不是日志,不进任何用户可见界面。
legacy: {
monitoredRoomIds: legacy.monitoredRoomIds,
notificationRoomIds: legacy.notificationRoomIds,
notificationTemplate: legacy.notificationTemplate
},
applied: {
enabled: plan.enabled,
targetType: plan.target.type,
targetNeedsReview: plan.targetNeedsReview
}
},
{ spaces: 2 }
)
} catch (error) {
console.warn(
`[Automation] 写入退群通知迁移备份失败: ${errorText(error)}`
)
}
}
/** 迁移日志:只允许出现判定结果与布尔量,**禁止** roomId / 模板正文。 */
private logMigration(outcome: string, plan: LeaveNotificationMigrationPlan | null): void {
if (!plan) {
console.log(`[Automation] 退群通知迁移 outcome=${outcome}`)
return
}
console.log(
`[Automation] 退群通知迁移 outcome=${outcome} enabled=${plan.enabled}` +
` targetType=${plan.target.type} needsReview=${plan.targetNeedsReview}`
)
}
private rulesFilePath(): string {
return path.join(this.userDataPath(), STORAGE_DIR, RULES_FILE)
}
@@ -163,4 +422,8 @@ export class AutomationRuleStore {
}
}
function errorText(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
export const automationRuleStore = new AutomationRuleStore()
+194 -8
View File
@@ -1,18 +1,25 @@
import { randomUUID } from 'node:crypto'
import { createHash, randomUUID } from 'node:crypto'
import {
matchAutomationRule,
type AutomationMatchInput
} from '../../shared/automation-matcher'
import type {
AutomationExecution,
AutomationRule,
AutomationStatusSummary
import {
AUTOMATION_SEND_PURPOSE,
BUILTIN_LEAVE_NOTIFICATION_RULE_ID,
type AutomationExecution,
type AutomationRule,
type AutomationStatusSummary
} from '../../shared/automation'
import type { GroupMemberExitedEvent } from '../../shared/group-exit-event'
import type { PersonalWechatSendCapability } from '../../shared/personal-wechat'
import type { Wcdb4Client } from '../wcdb4-client'
import type { NormalizedIncomingMessage } from './message-listener-service'
import { listContacts } from './chat-service'
import { getSelfAccountInfo, listContacts } from './chat-service'
import { getPersonalWechatSendCapability } from './personal-wechat-capability-service'
import {
resolveLeaveNotificationTarget,
type LeaveNotificationTargetResolution
} from './leave-notification-target'
import {
automationRuleStore,
type AutomationRuleStore
@@ -80,6 +87,37 @@ const CLAIM_MAX_ENTRIES = 5_000
/** 自己的 username 候选集缓存时长 —— 这个值几乎不变,没必要每条消息都查。 */
const SELF_USERNAME_TTL_MS = 30_000
/**
* 退群事件幂等表的容量上限。
*
* 退群是低频事件,这里不需要 MessageListener 那种 5000 级容量;
* 但同样要有界,避免长跑期间无界增长。
*/
const EXIT_CLAIM_MAX_ENTRIES = 1_000
/**
* 由 `ruleId + eventId` 推出**确定性** executionId。
*
* 为什么要确定而不是 `randomUUID()`:幂等必须跨进程重启成立。
* 同一 eventId 重新投递时算出同一个 executionId,`ExecutionLog.record()` 按 id 覆盖,
* 于是"同一事件只产生一条 execution"在重启后依然成立。
*/
export function leaveNotificationExecutionId(ruleId: string, eventId: string): string {
return createHash('sha1')
.update(`leave_notification\u0001${ruleId}\u0001${eventId}`)
.digest('hex')
}
/**
* 退群通知的发送幂等键。
*
* **必须由 eventId 派生**(而不是 executionId 派生):gateway 的审计是落盘的,
* 用 eventId 才能让"重启后重复投递同一事件"被持久层直接短路掉。
*/
export function leaveNotificationIdempotencyKey(eventId: string): string {
return `${AUTOMATION_SEND_PURPOSE.leaveNotification}:${eventId}`
}
export interface AutomationServiceDependencies {
ruleStore?: AutomationRuleStore
executionLog?: AutomationExecutionLogService
@@ -106,6 +144,13 @@ export class AutomationService {
/** `ruleId:sessionId:localId` → 登记时间。 */
private readonly claims = new Map<string, ClaimEntry>()
/**
* 退群事件幂等表:`ruleId:eventId` → 登记时间。
*
* 与消息型 `claims` 分开:退群事件没有 `localId` 这个概念,
* 硬塞进同一张表会让两边的 key 语义混在一起。
*/
private readonly exitClaims = new Map<string, ClaimEntry>()
/**
* Automation rule ↔ conversation gate。
*
@@ -247,8 +292,149 @@ export class AutomationService {
}
}
/** 「在哪些聊天生效」的可选项。`id` 必须是 `xxx@chatroom`,与 message.sessionId 对齐。 */
listGroups(): Array<{ id: string; name: string }> {
/**
* 退群事件入口。**绝不抛** —— 它挂在 `GroupExitMonitorService` 的回调上,
* 抛出去会污染监控循环(退群事实已经记录成功,不能被通知失败连累)。
*
* 与 `handleMessage` 的分工:
* - 消息型规则:MessageListener → handleMessage → 动作链;
* - 退群通知:GroupExitMonitor → **本入口** → 单次文本发送。
*
* 三层闸门(缺一不可):
* 1. `enabled === false` → 不执行,**且不创建 execution**(§「没有执行:不创建」);
* 2. `targetNeedsReview` → 迁移过来但目标无法无损映射,同样不执行、不创建记录;
* 3. `ruleId + eventId` 幂等 → 同一事件即使重复投递也只发送一次。
*/
async handleGroupExit(event: GroupMemberExitedEvent): Promise<void> {
const eventId = String(event?.eventId || '').trim()
let rule: AutomationRule | undefined
try {
rule = this.ruleStore.getRule(BUILTIN_LEAVE_NOTIFICATION_RULE_ID)
} catch (error) {
this.warn(`读取退群通知规则失败: ${errorText(error)}`)
return
}
if (!rule || rule.ruleType !== 'leave_notification' || !rule.leaveNotification) return
if (!eventId) {
this.warn('退群事件缺少事件 id,已跳过本次通知')
return
}
// ① 关闭:不执行、不创建失败记录。
if (!rule.enabled) return
// ② 迁移遗留的"目标待重选":同样不算一次执行,由 UI 提示用户处理。
if (rule.leaveNotification.targetNeedsReview) {
this.warn(`退群通知目标待重新选择,已跳过本次通知 ruleId=${rule.id}`)
return
}
// ③ 幂等(内存快路径)。跨重启那一层由确定性 executionId + gateway 审计兜住。
if (!this.claimGroupExit(rule.id, eventId)) return
const executionId = leaveNotificationExecutionId(rule.id, eventId)
const startedAt = this.now()
const sourceDisplayName = String(event.groupName || '').trim() || '群聊'
// 目标解析与发送能力预检都放在 runner 之外,runner 只负责"跑成步骤"。
const resolution = this.resolveLeaveTarget(rule, event)
const sendBlockedReason = await this.leaveNotificationCapabilityError()
let result: Awaited<ReturnType<AutomationActionRunner['runLeaveNotification']>>
try {
result = await this.runner.runLeaveNotification({
executionId,
event,
config: rule.leaveNotification,
resolution,
sourceDisplayName,
...(sendBlockedReason ? { sendBlockedReason } : {})
})
} catch (error) {
result = {
steps: [],
status: 'failed',
errorSummary: `执行过程异常:${errorText(error)}`
}
}
this.executionLog.record(
this.buildExecution({
executionId,
rule,
startedAt,
sourceDisplayName,
status: result.status,
durationMs: this.now() - startedAt,
steps: result.steps,
...(result.errorSummary ? { errorSummary: result.errorSummary } : {})
})
)
// 隐私红线:日志只允许 ruleId / executionId / 状态 / 耗时 / 步骤状态与目标类型。
// 群名、昵称、wxid、模板正文一律不进日志。
const trail = result.steps.map((step) => `${step.key}=${step.status}`).join(' ')
this.info(
`executed ruleId=${rule.id} executionId=${executionId} status=${result.status}` +
` durationMs=${this.now() - startedAt}${trail ? ` ${trail}` : ''}`
)
}
/** 解析退群通知目标(联系人清单 / 自身身份都取自既有能力,不另建一套)。 */
private resolveLeaveTarget(
rule: AutomationRule,
event: GroupMemberExitedEvent
): LeaveNotificationTargetResolution {
let contacts: ReturnType<typeof listContacts> = []
try {
contacts = listContacts()
} catch (error) {
this.warn(`读取联系人失败: ${errorText(error)}`)
}
let selfWxid = ''
try {
selfWxid = String(getSelfAccountInfo()?.wxid || '')
} catch (error) {
this.warn(`读取当前账号失败: ${errorText(error)}`)
}
return resolveLeaveNotificationTarget({
config: rule.leaveNotification,
event,
contacts,
selfWxid
})
}
/** 退群通知只需要文字能力。缺失时给出用户能看懂的一句说明。 */
private async leaveNotificationCapabilityError(): Promise<string | undefined> {
let capability: PersonalWechatSendCapability | null = null
try {
capability = await this.getCapability()
} catch {
return '暂时无法获知微信发送能力,本次退群通知未发送。'
}
if (!capability?.supported) {
return capability?.message || '当前系统不支持微信消息发送'
}
if (!capability.capabilities?.text) {
return '当前环境无法发送文字,本次退群通知未发送。'
}
return undefined
}
/** 退群事件幂等登记(原子 check + 写入,中间无 await)。 */
private claimGroupExit(ruleId: string, eventId: string): boolean {
const key = `${ruleId}:${eventId}`
const now = this.now()
if (this.exitClaims.has(key)) return false
this.exitClaims.set(key, { at: now })
if (this.exitClaims.size > EXIT_CLAIM_MAX_ENTRIES) {
for (const [entryKey, entry] of this.exitClaims) {
if (this.exitClaims.size <= EXIT_CLAIM_MAX_ENTRIES) break
if (now - entry.at > CLAIM_TTL_MS) this.exitClaims.delete(entryKey)
}
}
return true
}
/** 「在哪些聊天生效」的可选项。`id` 必须是 `xxx@chatroom`,与 message.sessionId 对齐。 */ listGroups(): Array<{ id: string; name: string }> {
try {
return listContacts()
.filter((contact) => contact.type === 'group' || contact.m_nsUsrName?.endsWith('@chatroom'))
+71 -164
View File
@@ -2,19 +2,17 @@ import { app, BrowserWindow } from 'electron'
import fs from 'fs-extra'
import path from 'path'
import * as chat from './chat-service'
import { wechatActionGateway, type WechatActionGateway } from './wechat-action-gateway'
import {
buildMemberLeftNotification,
findRemovedGroupMembers,
groupExitMemberName,
normalizeGroupExitNotificationTemplate,
validateGroupExitNotificationTemplate,
type GroupExitMonitorEvent,
type GroupExitMonitorMember,
type GroupExitNotificationStatus,
type GroupExitNotificationState,
type GroupExitMonitorState
} from '../../shared/group-exit-monitor'
import {
toGroupMemberExitedEvent,
type GroupMemberExitedEvent
} from '../../shared/group-exit-event'
type StoredState = {
enabled?: boolean
@@ -23,9 +21,14 @@ type StoredState = {
lastReadAt?: number
monitorSelectionConfigured?: boolean
monitoredRoomIds?: string[]
notificationRoomIds?: string[]
notificationTemplate?: unknown
snapshots?: Partial<StoredGroupSnapshot>[]
/*
* 历史遗留字段(`notificationRoomIds` / `notificationTemplate`)**刻意不再声明**。
*
* 它们已经迁到「自动化 → 退群通知」规则里,本服务运行期不再读、不再写。
* 旧状态文件原样保留在磁盘上(迁移时另有一份 backup),只是没有任何读取方 ——
* 这是"不双读"的落地方式。迁移逻辑在 `automation-rule-store.ts`。
*/
}
type GroupSnapshotRecord = {
@@ -67,17 +70,20 @@ const DUPLICATE_WINDOW_MS = 2 * 60 * 1000
*/
const MAX_EVENTS = 500
/**
* 退群事件处理器。
*
* 由主进程注入成 `automationService.handleGroupExit` —— 本服务**不再自己发送**。
* 契约:**必须自己吞掉异常**(实现方负责),本服务也会再兜一层。
*/
export type GroupExitEventHandler = (event: GroupMemberExitedEvent) => void | Promise<void>
export interface GroupExitMonitorServiceDependencies {
actionGateway?: GroupExitActionGateway
onGroupExit?: GroupExitEventHandler
}
type GroupExitActionGateway = Pick<WechatActionGateway, 'execute'> &
Partial<
Pick<WechatActionGateway, 'registerMemberEvent' | 'registerMemberEvents' | 'clearMemberEvents'>
>
class GroupExitMonitorService {
private readonly actionGateway: GroupExitActionGateway
private onGroupExit: GroupExitEventHandler | undefined
private active = false
private enabled = true
private nativeMonitorActive = false
@@ -95,7 +101,6 @@ class GroupExitMonitorService {
private events: GroupExitMonitorEvent[] = []
private monitorSelectionConfigured = true
private monitoredRoomIds = new Set<string>()
private notificationRoomIds = new Set<string>()
private loaded = false
private accountRoot = ''
private groupNamesByRoomId = new Map<string, string>()
@@ -103,10 +108,19 @@ class GroupExitMonitorService {
private eventSequence = 0
private scopeGeneration = 0
private legacyFallbackLogged = false
private notificationTemplate = normalizeGroupExitNotificationTemplate(undefined)
constructor(deps: GroupExitMonitorServiceDependencies = {}) {
this.actionGateway = deps.actionGateway || wechatActionGateway
this.onGroupExit = deps.onGroupExit
}
/**
* 注入退群事件处理器(主进程在 AutomationService 初始化后调用)。
*
* 用 setter 而不是构造参数:AutomationService 依赖 `Wcdb4Client`,
* 要等数据库解锁后才存在,而本服务是模块级单例。
*/
setGroupExitHandler(handler: GroupExitEventHandler | undefined): void {
this.onGroupExit = handler
}
getState(): GroupExitMonitorState {
@@ -122,8 +136,6 @@ class GroupExitMonitorService {
monitoredGroupCount: this.snapshots.size,
monitorSelectionConfigured: this.monitorSelectionConfigured,
monitoredRoomIds: Array.from(this.monitoredRoomIds),
notificationRoomIds: Array.from(this.notificationRoomIds),
notificationTemplate: this.notificationTemplate,
lastCheckedAt: this.lastCheckedAt,
lastReadAt: this.lastReadAt,
unreadCount: this.events.filter((event) => event.detectedAt > this.lastReadAt).length
@@ -143,13 +155,11 @@ class GroupExitMonitorService {
(this.accountRoot && !sameAccountRoot(currentRoot, this.accountRoot)) ||
(!this.accountRoot && this.snapshots.size > 0)
) {
this.actionGateway.clearMemberEvents?.()
this.events = []
this.rewriteEventsToDisk([])
this.lastReadAt = 0
this.monitorSelectionConfigured = true
this.monitoredRoomIds.clear()
this.notificationRoomIds.clear()
this.snapshots.clear()
this.groupNamesByRoomId.clear()
}
@@ -220,10 +230,13 @@ class GroupExitMonitorService {
return this.getState()
}
async setMonitoredRoomIds(
roomIds: string[],
notificationRoomIds: string[] = []
): Promise<GroupExitMonitorState> {
/**
* 保存**监控范围**。
*
* 参数只剩监控目标 —— 旧版第二个参数(通知群聊)已经迁到自动化规则里,
* 本服务不再持有任何通知配置。
*/
async setMonitoredRoomIds(roomIds: string[]): Promise<GroupExitMonitorState> {
this.ensureLoaded()
this.eventSequence += 1
this.scopeGeneration += 1
@@ -237,10 +250,6 @@ class GroupExitMonitorService {
}
this.monitoredRoomIds = nextMonitoredRoomIds
this.groupNamesRefreshPending = true
const requestedNotifications = normalizeRoomIds(notificationRoomIds)
this.notificationRoomIds = new Set(
Array.from(requestedNotifications).filter((roomId) => this.monitoredRoomIds.has(roomId))
)
this.lastCheckedAt = undefined
this.save()
this.broadcast()
@@ -273,18 +282,6 @@ class GroupExitMonitorService {
return this.getState()
}
setNotificationTemplate(value: unknown): GroupExitMonitorState {
this.ensureLoaded()
const result = validateGroupExitNotificationTemplate(value)
if (!result.valid || !result.template) {
throw new Error(result.error || '退群监测模板无效')
}
this.notificationTemplate = result.template
this.save()
this.broadcast()
return this.getState()
}
/**
* 按群 / 时间范围查退群事件(档案合并展示用)。
*
@@ -320,7 +317,6 @@ class GroupExitMonitorService {
this.events = []
// 磁盘上的 append-only 历史也要清掉,否则下次启动又读回来了。
this.rewriteEventsToDisk([])
this.actionGateway.clearMemberEvents?.()
this.lastReadAt = Date.now()
this.save()
this.broadcast()
@@ -443,7 +439,7 @@ class GroupExitMonitorService {
groups: GroupMembershipRecord[],
scopeGeneration: number
): Promise<number> {
const notifications: Array<{ group: GroupSnapshotRecord; event: GroupExitMonitorEvent }> = []
const exits: GroupExitMonitorEvent[] = []
let changedGroups = 0
for (const membership of groups) {
if (!this.enabled || !this.active || scopeGeneration !== this.scopeGeneration) {
@@ -474,10 +470,9 @@ class GroupExitMonitorService {
if (previous && next.members.length < previous.members.length) {
const removed = findRemovedGroupMembers(previous.members, next.members)
for (const member of removed) {
// 一人一条事件、一条通知 —— 保持既有产品语义,不聚合。
const event = this.recordExit(next, member, previous.members.length, next.members.length)
if (event && this.notificationRoomIds.has(next.roomId)) {
notifications.push({ group: next, event })
}
if (event) exits.push(event)
}
}
@@ -492,12 +487,11 @@ class GroupExitMonitorService {
return changedGroups
}
this.lastCheckedAt = Date.now()
// 先把事件和新基线作为同一检查点落盘,再执行可失败的通知动作。
// 先把事件和新基线作为同一检查点落盘,再交给自动化。
// 「退群事实已记录」与「通知发送成功」是两件独立的事。
this.save()
this.broadcast()
if (notifications.length) {
await Promise.all(notifications.map(({ group, event }) => this.notifyGroup(group, event)))
}
for (const event of exits) this.emitGroupExit(event)
return changedGroups
}
@@ -633,10 +627,8 @@ class GroupExitMonitorService {
currentCount,
delta: currentCount - previousCount,
message,
detectedAt,
notificationStatus: 'not_requested'
detectedAt
}
this.actionGateway.registerMemberEvent?.(event)
// 内存按时间倒序(新事件在前);磁盘**只追加这一条**,不重写历史。
// 这里不再有 `.slice(0, MAX_EVENTS)` —— 事件是永久保留的。
this.events = [event, ...this.events]
@@ -647,83 +639,37 @@ class GroupExitMonitorService {
return event
}
private async notifyGroup(
group: GroupSnapshotRecord,
event: GroupExitMonitorEvent,
idempotencyKey = `member_left_notification:${event.id}`
): Promise<void> {
event.notificationStatus = 'pending'
event.notification = { status: 'pending' }
this.save()
this.broadcast()
/**
* 把退群事件交给自动化 —— **不等待**。
*
* 三条约束:
* 1. **绝不 await**:快照扫描与成员 diff 不能被微信发送耗时拖住;
* 2. **异常必须被捕获**:`void promise` 漏掉 `.catch` 会变成 unhandled rejection;
* 3. **不影响退群事实**:事件与快照在同一检查点已经先落盘,通知失败不回滚记录。
*/
private emitGroupExit(event: GroupExitMonitorEvent): void {
const handler = this.onGroupExit
if (!handler) return
try {
const result = await this.actionGateway.execute({
idempotencyKey,
origin: 'member_monitor',
purpose: 'member_left_notification',
triggerType: 'automation',
sourceId: event.id,
recipient: {
type: 'group',
id: group.roomId,
name: group.groupName
},
content: {
type: 'text',
text: buildMemberLeftNotification(event, this.notificationTemplate)
},
metadata: {
memberId: event.memberWxid,
memberName: event.memberName,
detectedAt: event.detectedAt,
eventType: 'member_left',
eventRoomId: event.roomId
}
})
const notification: GroupExitNotificationState = {
status: result.status,
actionId: result.actionId,
decision: result.decision,
...(result.errorCode ? { errorCode: result.errorCode } : {}),
...(result.reason ? { reason: result.reason } : {}),
startedAt: result.startedAt,
finishedAt: result.finishedAt
}
event.notificationStatus = result.status
event.notification = notification
if (result.status !== 'sent') {
void Promise.resolve(
handler(toGroupMemberExitedEvent(event))
).catch((error) => {
console.warn(
`[GroupMonitor] 群聊通知未发送 roomId=${group.roomId} status=${result.status} code=${result.errorCode || ''}`
`[GroupMonitor] 退群通知处理失败 eventId=${event.id}: ${
error instanceof Error ? error.message : String(error)
}`
)
}
})
} catch (error) {
event.notificationStatus = 'failed'
event.notification = {
status: 'failed',
errorCode: 'UNKNOWN',
reason: error instanceof Error ? error.message : String(error)
}
// handler 同步抛出的情况(`handleGroupExit` 本身不抛,这里是防御性兜底)。
console.warn(
`[GroupMonitor] 群聊通知异常 roomId=${group.roomId}:`,
error instanceof Error ? error.message : String(error)
`[GroupMonitor] 退群通知处理异常 eventId=${event.id}: ${
error instanceof Error ? error.message : String(error)
}`
)
} finally {
this.save()
this.broadcast()
}
}
async resendEvent(eventId: string): Promise<GroupExitMonitorState> {
const event = this.events.find((item) => item.id === eventId)
if (!event) throw new Error('退群动态不存在')
await this.notifyGroup(
{ roomId: event.roomId, groupName: event.groupName } as GroupSnapshotRecord,
event,
`member_left_notification:${event.id}:retry:${Date.now()}`
)
return this.getState()
}
private filePath(): string {
return path.join(app.getPath('userData'), 'group-exit-monitor.json')
}
@@ -806,20 +752,11 @@ class GroupExitMonitorService {
// 「新在前」这个内存不变量,否则列表顶部会恒为最旧的一批。
this.events = sortEventsNewestFirst(normalizeEvents(fromDisk))
}
this.actionGateway.registerMemberEvents?.(this.events)
this.lastReadAt = Number(stored.lastReadAt) || 0
this.accountRoot = String(stored.accountRoot || '')
// 没有显式范围时按空范围处理,保留已有选择。
this.monitorSelectionConfigured = true
this.monitoredRoomIds = normalizeRoomIds(stored.monitoredRoomIds || [])
this.notificationRoomIds = new Set(
Array.from(normalizeRoomIds(stored.notificationRoomIds || [])).filter((roomId) =>
this.monitoredRoomIds.has(roomId)
)
)
this.notificationTemplate = normalizeGroupExitNotificationTemplate(
stored.notificationTemplate
)
this.snapshots = normalizeSnapshots(stored.snapshots, this.monitoredRoomIds)
} catch {
// 首次启动或状态文件损坏时从空记录开始 —— 但事件在独立文件里,
@@ -829,8 +766,6 @@ class GroupExitMonitorService {
this.lastReadAt = 0
this.monitorSelectionConfigured = true
this.monitoredRoomIds.clear()
this.notificationRoomIds.clear()
this.notificationTemplate = normalizeGroupExitNotificationTemplate(undefined)
this.snapshots.clear()
}
}
@@ -849,8 +784,6 @@ class GroupExitMonitorService {
lastReadAt: this.lastReadAt,
monitorSelectionConfigured: this.monitorSelectionConfigured,
monitoredRoomIds: Array.from(this.monitoredRoomIds),
notificationRoomIds: Array.from(this.notificationRoomIds),
notificationTemplate: this.notificationTemplate,
snapshots: Array.from(this.snapshots.values(), toStoredSnapshot)
},
{ spaces: 2 }
@@ -1047,15 +980,11 @@ function normalizeEvents(
? Number(value.delta)
: currentCount - previousCount,
message: String(value.message || `${memberName}退出了${groupName}`),
detectedAt,
...(value.notificationStatus
? { notificationStatus: normalizeNotificationStatus(value.notificationStatus) }
: {}),
...(value.notification && typeof value.notification === 'object'
? { notification: normalizeNotification(value.notification) }
: {})
detectedAt
})
// 不再按 MAX_EVENTS 截断:事件是永久保留的,截在这里等于每次启动都丢掉历史。
// 历史上的 `notificationStatus` / `notification` 字段被**丢弃**:
// 通知状态已归 Automation 执行日志,退群监控不再持有它。
}
return normalized
}
@@ -1075,28 +1004,6 @@ function sortEventsNewestFirst(events: GroupExitMonitorEvent[]): GroupExitMonito
return [...events].sort((left, right) => right.detectedAt - left.detectedAt)
}
function normalizeNotificationStatus(value: unknown): GroupExitNotificationStatus {
const status = String(value || '').trim()
return status === 'pending' || status === 'sent' || status === 'blocked' || status === 'failed'
? status
: 'not_requested'
}
function normalizeNotification(value: object): GroupExitNotificationState {
const input = value as Partial<GroupExitNotificationState>
return {
status: normalizeNotificationStatus(input.status),
...(input.actionId ? { actionId: String(input.actionId) } : {}),
...(input.decision === 'allow' || input.decision === 'block'
? { decision: input.decision }
: {}),
...(input.errorCode ? { errorCode: String(input.errorCode) } : {}),
...(input.reason ? { reason: String(input.reason) } : {}),
...(input.startedAt ? { startedAt: String(input.startedAt) } : {}),
...(input.finishedAt ? { finishedAt: String(input.finishedAt) } : {})
}
}
function isContactEvent(rawPayload: string): boolean {
const payload = String(rawPayload || '').trim()
if (!payload) return false
@@ -0,0 +1,106 @@
import {
LEAVE_NOTIFICATION_DEFAULT_TARGET_TYPE,
normalizeLeaveNotificationTemplate,
type LeaveNotificationTarget
} from '../../shared/automation'
/**
* 旧「退群监控 → 通知群聊 / 模板」配置 → 新「自动化 → 退群通知」规则的**纯映射器**。
*
* 单独成文件是为了能直接单测:这是整轮迁移里**唯一会改变用户既有行为**的地方,
* 不允许只靠"跑一遍看看"来验证。
*
* ## 旧语义(实测自 `group-exit-monitor-service.ts`)
*
* `notificationRoomIds` **不是**"统一通知接收目标列表",而是
* **"哪些被监控群要在自己群里通报成员退出"**(per-group 多值):
* - `applyCurrentMemberships()` 只在 `notificationRoomIds.has(roomId)` 时通知;
* - `notifyGroup()` 的收件人**恒等于事件所在群**。
*
* ## 新语义
*
* 单值目标:`source_chat` / `self` / `file_transfer` / `contact`。
*
* ⇒ 两者**只在一种情况下等价**:旧配置把**每一个**被监控群都勾上了通知。
* 严格子集无法表达,按保守策略处理,绝不"随便取第一个群"。
*/
export interface LegacyLeaveNotificationState {
monitoredRoomIds: string[]
notificationRoomIds: string[]
notificationTemplate?: unknown
}
export type LeaveNotificationMigrationOutcome =
/** 旧配置从不通知任何人 ⇒ 迁移成"关闭",行为完全一致。 */
| 'lossless_off'
/** 旧配置在**每个**被监控群里通报 ⇒ 等价于 `source_chat`,行为完全一致。 */
| 'lossless_source_chat'
/** 旧配置只在**部分**群里通报 ⇒ 单值目标无法表达,需用户重选。 */
| 'needs_review'
export interface LeaveNotificationMigrationPlan {
outcome: LeaveNotificationMigrationOutcome
enabled: boolean
target: LeaveNotificationTarget
targetNeedsReview: boolean
template: string
}
function uniqueRoomIds(values: unknown): string[] {
if (!Array.isArray(values)) return []
const result: string[] = []
const seen = new Set<string>()
for (const value of values) {
const roomId = String(value || '').trim()
if (!roomId || seen.has(roomId)) continue
seen.add(roomId)
result.push(roomId)
}
return result
}
export function planLeaveNotificationMigration(
legacy: LegacyLeaveNotificationState
): LeaveNotificationMigrationPlan {
const monitored = uniqueRoomIds(legacy.monitoredRoomIds)
const monitoredSet = new Set(monitored)
// 再夹一次:防止脏状态文件把范围外的群带进来。
const notifications = uniqueRoomIds(legacy.notificationRoomIds).filter((roomId) =>
monitoredSet.has(roomId)
)
// 模板永远是无损迁移的那一项:它就是用户写的那段文字。
const template = normalizeLeaveNotificationTemplate(legacy.notificationTemplate)
const defaultTarget: LeaveNotificationTarget = { type: LEAVE_NOTIFICATION_DEFAULT_TARGET_TYPE }
if (!notifications.length) {
// 旧语义是"从不通知" ⇒ 关掉,避免迁移后突然开始发消息。
return {
outcome: 'lossless_off',
enabled: false,
target: defaultTarget,
targetNeedsReview: false,
template
}
}
if (monitored.length > 0 && notifications.length === monitored.length) {
// 每个被监控群都在自己群里通报 ⇔ 单值的 `source_chat`。
return {
outcome: 'lossless_source_chat',
enabled: true,
target: { type: 'source_chat' },
targetNeedsReview: false,
template
}
}
// 部分勾选:**不能**无损映射。不取第一个群、不改写成当前群聊、保持关闭、要求重选。
return {
outcome: 'needs_review',
enabled: false,
target: defaultTarget,
targetNeedsReview: true,
template
}
}
@@ -0,0 +1,172 @@
import {
LEAVE_NOTIFICATION_TARGET_OPTIONS,
leaveNotificationTargetLabel,
type LeaveNotificationConfig
} from '../../shared/automation'
import type { GroupMemberExitedEvent } from '../../shared/group-exit-event'
import { WECHAT_FILE_HELPER_USERNAME, isFileHelperUsername } from '../../shared/wechat-identities'
/**
* 退群通知的**目标解析**。
*
* 纯函数(联系人清单与自身身份由调用方注入),因为这里最容易出错、
* 也最需要被单测穷举:发错人是不可撤销的。
*
* 四条规则:
* 1. `source_chat` 取**事件所在群**(`event.conversationId`),
* 不是"当前正在看的聊天",也不是"最后一个活跃会话"。
* 2. 目标解析失败 → **报错**,绝不 fallback 到别的目标(尤其不许偷偷发文件助手)。
* 3. 只允许出现在选项表里的目标类型。
* 4. 显示名不带 wxid:日志与界面只出现用户能看懂的称呼。
*/
/** 解析所需的最小联系人结构(`FormattedContact` 结构上可赋值给它)。 */
export interface LeaveNotificationCandidateContact {
m_nsUsrName: string
m_nsNickName?: string
type: 'user' | 'group'
isOfficialAccount?: boolean
wechatNickname?: string
remark?: string
}
export interface ResolvedLeaveNotificationTarget {
recipient: { type: 'group' | 'contact'; id: string; name: string }
/** 用户可读的目标名,直接进执行日志的 `detail`。 */
displayName: string
}
export type LeaveNotificationTargetResolution =
| { ok: true; target: ResolvedLeaveNotificationTarget }
| { ok: false; error: string }
/** 联系人显示名:备注 → 微信昵称 → 会话昵称 → 兜底称呼。**永不回落到 wxid**。 */
export function leaveNotificationContactDisplayName(
contact: LeaveNotificationCandidateContact
): string {
return (
contact.remark?.trim() ||
contact.wechatNickname?.trim() ||
contact.m_nsNickName?.trim() ||
'指定好友'
)
}
/**
* 这个联系人能不能作为「指定好友」的发送目标。
*
* 排除:群聊、公众号(`gh_`)、文件传输助手、自己。
* UI 的联系人选择器与运行时的目标解析**共用这一条判定**,
* 否则会出现"能选但发不出去"。
*/
export function isSendableFriendContact(
contact: LeaveNotificationCandidateContact,
selfWxid?: string
): boolean {
if (contact.type !== 'user') return false
if (contact.isOfficialAccount) return false
const username = String(contact.m_nsUsrName || '').trim()
if (!username) return false
if (isFileHelperUsername(username)) return false
const self = String(selfWxid || '').trim()
if (self && username === self) return false
return true
}
/** 供 UI 用的「可选好友」过滤(与运行时同一套判定)。 */
export function filterSendableFriendContacts<T extends LeaveNotificationCandidateContact>(
contacts: T[],
selfWxid?: string
): T[] {
return contacts.filter((contact) => isSendableFriendContact(contact, selfWxid))
}
export function resolveLeaveNotificationTarget(input: {
config: LeaveNotificationConfig | undefined
event: GroupMemberExitedEvent
contacts: LeaveNotificationCandidateContact[]
/** 当前登录账号的 wxid;拿不到时传空串。 */
selfWxid?: string
}): LeaveNotificationTargetResolution {
const config = input.config
if (!config) {
return { ok: false, error: '退群通知尚未配置发送目标' }
}
const known = LEAVE_NOTIFICATION_TARGET_OPTIONS.some(
(option) => option.type === config.target?.type
)
if (!known) {
return { ok: false, error: '退群通知的发送目标无效,请重新选择' }
}
switch (config.target.type) {
case 'source_chat': {
const conversationId = String(input.event.conversationId || '').trim()
if (!conversationId) {
return { ok: false, error: '无法确定发生退群的群聊,本次通知未发送' }
}
const groupName = String(input.event.groupName || '').trim()
return {
ok: true,
target: {
recipient: {
type: 'group',
id: conversationId,
name: groupName || '当前群聊'
},
displayName: groupName || '发生退群事件的群聊'
}
}
}
case 'self': {
const selfWxid = String(input.selfWxid || '').trim()
if (!selfWxid) {
// 明确报错,不 fallback。§「不要偷偷 fallback 文件传输助手」。
return { ok: false, error: '无法确定当前登录的微信账号,本次通知未发送' }
}
return {
ok: true,
target: {
recipient: { type: 'contact', id: selfWxid, name: '我' },
displayName: '我'
}
}
}
case 'file_transfer':
return {
ok: true,
target: {
recipient: {
type: 'contact',
id: WECHAT_FILE_HELPER_USERNAME,
name: leaveNotificationTargetLabel('file_transfer')
},
displayName: leaveNotificationTargetLabel('file_transfer')
}
}
case 'contact': {
const contactId = String(config.target.contactId || '').trim()
if (!contactId) {
return { ok: false, error: '还没有选择通知联系人,请重新选择' }
}
const contact = input.contacts.find(
(item) => String(item.m_nsUsrName || '').trim() === contactId
)
if (!contact || !isSendableFriendContact(contact, input.selfWxid)) {
// §「如果之前选择的联系人被删除 / 不可发送 / 找不到,规则不要偷偷发给其它地方」。
return { ok: false, error: '通知联系人已不存在或当前无法发送,请重新选择' }
}
const displayName = leaveNotificationContactDisplayName(contact)
return {
ok: true,
target: {
recipient: { type: 'contact', id: contactId, name: displayName },
displayName
}
}
}
}
}
+6 -84
View File
@@ -13,7 +13,6 @@ import type {
WechatActionAuditRecord,
WechatActionContent,
WechatActionErrorCode,
WechatActionMemberEventReference,
WechatActionRequest,
WechatActionResult
} from '../../shared/wechat-action'
@@ -25,12 +24,14 @@ const MAX_CONTENT_PREVIEW_LENGTH = 240
export const AUTOMATION_SEND_INTERVAL_MS = 3_000
const AUTOMATION_PURPOSE_ALLOWLIST = new Set([
'scheduled_report',
'member_left_notification',
// Automation v1(@我生成日报)。必须与 `src/shared/automation.ts` 的
// Automation(@我生成日报)。必须与 `src/shared/automation.ts` 的
// `AUTOMATION_SEND_PURPOSE` 保持一致,否则会被下面 evaluateWechatActionPolicy
// 以 ACTION_NOT_ALLOWED 拦下 —— 那是有意的闸门,不是 bug。
'automation_reply',
'automation_report',
// 退群通知(由 Automation 发出)。目标可以是群 / 自己 / 文件传输助手 / 指定好友,
// 所以**不绑定**任何 "只能发回原群" 的作用域锁。
'automation_leave_notification',
'manual_report_image',
'manual_report_postfix'
])
@@ -49,12 +50,6 @@ export interface ReportImageSequenceResult {
export interface WechatActionGatewayDependencies {
getCapability?: () => Promise<PersonalWechatSendCapability>
send?: (request: PersonalWechatSendRequest) => Promise<PersonalWechatSendResult>
getMemberEvent?: (
sourceId: string
) =>
| WechatActionMemberEventReference
| Promise<WechatActionMemberEventReference | undefined>
| undefined
getUserDataPath?: () => string
now?: () => Date
wait?: (milliseconds: number) => Promise<void>
@@ -65,10 +60,6 @@ interface LoadedAuditState {
records: WechatActionAuditRecord[]
}
export interface WechatActionPolicyContext {
memberEvent?: WechatActionMemberEventReference
}
const defaultDependencies = (): Required<
Pick<
WechatActionGatewayDependencies,
@@ -98,7 +89,6 @@ export class WechatActionGateway {
WechatActionGatewayDependencies,
'getCapability' | 'send' | 'getUserDataPath' | 'now' | 'wait'
>
private readonly memberEvents = new Map<string, WechatActionMemberEventReference>()
private readonly inFlight = new Map<string, Promise<WechatActionResult>>()
private auditState: LoadedAuditState | null = null
private automationSendTail: Promise<void> = Promise.resolve()
@@ -108,22 +98,6 @@ export class WechatActionGateway {
this.deps = { ...defaultDependencies(), ...deps }
}
/** 记录退群事件,发送通知前可确认事件所属群聊。 */
registerMemberEvent(event: WechatActionMemberEventReference): void {
const id = String(event?.id || '').trim()
const roomId = String(event?.roomId || '').trim()
if (!id || !roomId) return
this.memberEvents.set(id, { id, roomId })
}
registerMemberEvents(events: WechatActionMemberEventReference[]): void {
for (const event of events) this.registerMemberEvent(event)
}
clearMemberEvents(): void {
this.memberEvents.clear()
}
/**
* 用户确认后的日报发送序列。两步都进入 automation 发送队列,从而复用既有 3 秒间隔;
* 后置词 action 只在图片明确 sent 后创建,图片失败/blocked 时严格短路。
@@ -196,8 +170,7 @@ export class WechatActionGateway {
)
}
const eventContext = await this.resolveEventContext(request)
const policy = evaluateWechatActionPolicy(request, eventContext)
const policy = evaluateWechatActionPolicy(request)
if (policy.decision !== 'allow') {
return this.finishBlocked(
actionId,
@@ -291,28 +264,6 @@ export class WechatActionGateway {
return queued
}
private async resolveEventContext(
request: WechatActionRequest
): Promise<WechatActionPolicyContext> {
if (request.purpose !== 'member_left_notification') return {}
const sourceId = String(request.sourceId || '').trim()
if (!sourceId) return {}
let memberEvent: WechatActionMemberEventReference | undefined = this.memberEvents.get(sourceId)
if (!memberEvent && this.deps.getMemberEvent) {
let resolved: WechatActionMemberEventReference | undefined
try {
resolved = await this.deps.getMemberEvent(sourceId)
} catch {
resolved = undefined
}
if (resolved && String(resolved.id || '').trim() === sourceId) {
memberEvent = { id: String(resolved.id), roomId: String(resolved.roomId) }
this.registerMemberEvent(memberEvent)
}
}
return memberEvent ? { memberEvent } : {}
}
private async findExisting(idempotencyKey: string): Promise<WechatActionResult | undefined> {
const state = this.loadAuditState()
const existing = state.records.find((record) => record.idempotencyKey === idempotencyKey)
@@ -331,9 +282,6 @@ export class WechatActionGateway {
private idempotencyKey(request: WechatActionRequest): string | undefined {
const explicit = String(request.idempotencyKey || '').trim()
if (explicit) return explicit
if (request.purpose === 'member_left_notification' && request.sourceId) {
return `member_left_notification:${request.sourceId}`
}
if (request.origin === 'scheduled_report' && request.executionId) {
return `scheduled_report:${request.executionId}`
}
@@ -480,10 +428,7 @@ export class WechatActionGateway {
}
}
export function evaluateWechatActionPolicy(
request: WechatActionRequest,
context: WechatActionPolicyContext = {}
): PolicyDecision {
export function evaluateWechatActionPolicy(request: WechatActionRequest): PolicyDecision {
if (!request || typeof request !== 'object') {
return {
decision: 'block',
@@ -514,29 +459,6 @@ export function evaluateWechatActionPolicy(
reason: `自动化动作不允许执行 purpose=${request.purpose}`
}
}
if (request.purpose === 'member_left_notification') {
if (
!request.sourceId ||
!context.memberEvent ||
context.memberEvent.id !== request.sourceId ||
!context.memberEvent.roomId
) {
return {
decision: 'block',
source: 'deterministic',
reasonCode: 'INVALID_REQUEST',
reason: '退群通知必须关联已记录的退群事件'
}
}
if (request.recipient.type !== 'group' || request.recipient.id !== context.memberEvent.roomId) {
return {
decision: 'block',
source: 'deterministic',
reasonCode: 'RECIPIENT_SCOPE_VIOLATION',
reason: '退群通知只能发送回原事件所在群聊'
}
}
}
return { decision: 'allow', source: 'deterministic' }
}
+6 -6
View File
@@ -304,14 +304,10 @@ declare global {
limit?: number
}) => Promise<GroupExitMonitorEvent[]>
setGroupExitMonitorEnabled: (enabled: boolean) => Promise<GroupExitMonitorState>
setGroupExitMonitorGroups: (
roomIds: string[],
notificationRoomIds?: string[]
) => Promise<GroupExitMonitorState>
setGroupExitMonitorNotificationTemplate: (template: string) => Promise<GroupExitMonitorState>
/** 保存**监控范围**。通知配置已迁到「自动化 → 退群通知」。 */
setGroupExitMonitorGroups: (roomIds: string[]) => Promise<GroupExitMonitorState>
checkGroupExitMonitorNow: () => Promise<GroupExitMonitorState>
clearGroupExitMonitorEvents: () => Promise<GroupExitMonitorState>
resendGroupExitMonitorEvent: (eventId: string) => Promise<GroupExitMonitorState>
markGroupExitMonitorRead: (readAt?: number) => Promise<GroupExitMonitorState>
listWechatActionLogs: () => Promise<ActionLogEntry[]>
getAutomationStatus: () => Promise<AutomationStatusSummary>
@@ -326,6 +322,10 @@ declare global {
listAutomationExecutions: (query?: { limit?: number }) => Promise<AutomationExecution[]>
clearAutomationExecutions: () => Promise<boolean>
listAutomationGroups: () => Promise<Array<{ id: string; name: string }>>
/** 保存「退群通知」规则(singleton upsert)。 */
saveLeaveNotificationRule: (draft: AutomationRuleDraft) => Promise<AutomationRule>
/** 「指定好友」的可选项;已在 main 侧过滤掉群聊 / 公众号 / 文件传输助手 / 自己。 */
listSendableContacts: () => Promise<Array<{ id: string; name: string }>>
onGroupExitMonitorState: (callback: (state: GroupExitMonitorState) => void) => () => void
search: (keyword: string) => Promise<string | null>
searchKnowledge: (request: KnowledgeSearchIpcRequest) => Promise<KnowledgeSearchIpcResult>
+13 -11
View File
@@ -182,21 +182,13 @@ const api = {
ipcRenderer.invoke('group-exit-monitor:listEvents', query),
setGroupExitMonitorEnabled: (enabled: boolean): Promise<GroupExitMonitorState> =>
ipcRenderer.invoke('group-exit-monitor:setEnabled', enabled),
setGroupExitMonitorGroups: (
roomIds: string[],
notificationRoomIds?: string[]
): Promise<GroupExitMonitorState> =>
notificationRoomIds === undefined
? ipcRenderer.invoke('group-exit-monitor:setGroups', roomIds)
: ipcRenderer.invoke('group-exit-monitor:setGroups', roomIds, notificationRoomIds),
setGroupExitMonitorNotificationTemplate: (template: string): Promise<GroupExitMonitorState> =>
ipcRenderer.invoke('group-exit-monitor:setTemplate', template),
/** 保存**监控范围**。通知配置已迁到自动化规则,这里不再有第二个参数。 */
setGroupExitMonitorGroups: (roomIds: string[]): Promise<GroupExitMonitorState> =>
ipcRenderer.invoke('group-exit-monitor:setGroups', roomIds),
checkGroupExitMonitorNow: (): Promise<GroupExitMonitorState> =>
ipcRenderer.invoke('group-exit-monitor:checkNow'),
clearGroupExitMonitorEvents: (): Promise<GroupExitMonitorState> =>
ipcRenderer.invoke('group-exit-monitor:clearEvents'),
resendGroupExitMonitorEvent: (eventId: string): Promise<GroupExitMonitorState> =>
ipcRenderer.invoke('group-exit-monitor:resendEvent', eventId),
markGroupExitMonitorRead: (readAt?: number): Promise<GroupExitMonitorState> =>
ipcRenderer.invoke('group-exit-monitor:markRead', readAt),
listWechatActionLogs: (): Promise<ActionLogEntry[]> =>
@@ -220,6 +212,16 @@ const api = {
/** 「在哪些聊天生效」的可选项。`id` 为 `xxx@chatroom`,与规则内 conversationIds 同口径。 */
listAutomationGroups: (): Promise<Array<{ id: string; name: string }>> =>
ipcRenderer.invoke('automation:listGroups'),
/**
* 保存「退群通知」规则(singleton upsert,id 由 main 侧固定,渲染层不拼 id)。
*/
saveLeaveNotificationRule: (draft: AutomationRuleDraft): Promise<AutomationRule> =>
ipcRenderer.invoke('automation:saveLeaveNotificationRule', draft),
/**
* 「指定好友」的可选项。已在 main 侧过滤掉群聊 / 公众号 / 文件传输助手 / 自己。
*/
listSendableContacts: (): Promise<Array<{ id: string; name: string }>> =>
ipcRenderer.invoke('automation:listSendableContacts'),
onGroupExitMonitorState: (callback: (state: GroupExitMonitorState) => void) => {
const listener = (_event: Electron.IpcRendererEvent, state: GroupExitMonitorState): void =>
callback(state)
+34 -2
View File
@@ -45,8 +45,8 @@ import { switchGeneratedReportTemplate } from './utils/report-template-switch'
import { runtimePlatform, supportsPersonalWechatSend } from './utils/runtime-environment'
import { useToast } from './components/ui'
import { AppUpdatePrompt } from './features/app-update/AppUpdatePrompt'
import { GroupExitMonitorWorkspace } from './features/group-exit-monitor/GroupExitMonitorWorkspace'
import { AutomationWorkspace } from './features/automation/AutomationWorkspace'
import { GroupExitMonitorWorkspace, type GroupExitMonitorOpenViewRequest } from './features/group-exit-monitor/GroupExitMonitorWorkspace'
import { AutomationWorkspace, type AutomationOpenRuleRequest } from './features/automation/AutomationWorkspace'
import { selectContactAvatarRefreshUsernames } from './utils/contact-avatar'
import {
buildContactSearchIndex,
@@ -258,6 +258,17 @@ function App(): React.ReactElement {
const [databaseEnvironment, setDatabaseEnvironment] = useState<DatabaseKeyEnvironment>()
const connectionOperationRef = React.useRef(0)
const [activePage, setActivePage] = useState<AppPage>('archive')
/**
* 「退群监控 ⇄ 自动化」之间的跨页深链。
*
* 项目没有 react-router,一级菜单就是 `activePage` 这一份 state,
* 所以深链同样是 state —— 用一个单调递增的 requestId 表达"又点了一次",
* 子页面据此重新落位(同一个对象引用不会重复触发 effect)。
*/
const [automationOpenRuleRequest, setAutomationOpenRuleRequest] =
React.useState<AutomationOpenRuleRequest | null>(null)
const [exitMonitorOpenViewRequest, setExitMonitorOpenViewRequest] =
React.useState<GroupExitMonitorOpenViewRequest | null>(null)
const [archiveJumpTime, setArchiveJumpTime] = useState<number | null>(null)
/**
* 精确跳转目标(规范化后的消息 id)。
@@ -1655,6 +1666,23 @@ function App(): React.ReactElement {
setActivePage('agent-hub')
}
/**
* 「退群监控 → 退群通知自动化」。
*
* 直接落到「自动化 → 规则 → 退群通知」,不是只跳到自动化首页。
* 这是纯导航:不改退群监控的任何配置,也不触发任何发送。
*/
const openLeaveNotificationAutomation = (): void => {
setAutomationOpenRuleRequest({ ruleType: 'leave_notification', requestId: Date.now() })
setActivePage('automation')
}
/** 「自动化 → 退群通知 → 管理监控群聊 / 查看群聊」:回到退群监控的管理群聊页。 */
const openExitMonitorGroups = (): void => {
setExitMonitorOpenViewRequest({ view: 'manage', requestId: Date.now() })
setActivePage('exit-monitor')
}
const dismissFirstUseWelcome = (): void => {
try {
localStorage.setItem(FIRST_USE_WELCOME_SEEN_KEY, '1')
@@ -2097,6 +2125,8 @@ function App(): React.ReactElement {
dbReady={isDatabaseConnected}
contacts={contacts}
onOpenSendSettings={openWechatSendSettings}
openViewRequest={exitMonitorOpenViewRequest}
onOpenLeaveNotificationAutomation={openLeaveNotificationAutomation}
/>
)
case 'automation':
@@ -2104,6 +2134,8 @@ function App(): React.ReactElement {
<AutomationWorkspace
dbReady={isDatabaseConnected}
onOpenSendSettings={openWechatSendSettings}
onOpenExitMonitorGroups={openExitMonitorGroups}
openRuleRequest={automationOpenRuleRequest}
/>
)
case 'agent-hub':
@@ -0,0 +1,40 @@
import * as React from 'react'
import { SegmentedControl, SegmentedControlItem } from '../../components/ui'
import { AUTOMATION_RULE_TYPE_LABELS, type AutomationRuleType } from '../../../../shared/automation'
/**
* AutomationRuleTypeTabs —— 编辑器上方的**规则类型切换**。
*
* 它切的是"现在在编辑哪一类规则",**不是**"把当前这条规则改成另一类"。
* 文案必须说清这一点:叫「规则类型」时,用户会以为它是当前规则的类型选择器,
* 于是"切一下再保存"就意外改到了另一条规则上。
*
* 两项都是**真规则**:切换只改前端选中态,不碰任何已保存的配置。
*/
const RULE_TYPES: AutomationRuleType[] = ['daily_report', 'leave_notification']
export function AutomationRuleTypeTabs({
value,
onValueChange
}: {
value: AutomationRuleType
onValueChange: (next: AutomationRuleType) => void
}): React.ReactElement {
return (
<div className="automation-rule-type-tabs">
<span className="automation-rule-type-label">切换规则类型</span>
<SegmentedControl
value={value}
onValueChange={(next) => onValueChange(next as AutomationRuleType)}
aria-label="切换规则类型"
>
{RULE_TYPES.map((type) => (
<SegmentedControlItem key={type} value={type}>
{AUTOMATION_RULE_TYPE_LABELS[type]}
</SegmentedControlItem>
))}
</SegmentedControl>
</div>
)
}
@@ -1,20 +1,30 @@
import * as React from 'react'
import type {
AutomationExecution,
AutomationRule,
AutomationRuleDraft,
AutomationStatusSummary
import {
BUILTIN_DAILY_REPORT_RULE_ID,
BUILTIN_LEAVE_NOTIFICATION_RULE_ID,
type AutomationExecution,
type AutomationRule,
type AutomationRuleDraft,
type AutomationRuleType,
type AutomationStatusSummary
} from '../../../../shared/automation'
import {
Button,
SegmentedControl,
SegmentedControlItem,
Spinner,
useToast
} from '../../components/ui'
import { RuleListPanel } from './RuleListPanel'
import { RuleEditorPanel } from './RuleEditorPanel'
import { ExecutionLogPanel } from './ExecutionLogPanel'
import { ExecutionDetailDrawer } from './ExecutionDetailDrawer'
import { AutomationRuleTypeTabs } from './AutomationRuleTypeTabs'
import {
LeaveNotificationEditor,
type LeaveNotificationSaveInput
} from './LeaveNotificationEditor'
import type { LeaveNotificationContactOption } from './LeaveNotificationTargetPicker'
import { UNAVAILABLE_STATUS, automationApi, isAutomationApiAvailable, type AutomationGroupOption } from './model/api'
/**
@@ -23,24 +33,41 @@ import { UNAVAILABLE_STATUS, automationApi, isAutomationApiAvailable, type Autom
* 两个 tab:规则 / 执行日志。顶部三块状态(监听 / 发送能力 / 今日执行)
* **全部来自 main 的真实能力**,渲染层不做任何平台猜测 ——
* 粗平台判断在这里是错的:装了 macOS 但没绑定个人微信,照样发不出去。
*
* 「规则」内部再分一层**规则类型**:@我生成日报 / 退群通知。
* 两种类型现在都是**真实规则**,保存都会落盘。
*/
type AutomationTab = 'rules' | 'logs'
/** 由「退群监控」深链过来时的请求:直接打开指定规则类型。 */
export interface AutomationOpenRuleRequest {
ruleType: AutomationRuleType
/** 每次点击都要能重新触发,所以带一个自增/时间戳。 */
requestId: number
}
export interface AutomationWorkspaceProps {
dbReady: boolean
/** 发送能力不可用时,引导用户去设置页。 */
onOpenSendSettings?: () => void
/** 「管理监控群聊 →」:跳到退群监控的管理群聊页(纯导航)。 */
onOpenExitMonitorGroups?: () => void
/** 深链请求(来自退群监控页的「退群通知自动化」入口)。 */
openRuleRequest?: AutomationOpenRuleRequest | null
}
interface EditorState {
mode: 'create' | 'edit'
rule: AutomationRule | null
ruleType: AutomationRuleType
}
export function AutomationWorkspace({
dbReady,
onOpenSendSettings
onOpenSendSettings,
onOpenExitMonitorGroups,
openRuleRequest
}: AutomationWorkspaceProps): React.ReactElement {
const { toast } = useToast()
const apiAvailable = React.useMemo(() => isAutomationApiAvailable(), [])
@@ -50,6 +77,8 @@ export function AutomationWorkspace({
const [rules, setRules] = React.useState<AutomationRule[]>([])
const [groups, setGroups] = React.useState<AutomationGroupOption[]>([])
const [executions, setExecutions] = React.useState<AutomationExecution[]>([])
const [sendableContacts, setSendableContacts] = React.useState<LeaveNotificationContactOption[]>([])
const [monitoredGroupCount, setMonitoredGroupCount] = React.useState(0)
const [loading, setLoading] = React.useState(true)
const [clearing, setClearing] = React.useState(false)
const [saving, setSaving] = React.useState(false)
@@ -59,16 +88,22 @@ export function AutomationWorkspace({
const [pendingDelete, setPendingDelete] = React.useState<AutomationRule | null>(null)
const reload = React.useCallback(async (): Promise<void> => {
const [nextStatus, nextRules, nextGroups, nextExecutions] = await Promise.all([
automationApi.getStatus(),
automationApi.listRules(),
automationApi.listGroups(),
automationApi.listExecutions(100)
])
const [nextStatus, nextRules, nextGroups, nextExecutions, nextContacts, nextMonitored] =
await Promise.all([
automationApi.getStatus(),
automationApi.listRules(),
automationApi.listGroups(),
automationApi.listExecutions(100),
// 「指定好友」候选与已监控群聊数量:都来自既有能力,不在自动化里另存一份。
automationApi.listSendableContacts(),
automationApi.getMonitoredGroupCount()
])
setStatus(nextStatus)
setRules(nextRules)
setGroups(nextGroups)
setExecutions(nextExecutions)
setSendableContacts(nextContacts)
setMonitoredGroupCount(nextMonitored)
}, [])
React.useEffect(() => {
@@ -89,6 +124,65 @@ export function AutomationWorkspace({
void reload()
}, [tab, reload])
/**
* 「退群监控 → 退群通知自动化」的深链。
*
* 落到「规则 → 退群通知」这一层,而不是只跳到自动化首页 ——
* 否则用户还得自己再找一次。请求对象本身在父层是稳定的,
* 所以依赖整个对象;requestId 变化就代表又点了一次。
*/
React.useEffect(() => {
if (!openRuleRequest) return
setTab('rules')
setEditor({ mode: 'edit', rule: null, ruleType: openRuleRequest.ruleType })
}, [openRuleRequest])
/**
* 当前编辑目标(日报)—— **UI 显示与保存共用这一个**。
*
* 这里保留"回落到内置日报"的兜底,但它同时驱动下面的「正在编辑:X」,
* 所以即使回落发生,用户也能看到真实目标,不会再出现"以为在改 A、实际改了 B"。
*/
const dailyReportTarget =
editor?.ruleType === 'daily_report'
? (editor.rule ?? rules.find((item) => item.id === BUILTIN_DAILY_REPORT_RULE_ID) ?? null)
: null
/** 退群通知是 singleton 规则,永远从规则表里取(不存在则由 main 侧播种)。 */
const leaveNotificationRule =
rules.find((item) => item.id === BUILTIN_LEAVE_NOTIFICATION_RULE_ID) ?? null
/** 「正在编辑」显示的名字(与保存目标同源)。 */
const editorTargetName = React.useMemo((): string => {
if (!editor) return ''
if (editor.ruleType === 'leave_notification') return leaveNotificationRule?.name ?? '退群通知'
return dailyReportTarget?.name ?? '新建自动化'
}, [editor, leaveNotificationRule, dailyReportTarget])
/** 「指定好友」目标的显示名(首页卡片用;拿不到时留空,不伪造)。 */
const leaveContactName = React.useMemo((): string => {
const target = leaveNotificationRule?.leaveNotification?.target
if (!target || target.type !== 'contact') return ''
return sendableContacts.find((contact) => contact.id === target.contactId)?.name ?? ''
}, [leaveNotificationRule, sendableContacts])
/**
* 记住"上一次在日报这一类里编辑的那条"。
*
* 切类型只是换一类规则来编辑,切回来时应当回到刚才那条 ——
* 否则用户从"某条日报"切走再切回,会被悄悄带到内置日报上。
*/
const lastDailyTargetRef = React.useRef<AutomationRule | null>(null)
/** 切到「@我生成日报」时的编辑目标:上次那条 → 内置日报 → 新建。 */
const resolveDailyTarget = React.useCallback((): AutomationRule | null => {
return (
lastDailyTargetRef.current ??
rules.find((item) => item.id === BUILTIN_DAILY_REPORT_RULE_ID) ??
null
)
}, [rules])
const handleToggle = async (rule: AutomationRule, enabled: boolean): Promise<void> => {
setBusyRuleId(rule.id)
const updated = await automationApi.setRuleEnabled(rule.id, enabled)
@@ -103,11 +197,32 @@ export function AutomationWorkspace({
const handleSave = async (draft: AutomationRuleDraft): Promise<void> => {
if (!editor) return
const target = editor.ruleType === 'daily_report' ? dailyReportTarget : editor.rule
/*
* 编辑态但目标不存在 ⇒ **报错,绝不许静默新建**。
*
* 这里以前写的是 `editor.mode === 'create' || !target ? createRule : updateRule` ——
* 把"没有目标"当成了"新建",于是用户以为在改 A、实际多出一条新规则。
* 新建只能由 `mode === 'create'` 决定;`edit` 且无目标是不可恢复的状态,必须说出来。
*/
if (editor.mode === 'edit' && !target) {
toast({
description: '要编辑的规则已不存在,请返回列表重新打开',
variant: 'destructive',
duration: 3600
})
return
}
setSaving(true)
const saved =
editor.mode === 'create'
? await automationApi.createRule(draft)
: await automationApi.updateRule(editor.rule?.id ?? '', draft)
let saved: AutomationRule | null = null
if (editor.mode === 'create') {
saved = await automationApi.createRule(draft)
} else if (target) {
saved = await automationApi.updateRule(target.id, draft)
}
setSaving(false)
if (!saved) {
toast({ description: '保存失败,请稍后重试', variant: 'destructive', duration: 3200 })
@@ -127,6 +242,45 @@ export function AutomationWorkspace({
void automationApi.getStatus().then(setStatus)
}
/**
* 保存「退群通知」规则。
*
* 走独立的 singleton upsert 通道:这条规则 id 固定,保存永远不会多出一条。
* 保存成功后清掉迁移遗留的 `targetNeedsReview`(用户已经做过选择)。
*/
const handleLeaveNotificationSave = async (
input: LeaveNotificationSaveInput
): Promise<void> => {
// 静默 return 会让"点保存没反应"变成用户眼里的坏掉,必须说出来。
if (!leaveNotificationRule) {
toast({
description: '退群通知规则已不存在,请返回列表重新打开',
variant: 'destructive',
duration: 3600
})
return
}
setSaving(true)
const saved = await automationApi.saveLeaveNotificationRule({
...leaveNotificationRule,
enabled: input.enabled,
leaveNotification: input.config
})
setSaving(false)
if (!saved) {
toast({ description: '保存失败,请稍后重试', variant: 'destructive', duration: 3200 })
return
}
setRules((current) =>
current.some((item) => item.id === saved.id)
? current.map((item) => (item.id === saved.id ? saved : item))
: [...current, saved]
)
setEditor(null)
toast({ description: `已保存「${saved.name}」`, duration: 2800 })
void automationApi.getStatus().then(setStatus)
}
const confirmDelete = async (): Promise<void> => {
if (!pendingDelete) return
const target = pendingDelete
@@ -166,7 +320,14 @@ export function AutomationWorkspace({
<p>当群里出现符合条件的消息时,TraceMemo 会自动执行你配置的动作。</p>
</div>
{tab === 'rules' && !showEditor ? (
<Button onClick={() => setEditor({ mode: 'create', rule: null })}>新建自动化</Button>
<Button
onClick={() => {
lastDailyTargetRef.current = null
setEditor({ mode: 'create', rule: null, ruleType: 'daily_report' })
}}
>
新建自动化
</Button>
) : null}
</header>
@@ -223,24 +384,154 @@ export function AutomationWorkspace({
) : null}
{showEditor && editor ? (
<RuleEditorPanel
mode={editor.mode}
rule={editor.rule}
groups={groups}
saving={saving}
onCancel={() => setEditor(null)}
onSave={(draft) => void handleSave(draft)}
/>
<div className="automation-rule-type-shell">
{/*
切类型 = **换一条规则来编辑**,不是"把这条规则改成另一类"。
以前这里只改 `ruleType`,把上个类型的 `mode`/`rule` 一起带过来,
于是"从退群通知切到日报"会留下 `mode='edit' + rule=null` 的中间态,
保存时再隐式回落到内置日报 —— 用户以为在改退群通知,实际改掉了日报。
现在切换时**显式重算编辑目标**,并把当前目标名显示出来。
*/}
<AutomationRuleTypeTabs
value={editor.ruleType}
onValueChange={(next) => {
if (next === editor.ruleType) return
if (next === 'leave_notification') {
// 退群通知是 singleton:编辑目标就是它自己,不依赖规则列表。
setEditor({ mode: 'edit', rule: null, ruleType: 'leave_notification' })
return
}
// 日报可以有多条:目标必须是**具体某一条**或"新建",
// 绝不允许留下 mode='edit' 却没有目标的状态。
const target = resolveDailyTarget()
lastDailyTargetRef.current = target
setEditor(
target
? { mode: 'edit', rule: target, ruleType: 'daily_report' }
: { mode: 'create', rule: null, ruleType: 'daily_report' }
)
}}
/>
{/*
编辑目标必须**可见**。
保存的目标与这里显示的名字来自**同一个变量** —— 不允许 UI 显示一个、保存改另一个。
*/}
<p className="automation-editing-target" data-testid="automation-editing-target">
正在编辑:<strong>{editorTargetName}</strong>
</p>
{editor.ruleType === 'daily_report' ? (
/*
* 编辑态却没有目标(规则被删/列表刷新后消失)—— 不能让用户对着一个
* 标题写着「编辑自动化」的空表单填半天,最后保存时报错。
*/
editor.mode === 'edit' && !dailyReportTarget ? (
<div className="automation-notice warning automation-notice-stack">
<p>要编辑的规则已不存在(可能已被删除)。</p>
<Button variant="outline" size="sm" onClick={() => setEditor(null)}>
返回规则列表
</Button>
</div>
) : (
<RuleEditorPanel
mode={editor.mode}
rule={dailyReportTarget}
groups={groups}
saving={saving}
onCancel={() => setEditor(null)}
onSave={(draft) => void handleSave(draft)}
/>
)
) : leaveNotificationRule ? (
<LeaveNotificationEditor
rule={leaveNotificationRule}
contacts={sendableContacts}
monitoredCount={monitoredGroupCount}
saving={saving}
{...(capabilityReady
? {}
: {
// §「规则仍然可以保存」:只提示,不阻断编辑。
sendCapabilityWarning:
'当前发送能力未就绪:退群事件仍会被记录,但通知发送会失败。'
})}
onOpenMonitoredGroups={onOpenExitMonitorGroups}
onCancel={() => setEditor(null)}
onSave={(input) => void handleLeaveNotificationSave(input)}
/>
) : loading ? (
/*
* **正在读,不是"没有"。**
*
* 从「退群监控 → 退群通知自动化」深链进来时,编辑器是同步打开的,
* 而规则列表还在 IPC 回来的路上 —— 这一帧必然还没有规则。
* 旧写法在这里直接渲染「尚未就绪 / 版本不匹配」,把最正常的一种
* 加载状态说成了故障,必须区分开。
*/
<div className="automation-loading">
<Spinner />
<span>正在读取退群通知规则…</span>
</div>
) : (
/*
* 读完了**确实没有**这条规则。
*
* `builtin-leave-notification` 由主进程 `AutomationRuleStore` 首次加载时自动播种,
* 所以走到这里通常意味着:跑着的主进程还是迁移前的旧构建
* (渲染层已经升级,`out/main` 没有)。如实说明并给一个重试入口。
*/
<div className="automation-notice warning automation-notice-stack">
<p>没有找到内置的退群通知规则。</p>
<small>
这条规则应由主进程在首次启动时自动创建。如果你刚更新过版本,
请重启 TraceMemo —— 已经跑起来的主进程不会自动换成新代码。
</small>
<Button
variant="outline"
size="sm"
disabled={loading}
onClick={() => {
setLoading(true)
void reload().finally(() => setLoading(false))
}}
>
重新加载
</Button>
</div>
)}
</div>
) : tab === 'rules' ? (
<RuleListPanel
rules={rules}
groups={groups}
loading={loading}
busyRuleId={busyRuleId}
leaveNotification={
leaveNotificationRule
? {
rule: leaveNotificationRule,
monitoredCount: monitoredGroupCount,
contactName: leaveContactName
}
: null
}
onToggle={(rule, enabled) => void handleToggle(rule, enabled)}
onEdit={(rule) => setEditor({ mode: 'edit', rule })}
onEdit={(rule) => {
// 记住这条,切类型回来时能恢复 —— 不记住就会被悄悄换成内置日报。
lastDailyTargetRef.current = rule
setEditor({ mode: 'edit', rule, ruleType: rule.ruleType ?? 'daily_report' })
}}
onDelete={(rule) => setPendingDelete(rule)}
onCreate={() => setEditor({ mode: 'create', rule: null })}
onCreate={() => {
// 新建是"还没有对象",所以不记住任何规则。
lastDailyTargetRef.current = null
setEditor({ mode: 'create', rule: null, ruleType: 'daily_report' })
}}
onEditLeaveNotification={() =>
setEditor({ mode: 'edit', rule: null, ruleType: 'leave_notification' })
}
/>
) : (
<ExecutionLogPanel
@@ -127,6 +127,9 @@ export function ExecutionDetailDrawer({
</>
)}
</div>
{step.detail ? (
<p className="automation-step-detail">{step.detail}</p>
) : null}
{step.error ? <p className="automation-step-error">{step.error}</p> : null}
</div>
</li>
@@ -0,0 +1,284 @@
import * as React from 'react'
import { Button, RadioGroup, RadioGroupItem, Switch } from '../../components/ui'
import {
LEAVE_NOTIFICATION_TARGET_OPTIONS,
createDefaultLeaveNotificationRule,
type AutomationRule,
type LeaveNotificationConfig,
type LeaveNotificationTargetType
} from '../../../../shared/automation'
import { insertGroupNamePlaceholder } from '../../../../shared/group-exit-monitor'
import { LeaveNotificationPreview } from './LeaveNotificationPreview'
import {
LeaveNotificationTargetPicker,
type LeaveNotificationContactOption
} from './LeaveNotificationTargetPicker'
import { LeaveNotificationTemplateEditor } from './LeaveNotificationTemplateEditor'
/**
* LeaveNotificationEditor —— 「自动化 → 规则 → 退群通知」。
*
* 这是**真实配置页**:保存会落盘成一条 singleton 规则(`builtin-leave-notification`),
* 由 `AutomationService.handleGroupExit` 在退群事件到来时执行。
*
* 职责边界在 UI 上一眼可见:
* 退群监控 = 监测哪些群有人退出(**范围完全由它管**);自动化 = 检测到之后发到哪。
* 所以这里**没有**「触发范围」二次筛选,规则级启停也**只有**顶部一处。
*/
export interface LeaveNotificationSaveInput {
enabled: boolean
config: LeaveNotificationConfig
}
export interface LeaveNotificationEditorProps {
/** 已持久化的退群通知规则(singleton,永远存在)。 */
rule: AutomationRule
/** 「指定好友」的可选项(main 侧已过滤,只含可发送的个人联系人)。 */
contacts: LeaveNotificationContactOption[]
/** 真实已监控群聊数量(来自退群监控,自动化不维护副本)。 */
monitoredCount: number
saving: boolean
/** 发送能力不完整时的一句话提示;为 undefined 表示能力正常。 */
sendCapabilityWarning?: string
/** 「管理监控群聊 →」—— 跳退群监控的管理群聊页,纯导航。 */
onOpenMonitoredGroups?: () => void
onCancel: () => void
onSave: (input: LeaveNotificationSaveInput) => void
}
function initialConfig(rule: AutomationRule): LeaveNotificationConfig {
return (
rule.leaveNotification ??
createDefaultLeaveNotificationRule(Date.now()).leaveNotification ?? {
target: { type: 'file_transfer' },
template: ''
}
)
}
export function LeaveNotificationEditor({
rule,
contacts,
monitoredCount,
saving,
sendCapabilityWarning,
onOpenMonitoredGroups,
onCancel,
onSave
}: LeaveNotificationEditorProps): React.ReactElement {
const [enabled, setEnabled] = React.useState(rule.enabled)
const [config, setConfig] = React.useState<LeaveNotificationConfig>(() => initialConfig(rule))
// 切换编辑对象 / 保存后回填时重建草稿,避免把上一份改动带过来。
React.useEffect(() => {
setEnabled(rule.enabled)
setConfig(initialConfig(rule))
}, [rule])
const targetType = config.target.type
const selectedContactId = String(config.target.contactId || '')
const selectedContactName = React.useMemo(
() => contacts.find((contact) => contact.id === selectedContactId)?.name ?? '',
[contacts, selectedContactId]
)
/** 选了「指定好友」但那个联系人已经不在了(被删 / 不可发送 / 找不到)。 */
const contactMissing =
targetType === 'contact' && Boolean(selectedContactId) && !selectedContactName
/**
* 通知不是发给「当前群聊」、但内容里又没有群名 → 收件人认不出是哪个群。
*
* 只在非「当前群聊」时要求:发给群内时,群名是冗余的。
*/
const missingGroupName =
targetType !== 'source_chat' && !config.template.includes('{groupName}')
const selectTarget = (next: LeaveNotificationTargetType): void => {
setConfig((current) => ({
...current,
// 换目标类型时清掉上一个类型才有的字段,避免存下互相矛盾的配置。
// 同时**清除迁移遗留的「待重选」标记** —— 用户已经做了选择。
target: next === 'contact' ? { type: next, contactId: current.target.contactId } : { type: next }
}))
}
const handleSave = (): void => {
onSave({ enabled, config: { ...config, targetNeedsReview: undefined } })
}
return (
<div className="automation-editor">
<header className="automation-editor-header">
<div>
<h2>退群通知</h2>
<p>当已监控群聊检测到成员退出时,自动发送通知。</p>
<div className="automation-leave-status-row">
<span className="automation-field-label">启用这条自动化</span>
<Switch
checked={enabled}
onCheckedChange={setEnabled}
aria-label="启用这条自动化"
/>
</div>
</div>
<div className="automation-editor-actions">
<Button variant="ghost" onClick={onCancel} disabled={saving}>
取消
</Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? '保存中…' : '保存'}
</Button>
</div>
</header>
{config.targetNeedsReview ? (
<p className="automation-notice warning">
旧版「通知群聊」是逐群配置的,无法无损转换成新的单一通知目标。已保留你的通知模板,
但<strong>在你重新选择通知目标之前不会发送任何通知</strong>。
</p>
) : null}
{contactMissing ? (
<p className="automation-notice warning">
之前选择的联系人已不存在或当前无法发送,请重新选择通知目标。
</p>
) : null}
{sendCapabilityWarning ? (
<p className="automation-notice warning">{sendCapabilityWarning}</p>
) : null}
<div className="automation-editor-body">
<div className="automation-editor-form">
<section className="automation-section">
<div className="automation-section-heading">
<h3>1 · 什么时候触发</h3>
</div>
<div className="automation-inline-row">
<div>
<span className="automation-field-label">触发事件</span>
<span className="automation-static-value">检测到群成员退出</span>
<small>这是「退群监控」提供的事件,由本规则响应。</small>
</div>
</div>
<div className="automation-inline-row">
<div>
<span className="automation-field-label">监控来源</span>
<span className="automation-static-value">退群监控</span>
</div>
</div>
{/*
只读摘要 + 唯一入口。
「哪些群被监控」完全由退群监控决定,本规则不做二次筛选 ——
所以这里不给任何勾选控件,只把当前范围如实说出来。
*/}
<div className="automation-leave-groups-card">
<div>
<span className="automation-field-label">已监控群聊</span>
<strong>{monitoredCount} 个群聊</strong>
<small>监控范围由「退群监控」管理</small>
</div>
<Button
variant="outline"
size="sm"
onClick={onOpenMonitoredGroups}
disabled={!onOpenMonitoredGroups}
>
管理监控群聊 →
</Button>
</div>
</section>
<section className="automation-section">
<div className="automation-section-heading">
<h3>2 · 通知发送到哪里</h3>
</div>
<RadioGroup
value={targetType}
onValueChange={(value) => selectTarget(value as LeaveNotificationTargetType)}
aria-label="通知发送到哪里"
className="automation-leave-targets"
>
{LEAVE_NOTIFICATION_TARGET_OPTIONS.map((option) => (
<div key={option.type} className="automation-leave-radio option">
<RadioGroupItem value={option.type} id={`leave-target-${option.type}`} />
<div className="automation-leave-radio-body">
<label htmlFor={`leave-target-${option.type}`}>{option.label}</label>
<small>{option.description}</small>
</div>
</div>
))}
</RadioGroup>
{targetType === 'contact' ? (
<div className="automation-field">
<span className="automation-field-label">选择好友</span>
<LeaveNotificationTargetPicker
contacts={contacts}
selectedId={selectedContactId}
onSelect={(id) =>
setConfig((current) => ({
...current,
target: { type: 'contact', contactId: id },
targetNeedsReview: undefined
}))
}
ariaLabel="选择好友"
searchPlaceholder="搜索好友"
emptyText="没有可发送的联系人"
/>
</div>
) : null}
</section>
<section className="automation-section">
<div className="automation-section-heading">
<h3>3 · 通知内容</h3>
</div>
{/*
护栏:通知发给「别人」时,不带群名的通知等于「张三退群了」。
主进程已替用户补过一次(一次性迁移),这里兜的是"用户后来主动删掉了"的情况 ——
只提示 + 一键补上,不强行改用户文本。
*/}
{missingGroupName ? (
<div className="automation-notice warning automation-notice-stack">
<p>这份内容里没有群聊名,收件人看不出是哪个群退的人。</p>
<Button
variant="outline"
size="sm"
onClick={() =>
setConfig((current) => ({
...current,
template: insertGroupNamePlaceholder(current.template)
}))
}
>
补上群聊名
</Button>
</div>
) : null}
<LeaveNotificationTemplateEditor
template={config.template}
onChange={(next) =>
setConfig((current) => ({ ...current, template: next }))
}
/>
</section>
</div>
<LeaveNotificationPreview
config={config}
contactName={selectedContactName}
monitoredCount={monitoredCount}
/>
</div>
</div>
)
}
@@ -0,0 +1,109 @@
import * as React from 'react'
import {
describeMonitoredScope,
leaveNotificationTargetLabel,
type LeaveNotificationConfig
} from '../../../../shared/automation'
import {
createGroupExitPreviewSample,
renderLeaveNotificationText
} from '../../../../shared/group-exit-event'
/**
* LeaveNotificationPreview —— 右侧「效果预览」。
*
* 与左侧表单逐项联动:改模板 → 气泡变;改发送目标 → 顶部收件会话与底部「通知目标」一起变。
*
* **绝不发送**:即使规则已经真实接通,这里也只做渲染 ——
* 它拿的是 `GROUP_EXIT_PREVIEW_SAMPLE` 样本事件,不碰 `WechatActionGateway`。
* 模板渲染复用 shared 的唯一实现,所以预览与实发不会出现两套结果。
*/
export interface LeaveNotificationPreviewProps {
config: LeaveNotificationConfig
/** 「指定好友」已选联系人的显示名;未选 / 不是该目标时传空串。 */
contactName: string
/** 真实已监控群聊数量(来自退群监控)。 */
monitoredCount: number
}
export function LeaveNotificationPreview({
config,
contactName,
monitoredCount
}: LeaveNotificationPreviewProps): React.ReactElement {
// 样本事件只服务预览;时间取"此刻",不伪造历史日期。
const sample = React.useMemo(() => createGroupExitPreviewSample(Date.now()), [])
const chatTitle = React.useMemo((): string => {
switch (config.target.type) {
case 'source_chat':
return sample.groupName || '当前群聊'
case 'self':
return '我'
case 'file_transfer':
return leaveNotificationTargetLabel('file_transfer')
case 'contact':
return contactName.trim() || '未选择好友'
}
}, [config.target.type, contactName, sample.groupName])
const targetSummary = React.useMemo((): string => {
switch (config.target.type) {
case 'source_chat':
return '发生退群事件的群聊'
case 'self':
return '我'
case 'file_transfer':
return leaveNotificationTargetLabel('file_transfer')
case 'contact':
return contactName.trim() || '未选择好友'
}
}, [config.target.type, contactName])
const bubbleText = renderLeaveNotificationText(sample, config.template).trim() || '(模板为空)'
return (
<section className="automation-preview" aria-label="效果模拟预览">
<div className="automation-preview-heading">
<h3>效果预览</h3>
<span className="automation-preview-badge">仅预览,不会真实发送</span>
</div>
<div className="automation-preview-phone">
<div className="automation-preview-chat-title" data-testid="leave-preview-recipient">
<span>{chatTitle}</span>
</div>
<div className="automation-preview-chat">
{/* 退群监控产生的系统事件,不来自任何消息文本 */}
<p className="automation-preview-system">检测到成员退出</p>
<div className="automation-chat-row outgoing">
<div className="automation-chat-body">
<span className="automation-chat-name">我</span>
<span className="automation-chat-bubble pre">{bubbleText}</span>
</div>
<span className="automation-chat-avatar self" aria-hidden="true">
我
</span>
</div>
</div>
</div>
<dl className="automation-preview-facts">
<div>
<dt>触发事件</dt>
<dd>成员退出</dd>
</div>
<div>
<dt>监控来源</dt>
<dd>{describeMonitoredScope(monitoredCount)}</dd>
</div>
<div>
<dt>通知目标</dt>
<dd>{targetSummary}</dd>
</div>
</dl>
</section>
)
}
@@ -0,0 +1,75 @@
import * as React from 'react'
import { Input, RadioGroup, RadioGroupItem } from '../../components/ui'
/**
* LeaveNotificationTargetPicker —— 「指定好友」展开后的联系人选择器。
*
* **单选**:通知只能发给一个联系人,所以这里是 radio 而不是 checkbox ——
* 多选联系人不在范围内,也不该顺手加。
*
* 联系人清单由 main 侧提供(`listSendableContacts`),**已经过滤掉**
* 群聊 / 公众号 / 文件传输助手 / 自己。这里不再二次筛选,避免两处规则漂移。
*/
export interface LeaveNotificationContactOption {
/** 稳定 id(wxid)。保存与发送都用它,**不用显示名**。 */
id: string
name: string
}
export interface LeaveNotificationTargetPickerProps {
contacts: LeaveNotificationContactOption[]
selectedId: string
onSelect: (id: string) => void
searchPlaceholder: string
emptyText: string
ariaLabel: string
}
export function LeaveNotificationTargetPicker({
contacts,
selectedId,
onSelect,
searchPlaceholder,
emptyText,
ariaLabel
}: LeaveNotificationTargetPickerProps): React.ReactElement {
const [filter, setFilter] = React.useState('')
const visibleContacts = React.useMemo(() => {
const needle = filter.trim().toLowerCase()
if (!needle) return contacts
return contacts.filter(
(contact) =>
contact.name.toLowerCase().includes(needle) || contact.id.toLowerCase().includes(needle)
)
}, [contacts, filter])
return (
<RadioGroup
value={selectedId}
onValueChange={onSelect}
aria-label={ariaLabel}
className="automation-leave-contact-picker"
>
<Input
value={filter}
onChange={(event) => setFilter(event.target.value)}
placeholder={searchPlaceholder}
aria-label={searchPlaceholder}
/>
<div className="automation-leave-contact-list">
{visibleContacts.length === 0 ? (
<p className="automation-group-empty">{emptyText}</p>
) : (
visibleContacts.map((contact) => (
<div key={contact.id} className="automation-leave-radio option">
<RadioGroupItem value={contact.id} id={`leave-contact-${contact.id}`} />
<label htmlFor={`leave-contact-${contact.id}`}>{contact.name}</label>
</div>
))
)}
</div>
</RadioGroup>
)
}
@@ -0,0 +1,100 @@
import * as React from 'react'
import { Textarea } from '../../components/ui'
import {
GROUP_EXIT_NOTIFICATION_PLACEHOLDER_LABELS,
GROUP_EXIT_NOTIFICATION_TEMPLATE_MAX_LENGTH,
GROUP_EXIT_NOTIFICATION_TEMPLATE_PLACEHOLDERS
} from '../../../../shared/group-exit-monitor'
/** 在光标处插入一个变量;没有光标位置就追加到末尾。纯 UI 辅助。 */
function insertTemplateVariable(
template: string,
variable: string,
selectionStart?: number | null,
selectionEnd?: number | null
): string {
const token = `{${variable}}`
const start = typeof selectionStart === 'number' ? selectionStart : template.length
const end = typeof selectionEnd === 'number' ? selectionEnd : start
return `${template.slice(0, start)}${token}${template.slice(end)}`
}
/**
* LeaveNotificationTemplateEditor —— 「3 · 通知内容」。
*
* 占位符清单直接复用 shared 常量 —— UI 能插的变量与真实解释逻辑必须是同一份,
* 否则会出现"插得进去、发出去不变"的变量。
*/
export interface LeaveNotificationTemplateEditorProps {
template: string
onChange: (next: string) => void
}
export function LeaveNotificationTemplateEditor({
template,
onChange
}: LeaveNotificationTemplateEditorProps): React.ReactElement {
const textareaRef = React.useRef<HTMLTextAreaElement | null>(null)
const insertVariable = (variable: string): void => {
const element = textareaRef.current
onChange(
insertTemplateVariable(
template,
variable,
element?.selectionStart ?? null,
element?.selectionEnd ?? null
)
)
}
return (
<div className="automation-leave-template">
<Textarea
ref={textareaRef}
id="automation-leave-template"
aria-label="退群通知模板内容"
value={template}
rows={11}
maxLength={GROUP_EXIT_NOTIFICATION_TEMPLATE_MAX_LENGTH}
onChange={(event) => onChange(event.target.value)}
className="automation-leave-template-editor"
/>
<div className="automation-leave-template-meta">
<span>支持</span>
<span className="automation-leave-template-chips">
{GROUP_EXIT_NOTIFICATION_TEMPLATE_PLACEHOLDERS.map((placeholder) => (
<button
key={placeholder}
type="button"
className="automation-leave-template-chip"
// 悬停就能看到含义 —— 光看 {groupRemark} 这种符号谁也猜不出它是什么。
title={`{${placeholder}}:${GROUP_EXIT_NOTIFICATION_PLACEHOLDER_LABELS[placeholder]}`}
onClick={() => insertVariable(placeholder)}
aria-label={`插入变量 ${placeholder}`}
>
{`{${placeholder}}`}
</button>
))}
</span>
<span className="automation-leave-template-count">
{template.length}/{GROUP_EXIT_NOTIFICATION_TEMPLATE_MAX_LENGTH}
</span>
</div>
{/*
先把最容易混的两项说清楚,再说兜底。
实测有人把 {groupRemark} 当成群名(它其实是成员在本群的昵称),
于是以为通知里已经有群名了 —— 所以这句必须放在最前面、直说区别。
*/}
<p className="automation-leave-template-note">
{`{groupName}`} 是<strong>群聊名</strong>;{`{groupRemark}`} 是退群成员
<strong>在本群的昵称</strong>(不是群名)。取不到值时:{`{groupName}`}、{`{user}`}、
{`{wxid}`} 显示「未读取到」,{`{groupRemark}`} 显示「未设置」。
悬停变量可看含义;通知只发送到上面选定的目标。
</p>
</div>
)
}
@@ -1,6 +1,9 @@
import * as React from 'react'
import {
AUTOMATION_RULE_TEMPLATES,
BUILTIN_LEAVE_NOTIFICATION_RULE_ID,
describeLeaveNotificationTarget,
describeMonitoredScope,
describeRuleActions,
describeRuleTrigger,
type AutomationRule
@@ -8,24 +11,36 @@ import {
import { Button, Spinner, Switch } from '../../components/ui'
/**
* RuleListPanel —— 规则列表(对应 Stitch 设计稿 2 的「规则」tab)。
* RuleListPanel —— 规则列表(「规则」tab)。
*
* 两个区块:
* 1. **已配置的规则** —— 真实可运行,带启停开关;
* 1. **已配置的自动化** —— 全部是**真规则**,带启停开关;
* 2. **模板入口** —— 尚未接通的能力必须**如实标注**为「即将支持」。
* 这里最容易犯的错是把设计稿里的卡片全渲染成"已启用"的样子,
* 用户点进去发现是空的 —— 那比拼不出这个功能更糟。
*
* 「退群通知」现在是真规则:状态按真实 rule 展示(运行中 / 已关闭 / 配置异常),
* 不再有「待配置」「UI 预览」这类占位标识。
*/
export interface LeaveNotificationCardModel {
rule: AutomationRule
/** 真实已监控群聊数量(来自退群监控)。 */
monitoredCount: number
/** 「指定好友」目标的显示名;未选 / 不是该目标时为空串。 */
contactName: string
}
export interface RuleListPanelProps {
rules: AutomationRule[]
groups: Array<{ id: string; name: string }>
loading: boolean
busyRuleId: string | null
leaveNotification: LeaveNotificationCardModel | null
onToggle: (rule: AutomationRule, enabled: boolean) => void
onEdit: (rule: AutomationRule) => void
onDelete: (rule: AutomationRule) => void
onCreate: () => void
/** 打开「退群通知」规则类型。 */
onEditLeaveNotification: () => void
}
/** 把规则里的 conversationIds 翻成群名;找不到时显示占位而不是裸 id。 */
@@ -40,22 +55,41 @@ function scopeLabel(rule: AutomationRule, groups: Array<{ id: string; name: stri
return `${names.slice(0, 2).join('、')} 等 ${names.length} 个群`
}
/**
* 退群通知的真实状态。
*
* 「配置异常」只对应一种真实情况:迁移时旧「通知群聊」无法无损映射,
* 规则被标成待重选目标 —— 这时它不会发送,需要用户处理。
*/
function leaveStatus(rule: AutomationRule): { label: string; tone: 'on' | 'off' | 'pending' } {
if (rule.leaveNotification?.targetNeedsReview) return { label: '配置异常', tone: 'pending' }
return rule.enabled ? { label: '运行中', tone: 'on' } : { label: '已关闭', tone: 'off' }
}
export function RuleListPanel({
rules,
groups,
loading,
busyRuleId,
leaveNotification,
onToggle,
onEdit,
onDelete,
onCreate
onCreate,
onEditLeaveNotification
}: RuleListPanelProps): React.ReactElement {
// 退群通知有自己的卡片,别在「已配置的自动化」里重复渲染一遍。
const configuredRules = rules.filter((rule) => rule.id !== BUILTIN_LEAVE_NOTIFICATION_RULE_ID)
const leaveRule = leaveNotification?.rule ?? null
const leaveState = leaveRule ? leaveStatus(leaveRule) : null
const configuredCount = configuredRules.length + (leaveRule ? 1 : 0)
return (
<div className="automation-rule-list">
<section className="automation-rule-group">
<div className="automation-section-heading">
<h2>已配置的自动化</h2>
<span className="automation-section-note">{rules.length} 条</span>
<span className="automation-section-note">{configuredCount} 条</span>
</div>
{loading ? (
@@ -63,68 +97,123 @@ export function RuleListPanel({
<Spinner />
<span>正在读取规则…</span>
</div>
) : rules.length === 0 ? (
<div className="automation-empty-card">
<p>还没有任何自动化。</p>
<Button onClick={onCreate}>新建自动化</Button>
</div>
) : (
rules.map((rule) => (
<article key={rule.id} className={`automation-rule-card ${rule.enabled ? '' : 'disabled'}`}>
<div className="automation-rule-card-main">
<div className="automation-rule-card-title">
<h3>{rule.name}</h3>
<span className={`automation-rule-state ${rule.enabled ? 'on' : 'off'}`}>
{rule.enabled ? '运行中' : '已停用'}
</span>
<>
{!configuredRules.length && !leaveRule ? (
<div className="automation-empty-card">
<p>还没有任何自动化。</p>
<Button onClick={onCreate}>新建自动化</Button>
</div>
) : null}
{configuredRules.map((rule) => (
<article
key={rule.id}
className={`automation-rule-card ${rule.enabled ? '' : 'disabled'}`}
>
<div className="automation-rule-card-main">
<div className="automation-rule-card-title">
<h3>{rule.name}</h3>
<span className={`automation-rule-state ${rule.enabled ? 'on' : 'off'}`}>
{rule.enabled ? '运行中' : '已停用'}
</span>
</div>
<dl className="automation-rule-meta">
<div>
<dt>触发</dt>
<dd>{describeRuleTrigger(rule)}</dd>
</div>
<div>
<dt>范围</dt>
<dd>{scopeLabel(rule, groups)}</dd>
</div>
<div>
<dt>动作</dt>
<dd>{describeRuleActions(rule)}</dd>
</div>
<div>
<dt>间隔</dt>
<dd>{rule.cooldownSeconds > 0 ? `${rule.cooldownSeconds} 秒` : '不限制'}</dd>
</div>
</dl>
</div>
<dl className="automation-rule-meta">
<div>
<dt>触发</dt>
<dd>{describeRuleTrigger(rule)}</dd>
<div className="automation-rule-card-side">
<Switch
checked={rule.enabled}
disabled={busyRuleId === rule.id}
onCheckedChange={(checked) => onToggle(rule, checked)}
aria-label={`${rule.name} 启停`}
/>
<Button variant="outline" size="sm" onClick={() => onEdit(rule)}>
编辑
</Button>
<Button
variant="link"
size="sm"
onClick={() => onDelete(rule)}
disabled={busyRuleId === rule.id}
>
删除
</Button>
</div>
</article>
))}
{leaveRule && leaveState ? (
<article
className={`automation-rule-card ${leaveRule.enabled && leaveState.tone === 'on' ? '' : 'disabled'}`}
>
<div className="automation-rule-card-main">
<div className="automation-rule-card-title">
<h3>{leaveRule.name}</h3>
<span className={`automation-rule-state ${leaveState.tone}`}>
{leaveState.label}
</span>
</div>
<div>
<dt>范围</dt>
<dd>{scopeLabel(rule, groups)}</dd>
</div>
<div>
<dt>动作</dt>
<dd>{describeRuleActions(rule)}</dd>
</div>
<div>
<dt>间隔</dt>
<dd>{rule.cooldownSeconds > 0 ? `${rule.cooldownSeconds} 秒` : '不限制'}</dd>
</div>
</dl>
</div>
<div className="automation-rule-card-side">
<Switch
checked={rule.enabled}
disabled={busyRuleId === rule.id}
onCheckedChange={(checked) => onToggle(rule, checked)}
aria-label={`${rule.name} 启停`}
/>
<Button variant="outline" size="sm" onClick={() => onEdit(rule)}>
编辑
</Button>
<Button
variant="link"
size="sm"
onClick={() => onDelete(rule)}
disabled={busyRuleId === rule.id}
>
删除
</Button>
</div>
</article>
))
<dl className="automation-rule-meta">
<div>
<dt>触发</dt>
<dd>{describeRuleTrigger(leaveRule)}</dd>
</div>
<div>
<dt>范围</dt>
<dd>{describeMonitoredScope(leaveNotification?.monitoredCount ?? 0)}</dd>
</div>
<div>
<dt>动作</dt>
<dd>{describeRuleActions(leaveRule)}</dd>
</div>
<div>
<dt>目标</dt>
<dd>
{describeLeaveNotificationTarget(
leaveRule.leaveNotification,
leaveNotification?.contactName
)}
</dd>
</div>
</dl>
</div>
<div className="automation-rule-card-side">
<Switch
checked={leaveRule.enabled}
disabled={busyRuleId === leaveRule.id}
onCheckedChange={(checked) => onToggle(leaveRule, checked)}
aria-label={`${leaveRule.name} 启停`}
/>
<Button variant="outline" size="sm" onClick={onEditLeaveNotification}>
编辑
</Button>
</div>
</article>
) : null}
</>
)}
</section>
<section className="automation-rule-group">
<div className="automation-section-heading">
<h2>更多模板</h2>
<span className="automation-section-note">后续版本开放</span>
</div>
<div className="automation-template-grid">
{AUTOMATION_RULE_TEMPLATES.map((template) => (
@@ -134,9 +223,7 @@ export function RuleListPanel({
<span className="automation-template-flag">即将支持</span>
</div>
<p>{template.description}</p>
{template.unavailableReason ? (
<small>{template.unavailableReason}</small>
) : null}
{template.unavailableReason ? <small>{template.unavailableReason}</small> : null}
</article>
))}
</div>
@@ -4,6 +4,7 @@ import type {
AutomationRuleDraft,
AutomationStatusSummary
} from '../../../../../shared/automation'
import type { GroupExitMonitorState } from '../../../../../shared/group-exit-monitor'
/**
* `window.api` 的薄封装。
@@ -91,5 +92,31 @@ export const automationApi = {
clearExecutions: (): Promise<boolean> => invoke('clearAutomationExecutions', false),
listGroups: (): Promise<AutomationGroupOption[]> => invoke('listAutomationGroups', [])
listGroups: (): Promise<AutomationGroupOption[]> => invoke('listAutomationGroups', []),
/** 保存「退群通知」规则(singleton upsert,id 由 main 侧固定)。 */
saveLeaveNotificationRule: (draft: AutomationRuleDraft): Promise<AutomationRule | null> =>
invoke<AutomationRule | null>('saveLeaveNotificationRule', null, draft),
/**
* 「指定好友」的可选项。
*
* 过滤(群聊 / 公众号 / 文件传输助手 / 自己)在 main 侧完成 ——
* 那里才知道当前登录账号是谁。渲染层不再筛一遍,避免两套规则漂移。
*/
listSendableContacts: (): Promise<AutomationGroupOption[]> =>
invoke('listSendableContacts', []),
/**
* 真实已监控群聊数量。
*
* 数据源是**退群监控**,自动化不维护副本(§「Automation 不做二次 group filter」)。
* 取数与退群监控页保持同一表达式,否则两个页面会对同一个数字给出不同答案。
*/
async getMonitoredGroupCount(): Promise<number> {
const state = await invoke<GroupExitMonitorState | null>('getGroupExitMonitorState', null)
if (!state) return 0
if (state.monitorSelectionConfigured) return (state.monitoredRoomIds || []).length
return Number(state.monitoredGroupCount) || 0
}
}
@@ -4,16 +4,12 @@ import {
Checkbox,
EmptyState,
Input,
Popover,
PopoverContent,
PopoverTrigger,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Switch,
Textarea,
Tooltip,
TooltipContent,
TooltipTrigger
@@ -21,20 +17,30 @@ import {
import type { Contact } from '../../../../shared/types'
import {
GROUP_EXIT_NOTIFICATION_TEMPLATE,
GROUP_EXIT_NOTIFICATION_TEMPLATE_MAX_LENGTH,
renderGroupExitMonitorNotification,
validateGroupExitNotificationTemplate,
type GroupExitMonitorEvent,
type GroupExitMonitorState
} from '../../../../shared/group-exit-monitor'
import { BUILTIN_LEAVE_NOTIFICATION_RULE_ID } from '../../../../shared/automation'
import type { PersonalWechatSendCapability } from '../../../../shared/personal-wechat'
import { LeaveMonitorAutomationEntry } from './LeaveMonitorAutomationEntry'
type GroupSelectionFilter = 'all' | 'selected' | 'unselected'
/** 从「自动化 → 退群通知」跳回来时直接打开哪一页。 */
export interface GroupExitMonitorOpenViewRequest {
view: 'events' | 'manage'
requestId: number
}
interface GroupExitMonitorWorkspaceProps {
dbReady: boolean
contacts?: Contact[]
onOpenSendSettings?: () => void
/** 深链请求:直接落到「退群监控 → 管理群聊」。 */
openViewRequest?: GroupExitMonitorOpenViewRequest | null
/** 「退群通知自动化 →」:跳到自动化里对应的规则类型(纯导航)。 */
onOpenLeaveNotificationAutomation?: () => void
}
const EMPTY_STATE: GroupExitMonitorState = {
@@ -45,7 +51,6 @@ const EMPTY_STATE: GroupExitMonitorState = {
monitoredGroupCount: 0,
monitorSelectionConfigured: true,
monitoredRoomIds: [],
notificationRoomIds: [],
lastReadAt: 0,
unreadCount: 0
}
@@ -85,56 +90,6 @@ const sendCapabilityLabel = (capability: PersonalWechatSendCapability | null): s
const sendCapabilityTone = (capability: PersonalWechatSendCapability | null): string =>
capability?.ready && capability.capabilities.text ? 'ready' : 'unready'
function EventNotificationStatus({
event,
onOpenSendSettings,
onResend,
resending
}: {
event: GroupExitMonitorState['events'][number]
onOpenSendSettings?: () => void
onResend?: () => void
resending?: boolean
}): React.ReactElement {
const status = event.notificationStatus || event.notification?.status || 'not_requested'
const details =
status === 'sent'
? { label: '已通知当前群聊', tone: 'sent', description: '' }
: status === 'pending'
? { label: '正在通知当前群聊', tone: 'pending', description: '' }
: status === 'blocked'
? { label: '通知已拦截', tone: 'blocked', description: '该操作未通过发送策略检查。' }
: status === 'failed'
? {
label: '通知未发送',
tone: 'failed',
description:
event.notification?.errorCode === 'SEND_CAPABILITY_UNAVAILABLE' ||
event.notification?.errorCode === 'SEND_NOT_READY'
? '当前微信发送能力不可用,退群事件已正常记录。'
: '退群事件已正常记录,但通知发送失败。'
}
: { label: '仅记录', tone: 'recorded', description: '' }
return (
<div className={`exit-monitor-event-notification ${details.tone}`}>
<span>{details.label}</span>
{details.description ? <small>{details.description}</small> : null}
{(event.notification?.errorCode === 'SEND_CAPABILITY_UNAVAILABLE' ||
event.notification?.errorCode === 'SEND_NOT_READY') &&
onOpenSendSettings ? (
<Button variant="link" size="sm" onClick={onOpenSendSettings}>
去设置
</Button>
) : null}
{status !== 'sent' && status !== 'pending' && onResend ? (
<Button variant="link" size="sm" onClick={onResend} disabled={resending}>
{resending ? '发送中...' : '重新发送'}
</Button>
) : null}
</div>
)
}
function SendCapabilityStatus({
capability,
className = ''
@@ -160,88 +115,12 @@ function SendCapabilityStatus({
)
}
function GroupExitNotificationTemplate({
template,
saving,
error,
onSave
}: {
template: string
saving: boolean
error: string
onSave: (template: string) => Promise<boolean>
}): React.ReactElement {
const [open, setOpen] = React.useState(false)
const [draft, setDraft] = React.useState(template)
const [validationError, setValidationError] = React.useState('')
React.useEffect(() => {
if (!open) setDraft(template)
}, [open, template])
const handleOpenChange = (nextOpen: boolean): void => {
setOpen(nextOpen)
setValidationError('')
if (nextOpen) setDraft(template)
}
const saveDraft = async (): Promise<void> => {
const result = validateGroupExitNotificationTemplate(draft)
if (!result.valid || !result.template) {
setValidationError(result.error || '模板无效')
return
}
setValidationError('')
const saved = await onSave(result.template)
if (saved) setOpen(false)
}
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" aria-label="查看退群监测模板">
模板
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="exit-monitor-template-popover">
<div className="exit-monitor-template-heading">
<div>
<span className="exit-monitor-eyebrow">消息格式</span>
<h2>退群监测模板</h2>
</div>
<span className="exit-monitor-template-status">发送时使用</span>
</div>
<Textarea
id="exit-monitor-notification-template"
aria-label="退群监测模板内容"
value={draft}
rows={10}
maxLength={GROUP_EXIT_NOTIFICATION_TEMPLATE_MAX_LENGTH}
onChange={(event) => setDraft(event.target.value)}
className="exit-monitor-template-editor"
/>
<div className="exit-monitor-template-meta">
<span>
支持 {`{user}`}、{`{groupRemark}`}、{`{wxid}`}、{`{previousCount}`}、{`{currentCount}`}
、{`{time}`}
</span>
<span>
{draft.length}/{GROUP_EXIT_NOTIFICATION_TEMPLATE_MAX_LENGTH}
</span>
</div>
<p>退群时会替换占位符;群备注指退群前的群内昵称,没有群备注时显示“未设置”。</p>
{validationError || error ? (
<p className="exit-monitor-template-error">{validationError || error}</p>
) : null}
<div className="exit-monitor-template-actions">
<Button size="sm" onClick={() => void saveDraft()} disabled={saving}>
{saving ? '保存中...' : '保存模板'}
</Button>
</div>
</PopoverContent>
</Popover>
)
}
/*
* 「退群监测模板」编辑入口**已从这里移除**。
*
* 模板的唯一编辑处现在是「自动化 → 规则 → 退群通知 → 3 · 通知内容」。
* 退群监控只产生退群事实,不再持有通知模板 —— 两处可编辑必然漂移。
*/
function GroupAvatar({ contact }: { contact: Contact }): React.ReactElement {
const name = contactDisplayName(contact)
@@ -256,7 +135,6 @@ function GroupAvatar({ contact }: { contact: Contact }): React.ReactElement {
interface ManageGroupsProps {
groups: Contact[]
selectedRoomIds: Set<string>
notificationRoomIds: Set<string>
sendCapability: PersonalWechatSendCapability | null
keyword: string
listFilter: GroupSelectionFilter
@@ -265,20 +143,14 @@ interface ManageGroupsProps {
onKeywordChange: (keyword: string) => void
onListFilterChange: (filter: GroupSelectionFilter) => void
onToggle: (roomId: string) => void
onToggleNotification: (roomId: string) => void
onToggleAll: () => void
onSave: () => void
onCancel: () => void
notificationTemplate: string
templateSaving: boolean
templateError: string
onSaveTemplate: (template: string) => Promise<boolean>
}
function ManageGroups({
groups,
selectedRoomIds,
notificationRoomIds,
sendCapability,
keyword,
listFilter,
@@ -287,14 +159,9 @@ function ManageGroups({
onKeywordChange,
onListFilterChange,
onToggle,
onToggleNotification,
onToggleAll,
onSave,
onCancel,
notificationTemplate,
templateSaving,
templateError,
onSaveTemplate
onCancel
}: ManageGroupsProps): React.ReactElement {
const lowerKeyword = keyword.trim().toLowerCase()
const filteredGroups = React.useMemo(() => {
@@ -326,12 +193,6 @@ function ManageGroups({
<p>选择需要持续关注成员变化的群聊,保存后会重新建立成员快照。</p>
</div>
<div className="exit-monitor-manage-header-status">
<GroupExitNotificationTemplate
template={notificationTemplate}
saving={templateSaving}
error={templateError}
onSave={onSaveTemplate}
/>
<SendCapabilityStatus capability={sendCapability} />
<div className="exit-monitor-manage-count" aria-live="polite">
已选择 <strong>{selectedRoomIds.size}</strong> 个群聊
@@ -394,7 +255,6 @@ function ManageGroups({
const roomId = contactRoomId(contact)
const name = contactDisplayName(contact)
const checked = selectedRoomIds.has(roomId)
const notificationChecked = notificationRoomIds.has(roomId)
return (
<div key={roomId} className={`exit-monitor-group-row ${checked ? 'selected' : ''}`}>
<Checkbox
@@ -412,15 +272,6 @@ function ManageGroups({
<span className="exit-monitor-group-row-status">
{checked ? '监控中' : '未选择'}
</span>
<label className="exit-monitor-group-notification">
<Checkbox
checked={notificationChecked}
disabled={!checked}
onCheckedChange={() => onToggleNotification(roomId)}
aria-label={`是否通知${name}`}
/>
<span>通知群聊</span>
</label>
</div>
)
})}
@@ -435,7 +286,9 @@ function ManageGroups({
</section>
<footer className="exit-monitor-manage-footer">
<span>保存后将从新的成员快照开始记录退群动态;通知仅发送到已勾选群聊。</span>
<span>
保存后将从新的成员快照开始记录退群动态;退群通知发到哪里,请在「自动化 → 退群通知」里配置。
</span>
<div>
<Button variant="ghost" onClick={onCancel} disabled={saving}>
取消
@@ -452,7 +305,9 @@ function ManageGroups({
export function GroupExitMonitorWorkspace({
dbReady,
contacts = [],
onOpenSendSettings
onOpenSendSettings,
openViewRequest,
onOpenLeaveNotificationAutomation
}: GroupExitMonitorWorkspaceProps): React.ReactElement {
const [state, setState] = React.useState<GroupExitMonitorState>(EMPTY_STATE)
const [checking, setChecking] = React.useState(false)
@@ -462,21 +317,23 @@ export function GroupExitMonitorWorkspace({
const [sendCapability, setSendCapability] = React.useState<PersonalWechatSendCapability | null>(
null
)
const [templateSaving, setTemplateSaving] = React.useState(false)
const [templateError, setTemplateError] = React.useState('')
const [selectedRoomId, setSelectedRoomId] = React.useState('all')
const [view, setView] = React.useState<'events' | 'manage'>('events')
const [resendingEventId, setResendingEventId] = React.useState<string | null>(null)
const [copiedEventId, setCopiedEventId] = React.useState('')
/**
* 「复制退群信息」用的模板。
*
* 已迁到自动化规则;这里只是把它读回来给剪贴板用(见下面的 effect)。
*/
const [copyNoticeTemplate, setCopyNoticeTemplate] = React.useState(
GROUP_EXIT_NOTIFICATION_TEMPLATE
)
const copyResetTimer = React.useRef<number | null>(null)
const [manageKeyword, setManageKeyword] = React.useState('')
const [manageFilter, setManageFilter] = React.useState<GroupSelectionFilter>('all')
const [selectedManageRoomIds, setSelectedManageRoomIds] = React.useState<Set<string>>(
() => new Set()
)
const [selectedManageNotificationRoomIds, setSelectedManageNotificationRoomIds] = React.useState<
Set<string>
>(() => new Set())
const groups = React.useMemo(() => {
const seen = new Set<string>()
@@ -526,6 +383,36 @@ export function GroupExitMonitorWorkspace({
}
}, [])
// 「自动化 → 退群通知 → 管理监控群聊」深链过来时,直接落到管理群聊页。
React.useEffect(() => {
if (!openViewRequest) return
setView(openViewRequest.view)
}, [openViewRequest])
/**
* 「复制退群信息」用的模板。
*
* 模板**唯一来源**是「自动化 → 退群通知」规则 —— 退群监控自己不再持有模板。
* 读不到就回落默认模板(复制功能本身不依赖自动化是否启用)。
*/
React.useEffect(() => {
const api = typeof window !== 'undefined' ? window.api : undefined
if (!api || typeof api.listAutomationRules !== 'function') return
let disposed = false
void api
.listAutomationRules()
.then((rules) => {
if (disposed) return
const rule = rules.find((item) => item.id === BUILTIN_LEAVE_NOTIFICATION_RULE_ID)
const template = rule?.leaveNotification?.template?.trim()
setCopyNoticeTemplate(template || GROUP_EXIT_NOTIFICATION_TEMPLATE)
})
.catch(() => undefined)
return () => {
disposed = true
}
}, [])
React.useEffect(
() => () => {
if (copyResetTimer.current !== null) window.clearTimeout(copyResetTimer.current)
@@ -572,21 +459,15 @@ export function GroupExitMonitorWorkspace({
}, [state.events])
const setupEmpty =
state.monitorSelectionConfigured === true && !(state.monitoredRoomIds || []).length
const notificationTemplate = state.notificationTemplate || GROUP_EXIT_NOTIFICATION_TEMPLATE
const openManage = (): void => {
const availableRoomIds = new Set(groups.map(contactRoomId))
const defaultIds = state.monitorSelectionConfigured ? state.monitoredRoomIds || [] : []
const selectedIds = new Set(defaultIds.filter((roomId) => availableRoomIds.has(roomId)))
const notificationIds = new Set(
(state.notificationRoomIds || []).filter((roomId) => selectedIds.has(roomId))
)
setSelectedManageRoomIds(selectedIds)
setSelectedManageNotificationRoomIds(notificationIds)
setManageKeyword('')
setManageFilter('all')
setError('')
setTemplateError('')
setView('manage')
}
@@ -615,26 +496,15 @@ export function GroupExitMonitorWorkspace({
}
}
const resendEvent = async (eventId: string): Promise<void> => {
if (resendingEventId || typeof window.api.resendGroupExitMonitorEvent !== 'function') return
setResendingEventId(eventId)
setError('')
try {
setState(await window.api.resendGroupExitMonitorEvent(eventId))
} catch (reason) {
setError(reason instanceof Error ? reason.message : String(reason))
} finally {
setResendingEventId(null)
}
}
/**
* 手动复制退群信息:按“管理群聊”里保存的模板拼接当前事件,只写系统剪贴板,
* 手动复制退群信息:按「自动化 → 退群通知」里的模板拼接当前事件,只写系统剪贴板,
* 不触发任何微信发送,用户自行决定粘贴到哪里。
*
* 发送与重发**都不在这里** —— 通知由自动化执行,重发走执行日志。
*/
const copyEventNotification = async (event: GroupExitMonitorEvent): Promise<void> => {
const api = typeof window !== 'undefined' ? window.api : undefined
const text = renderGroupExitMonitorNotification(event, notificationTemplate)
const text = renderGroupExitMonitorNotification(event, copyNoticeTemplate)
setError('')
try {
const result = api && typeof api.copyText === 'function' ? await api.copyText(text) : null
@@ -656,13 +526,11 @@ export function GroupExitMonitorWorkspace({
setSaving(true)
setError('')
try {
// 只保存**监控范围**:通知配置属于自动化,不从这一页写。
const selectedIds = groups
.map(contactRoomId)
.filter((roomId) => selectedManageRoomIds.has(roomId))
const notificationIds = selectedIds.filter((roomId) =>
selectedManageNotificationRoomIds.has(roomId)
)
const nextState = await api.setGroupExitMonitorGroups(selectedIds, notificationIds)
const nextState = await api.setGroupExitMonitorGroups(selectedIds)
setState(nextState)
setView('events')
} catch (reason) {
@@ -672,26 +540,6 @@ export function GroupExitMonitorWorkspace({
}
}
const saveNotificationTemplate = async (template: string): Promise<boolean> => {
const api = window.api
if (templateSaving || typeof api.setGroupExitMonitorNotificationTemplate !== 'function') {
setTemplateError('当前版本不支持保存退群监测模板')
return false
}
setTemplateSaving(true)
setTemplateError('')
try {
const nextState = await api.setGroupExitMonitorNotificationTemplate(template)
setState(nextState)
return true
} catch (reason) {
setTemplateError(reason instanceof Error ? reason.message : String(reason))
return false
} finally {
setTemplateSaving(false)
}
}
const setMonitorEnabled = async (enabled: boolean): Promise<void> => {
const api = window.api
if (togglingEnabled || typeof api.setGroupExitMonitorEnabled !== 'function') return
@@ -707,20 +555,12 @@ export function GroupExitMonitorWorkspace({
}
const toggleManageGroup = (roomId: string): void => {
const wasSelected = selectedManageRoomIds.has(roomId)
setSelectedManageRoomIds((current) => {
const next = new Set(current)
if (next.has(roomId)) next.delete(roomId)
else next.add(roomId)
return next
})
if (wasSelected) {
setSelectedManageNotificationRoomIds((notifications) => {
const nextNotifications = new Set(notifications)
nextNotifications.delete(roomId)
return nextNotifications
})
}
}
const toggleAllManageGroups = (): void => {
@@ -734,17 +574,6 @@ export function GroupExitMonitorWorkspace({
})
return next
})
if (!shouldSelect) setSelectedManageNotificationRoomIds(new Set())
}
const toggleManageNotification = (roomId: string): void => {
if (!selectedManageRoomIds.has(roomId)) return
setSelectedManageNotificationRoomIds((current) => {
const next = new Set(current)
if (next.has(roomId)) next.delete(roomId)
else next.add(roomId)
return next
})
}
if (view === 'manage') {
@@ -752,7 +581,6 @@ export function GroupExitMonitorWorkspace({
<ManageGroups
groups={groups}
selectedRoomIds={selectedManageRoomIds}
notificationRoomIds={selectedManageNotificationRoomIds}
sendCapability={sendCapability}
keyword={manageKeyword}
listFilter={manageFilter}
@@ -761,14 +589,9 @@ export function GroupExitMonitorWorkspace({
onKeywordChange={setManageKeyword}
onListFilterChange={setManageFilter}
onToggle={toggleManageGroup}
onToggleNotification={toggleManageNotification}
onToggleAll={toggleAllManageGroups}
onSave={() => void saveGroups()}
onCancel={() => setView('events')}
notificationTemplate={notificationTemplate}
templateSaving={templateSaving}
templateError={templateError}
onSaveTemplate={saveNotificationTemplate}
/>
)
}
@@ -806,6 +629,19 @@ export function GroupExitMonitorWorkspace({
{statusLabel}
</span>
<SendCapabilityStatus capability={sendCapability} />
{/*
发送能力不完整时给一个可操作的去处。
§「规则仍然可以保存」:这里只提示,不阻断任何编辑。
*/}
{sendCapability &&
!(sendCapability.ready && sendCapability.capabilities?.text) &&
onOpenSendSettings ? (
<Button variant="link" size="sm" onClick={onOpenSendSettings}>
去设置发送能力
</Button>
) : null}
{/* 检测到退群之后做什么,归自动化管;这里只是个入口。 */}
<LeaveMonitorAutomationEntry onOpen={onOpenLeaveNotificationAutomation} />
<Button variant="outline" size="sm" onClick={openManage}>
管理群聊
</Button>
@@ -921,12 +757,10 @@ export function GroupExitMonitorWorkspace({
群人数 {event.previousCount} 人 → {event.currentCount} 人
</p>
) : null}
<EventNotificationStatus
event={event}
onOpenSendSettings={onOpenSendSettings}
onResend={() => void resendEvent(event.id)}
resending={resendingEventId === event.id}
/>
{/*
这里**不再显示通知状态**:发送结果归「自动化 → 执行日志」。
退群监控只对"谁、哪个群、什么时候、人数怎么变"负责。
*/}
<dl className="exit-monitor-event-details">
<div>
<dt>微信名</dt>
@@ -0,0 +1,30 @@
import React from 'react'
import { Button } from '../../components/ui'
/**
* LeaveMonitorAutomationEntry —— 退群监控页上的「退群通知自动化」入口。
*
* 只做导航:跳到「自动化 → 规则 → 退群通知」。
* 它不变更退群监控的任何配置,也不触发发送。
*
* 之所以把职责拆开说清楚:退群监控负责「谁退出了」,
* 自动化负责「检测到之后做什么」—— 入口名字必须体现这个分界。
*/
export function LeaveMonitorAutomationEntry({
onOpen
}: {
onOpen?: () => void
}): React.ReactElement | null {
if (!onOpen) return null
return (
<Button
variant="outline"
size="sm"
onClick={onOpen}
aria-label="配置退群通知自动化"
className="exit-monitor-automation-entry"
>
退群通知自动化 →
</Button>
)
}
@@ -13,7 +13,21 @@ import {
} from '../../components/ui'
type StatusFilter = 'all' | WechatActionStatus
type PurposeFilter = 'all' | 'member_left_notification' | 'scheduled_report' | 'other'
type PurposeFilter = 'all' | 'leave_notification' | 'scheduled_report' | 'other'
/**
* 「退群通知」的发送用途。
*
* - `automation_leave_notification`:迁移后由自动化发出的退群通知(**当前唯一生产者**);
* - `member_left_notification`:迁移前退群监控自己发的,只在**历史审计记录**里存在。
*
* 两个都归到「退群通知」这一个筛选项下 —— 否则用户翻旧日志时会发现
* 明明写着「退群通知」却筛不出来。
*/
const LEAVE_NOTIFICATION_PURPOSES: readonly string[] = [
'automation_leave_notification',
'member_left_notification'
]
const sourceLabel = (source: ActionLogEntry['source']): string => {
if (source === 'member_monitor') return '退群监控'
@@ -27,7 +41,7 @@ const triggerLabel = (triggerType: ActionLogEntry['triggerType']): string =>
triggerType === 'user' ? '用户触发' : '自动化'
const purposeLabel = (purpose: WechatActionPurpose): string => {
if (purpose === 'member_left_notification') return '退群通知'
if (LEAVE_NOTIFICATION_PURPOSES.includes(purpose)) return '退群通知'
if (purpose === 'scheduled_report') return '定时日报'
if (purpose === 'tts_voice') return '文字转语音'
return purpose || '其它动作'
@@ -51,7 +65,12 @@ const statusDetails = (entry: ActionLogEntry): { label: string; className: strin
const matchesPurpose = (entry: ActionLogEntry, filter: PurposeFilter): boolean => {
if (filter === 'all') return true
if (filter === 'other') {
return entry.purpose !== 'member_left_notification' && entry.purpose !== 'scheduled_report'
return (
!LEAVE_NOTIFICATION_PURPOSES.includes(entry.purpose) && entry.purpose !== 'scheduled_report'
)
}
if (filter === 'leave_notification') {
return LEAVE_NOTIFICATION_PURPOSES.includes(entry.purpose)
}
return entry.purpose === filter
}
@@ -133,7 +152,7 @@ export function LogsWorkspace(): React.ReactElement {
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部类型</SelectItem>
<SelectItem value="member_left_notification">退群通知</SelectItem>
<SelectItem value="leave_notification">退群通知</SelectItem>
<SelectItem value="scheduled_report">定时日报</SelectItem>
<SelectItem value="other">其它动作</SelectItem>
</SelectContent>
+304
View File
@@ -227,6 +227,13 @@
background: var(--wxex-bg-app);
opacity: 0.72;
}
// 视觉稿卡片:虚线边框表示"还没有真实配置"。
// 刻意不降透明度 —— 它需要被当成正常的一张卡来评估信息架构。
&.preview {
border-style: dashed;
background: var(--wxex-bg-app);
}
}
.automation-rule-card-main {
@@ -267,6 +274,14 @@
background: color-mix(in srgb, var(--wxex-text-muted) 18%, transparent);
color: var(--wxex-text-secondary);
}
// 「退群通知」尚未迁移真实功能,状态如实写「待配置」——
// 不给它 success 色,避免和真正在跑的规则混淆。
&.pending {
border: 1px dashed color-mix(in srgb, var(--wxex-text-muted) 45%, transparent);
background: transparent;
color: var(--wxex-text-muted);
}
}
.automation-rule-meta {
@@ -1179,3 +1194,292 @@
gap: 8px;
margin-top: 6px;
}
// ---------------------------------------------------------------------------
// 规则类型(@我生成日报 / 退群通知)
// ---------------------------------------------------------------------------
//
// 只是「规则」内部的一层导航。视觉继续用现有 token,
// 这里不引入第二套配色,也不做卡片化的 Dashboard 重构。
.automation-rule-type-shell {
display: flex;
min-width: 0;
flex-direction: column;
gap: 16px;
}
.automation-rule-type-tabs {
display: flex;
min-width: 0;
flex-direction: column;
gap: 8px;
align-items: flex-start;
}
.automation-rule-type-label {
color: var(--wxex-text-muted);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.automation-rule-type-note {
margin: 0;
color: var(--wxex-text-muted);
font-size: 12px;
line-height: 18px;
}
// ---------------------------------------------------------------------------
// 退群通知编辑器
// ---------------------------------------------------------------------------
.automation-leave-status-row {
display: flex;
align-items: center;
gap: 10px;
margin-top: 10px;
}
// 只读的「已监控群聊」摘要:范围归退群监控管,这里不重复维护一份选择状态。
.automation-leave-groups-card {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
padding: 14px 16px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-sm);
background: var(--wxex-bg-app);
> div {
display: flex;
min-width: 0;
flex-direction: column;
gap: 2px;
}
strong {
color: var(--wxex-text-primary);
font-size: 16px;
font-weight: 700;
line-height: 22px;
}
small {
color: var(--wxex-text-muted);
font-size: 12px;
line-height: 17px;
}
}
// 「通知发送到哪里」四选一。
.automation-leave-targets {
gap: 2px;
}
// 单选项一行:radio + (可选)标题/说明两行。
// 联系人单选也复用同一行样式,所以只有一处定义。
.automation-leave-radio {
display: flex;
min-width: 0;
align-items: flex-start;
gap: 8px;
label {
display: block;
color: var(--wxex-text-primary);
font-size: 13px;
line-height: 18px;
cursor: pointer;
}
&.option {
padding: 7px 10px;
border-radius: var(--wxex-radius-sm);
&:hover {
background: var(--wxex-bg-app);
}
}
}
.automation-leave-radio-body {
display: flex;
min-width: 0;
flex-direction: column;
gap: 2px;
small {
color: var(--wxex-text-muted);
font-size: 12px;
line-height: 17px;
overflow-wrap: anywhere;
}
}
.automation-leave-contact-picker {
display: flex;
min-width: 0;
flex-direction: column;
gap: 8px;
margin-top: 2px;
}
.automation-leave-contact-list {
display: flex;
min-width: 0;
max-height: 200px;
flex-direction: column;
gap: 2px;
overflow-y: auto;
padding: 6px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-sm);
background: var(--wxex-bg-app);
}
.automation-leave-template {
display: flex;
min-width: 0;
flex-direction: column;
gap: 10px;
}
.automation-leave-template-editor {
font-family: inherit;
font-size: 12px;
line-height: 19px;
}
.automation-leave-template-meta {
display: flex;
min-width: 0;
align-items: center;
gap: 8px;
flex-wrap: wrap;
color: var(--wxex-text-muted);
font-size: 12px;
}
.automation-leave-template-chips {
display: flex;
min-width: 0;
flex-wrap: wrap;
gap: 6px;
}
.automation-leave-template-chip {
padding: 2px 8px;
border: 1px solid var(--wxex-border);
border-radius: 999px;
background: var(--wxex-bg-app);
color: var(--wxex-text-secondary);
font-family: inherit;
font-size: 11px;
line-height: 18px;
cursor: pointer;
&:hover {
border-color: color-mix(in srgb, var(--wxex-brand) 40%, var(--wxex-border));
background: var(--wxex-brand-soft);
color: var(--wxex-brand);
}
}
.automation-leave-template-count {
margin-left: auto;
white-space: nowrap;
}
.automation-leave-template-note {
margin: 0;
color: var(--wxex-text-muted);
font-size: 12px;
line-height: 18px;
}
// ---------------------------------------------------------------------------
// 退群通知效果预览
// ---------------------------------------------------------------------------
.automation-preview-chat-title {
margin-bottom: 10px;
padding-bottom: 8px;
border-bottom: 1px solid var(--wxex-border);
color: var(--wxex-text-primary);
font-size: 12px;
font-weight: 600;
text-align: center;
overflow-wrap: anywhere;
}
// 退群监控产生的系统事件,不是聊天消息 —— 所以居中、弱化,不做成气泡。
.automation-preview-system {
margin: 0;
color: var(--wxex-text-muted);
font-size: 11px;
text-align: center;
}
// 通知模板是多行文本,必须保留换行,否则 JSON 式内容会糊成一行。
.automation-chat-bubble.pre {
white-space: pre-wrap;
}
// 退群通知的「已解析目标」(文件传输助手 / 我 / 张三…)——
// 只显示用户能理解的称呼,不含任何 id。
.automation-step-detail {
margin: 4px 0 0;
color: var(--wxex-text-secondary);
font-size: 12px;
line-height: 17px;
overflow-wrap: anywhere;
}
/*
* 需要「正文 + 说明 + 一个动作按钮」的提示块。
*
* 用 grid 而不是靠 inline-block 自然换行:按钮必须**独占一行**
* (历史要求:不靠 fixed width / transform 之类手段凑布局)。
*/
.automation-notice-stack {
display: grid;
gap: 8px;
justify-items: start;
p {
margin: 0;
color: var(--wxex-text-primary);
font-size: 13px;
line-height: 19px;
}
small {
color: var(--wxex-text-secondary);
font-size: 12px;
line-height: 18px;
}
}
/*
* 「正在编辑:<规则名>」。
*
* 必须显眼且与保存目标同源:用户报过"从退群通知切到日报后保存,却改到了日报",
* 根因就是编辑目标不可见 —— 界面只显示表单,不显示它在改哪条规则。
*/
.automation-editing-target {
margin: 0 0 6px;
color: var(--wxex-text-secondary);
font-size: 12px;
line-height: 18px;
strong {
color: var(--wxex-text-primary);
font-weight: 600;
}
}
@@ -157,69 +157,6 @@
gap: 8px;
}
.exit-monitor-template-popover {
width: min(390px, calc(100vw - 32px));
}
.exit-monitor-template-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.exit-monitor-template-heading h2 {
margin: 2px 0 0;
color: var(--wxex-text-primary);
font-size: 15px;
font-weight: 700;
line-height: 22px;
}
.exit-monitor-template-status {
flex: 0 0 auto;
color: var(--wxex-text-muted);
font-size: 11px;
line-height: 18px;
}
.exit-monitor-template-editor {
margin: 14px 0 0;
min-height: 190px;
resize: vertical;
background: color-mix(in srgb, var(--wxex-bg-main) 70%, var(--wxex-bg-elevated));
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
line-height: 20px;
}
.exit-monitor-template-meta {
display: flex;
justify-content: space-between;
gap: 12px;
margin-top: 6px;
color: var(--wxex-text-muted);
font-size: 11px;
line-height: 18px;
}
.exit-monitor-template-popover p {
margin: 10px 0 0;
color: var(--wxex-text-muted);
font-size: 11px;
line-height: 18px;
}
.exit-monitor-template-error {
color: var(--wxex-danger) !important;
}
.exit-monitor-template-actions {
display: flex;
justify-content: flex-end;
margin-top: 12px;
}
.exit-monitor-manage-count strong {
margin: 0 4px;
color: var(--wxex-brand);
@@ -363,40 +300,6 @@
color: var(--wxex-danger);
}
.exit-monitor-event-notification {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 6px;
margin-top: 7px;
color: var(--wxex-text-secondary);
font-size: 12px;
line-height: 18px;
}
.exit-monitor-event-notification span {
font-weight: 600;
}
.exit-monitor-event-notification small {
color: var(--wxex-text-muted);
font-size: 11px;
line-height: 18px;
}
.exit-monitor-event-notification.sent {
color: var(--wxex-success);
}
.exit-monitor-event-notification.blocked,
.exit-monitor-event-notification.failed {
color: var(--wxex-danger);
}
.exit-monitor-event-notification.pending {
color: var(--wxex-brand);
}
.exit-monitor-event-details {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -629,25 +532,6 @@
color: var(--wxex-brand);
}
.exit-monitor-group-notification {
display: inline-flex;
min-width: 82px;
align-items: center;
justify-content: flex-end;
gap: 7px;
color: var(--wxex-text-secondary);
cursor: pointer;
font-size: 11px;
line-height: 18px;
white-space: nowrap;
}
.exit-monitor-group-notification:has([data-disabled]) {
color: var(--wxex-text-muted);
cursor: not-allowed;
opacity: 0.65;
}
.exit-monitor-manage-footer {
display: flex;
align-items: center;
@@ -749,12 +633,6 @@
grid-row: 2;
}
.exit-monitor-group-notification {
grid-column: 4;
grid-row: 2;
min-width: 0;
}
.exit-monitor-manage-footer {
align-items: flex-start;
flex-direction: column;
+10
View File
@@ -32,6 +32,7 @@ export interface AutomationMatchInput {
}
export type AutomationMatchFailure =
| 'rule_type'
| 'disabled'
| 'self'
| 'scope'
@@ -52,6 +53,15 @@ export function matchAutomationRule(
/** 自己的 username 候选集(`getMyUsernameCandidates()`)。 */
selfUsernames: readonly string[]
): AutomationMatchResult {
// **规则类型闸门放在最前面。**
//
// 「退群通知」的空关键词 + 不要求 @我,会让它命中**每一条群消息** ——
// 那是最坏的一类 bug:用户什么都没配,规则却开始乱跑。
// 类型分派必须在这里一处解决,预览与真实执行才会一致。
if (rule.ruleType === 'leave_notification') {
return { matched: false, reason: 'rule_type' }
}
if (!rule.enabled) return { matched: false, reason: 'disabled' }
// 防死循环第一道闸:自己发的消息默认不触发。
+278 -18
View File
@@ -5,6 +5,8 @@
* 第一版不做:regex、IF/ELSE 嵌套、可视化编排、Webhook、通用 AI 机器人。
*/
import { normalizeGroupExitNotificationTemplate } from './group-exit-monitor'
/** 触发事件类型。第一版只支持「收到消息」。 */
export type AutomationTriggerType = 'message'
@@ -19,6 +21,49 @@ export type KeywordMatchMode = 'contains' | 'exact' | 'prefix'
/** 消息来源过滤。 */
export type AutomationMessageScope = 'group' | 'direct' | 'all'
/**
* 规则类型。
*
* 决定**这条规则由什么驱动、有哪些字段有意义**:
* - `daily_report`:消息驱动,条件 + 动作链(@我生成日报);
* - `leave_notification`:退群事件驱动,通知目标 + 模板(退群通知)。
*
* ⚠️ 这是一条**硬分派**:`matchAutomationRule` 会直接拒绝非 `daily_report` 的规则,
* 否则一条 keyword 为空的退群通知会命中**每一条群消息**。
*/
export type AutomationRuleType = 'daily_report' | 'leave_notification'
/** 规则类型的界面标签(Tab 与首页卡片共用一处文案)。 */
export const AUTOMATION_RULE_TYPE_LABELS: Record<AutomationRuleType, string> = {
daily_report: '@我生成日报',
leave_notification: '退群通知'
}
/** 退群通知发到哪里。刻意只有这四种。 */
export type LeaveNotificationTargetType = 'source_chat' | 'self' | 'file_transfer' | 'contact'
export interface LeaveNotificationTarget {
type: LeaveNotificationTargetType
/**
* `contact` 时必填。
*
* **稳定 id(wxid)**,不是显示昵称 —— 昵称会改,改完就发错人。
*/
contactId?: string
}
export interface LeaveNotificationConfig {
target: LeaveNotificationTarget
template: string
/**
* 迁移时旧配置**无法无损映射**的标记(旧「通知群聊」是 per-group 多值,
* 新目标是单值)。为 true 时:规则不执行发送,UI 提示用户重选目标。
*
* 只有迁移会把它置为 true,用户手动保存一次即清除。
*/
targetNeedsReview?: boolean
}
export interface AutomationConditions {
/** 必须**真正** @我(走 source → atuserlist,不是正文里的 @昵称)。 */
requireMentionMe: boolean
@@ -44,6 +89,8 @@ export interface AutomationRule {
id: string
name: string
enabled: boolean
/** 规则类型。缺省视为 `daily_report`(历史 rules.json 没有这个字段)。 */
ruleType: AutomationRuleType
trigger: AutomationTriggerType
scope: AutomationMessageScope
conditions: AutomationConditions
@@ -61,6 +108,8 @@ export interface AutomationRule {
* `normalizeReplyDelaySeconds()` 处理 ⇒ 缺省即默认 2 秒,不是「不等待」。
*/
replyDelaySeconds: number
/** `ruleType === 'leave_notification'` 时必填。 */
leaveNotification?: LeaveNotificationConfig
createdAt: number
updatedAt: number
}
@@ -68,8 +117,26 @@ export interface AutomationRule {
/** 执行步骤状态。`skipped` 用于「上一步失败所以这一步不做」。 */
export type AutomationStepStatus = 'pending' | 'running' | 'success' | 'failed' | 'skipped'
/** 执行步骤键。只有**真正进入执行**的规则才会有步骤。 */
export type AutomationStepKey = 'received' | 'matched' | 'reply' | 'report' | 'send'
/**
* 执行步骤键。
*
* 分两族,**不共用**:
* - 消息型(`daily_report`):received / matched / reply / report / send
* - 退群型(`leave_notification`):exit_received / exit_matched / exit_target / exit_send
*
* 不共用的理由:给退群通知渲染一个「回复确认 已跳过」的步骤是纯噪声,
* 用户会以为规则配错了。
*/
export type AutomationStepKey =
| 'received'
| 'matched'
| 'reply'
| 'report'
| 'send'
| 'exit_received'
| 'exit_matched'
| 'exit_target'
| 'exit_send'
export interface AutomationStep {
key: AutomationStepKey
@@ -79,6 +146,13 @@ export interface AutomationStep {
startedAt?: number
finishedAt?: number
durationMs?: number
/**
* 用户可读的**细节**(例如已解析的通知目标显示名「文件传输助手」)。
*
* ⚠️ 展示层契约:只允许放用户能理解的名称。
* **禁止** wxid / chatroom id / localId / serverId / 文件路径 / 原始 payload。
*/
detail?: string
/** 用户可读的错误信息。**禁止**含 wxid / 文件路径 / 原始 payload。 */
error?: string
/**
@@ -143,7 +217,15 @@ export const AUTOMATION_SEND_PURPOSE = {
/** 触发确认文字回复。 */
reply: 'automation_reply',
/** 日报图片。 */
report: 'automation_report'
report: 'automation_report',
/**
* 退群通知文字。
*
* 刻意**不复用**历史 purpose `member_left_notification`:那一个被 WechatActionGateway
* 的作用域锁绑死在「只能发回事件所在群」,而现在的目标可以是自己 / 文件传输助手 /
* 指定好友。用新 purpose 才能真正解除那个锁,同时让旧 purpose 变成无生产者的历史值。
*/
leaveNotification: 'automation_leave_notification'
} as const
/** 审计日志里的来源标识(`WechatActionOrigin`)。 */
@@ -194,6 +276,7 @@ export function automationIdempotencyKey(
export interface AutomationRuleDraft {
name: string
enabled: boolean
ruleType: AutomationRuleType
trigger: AutomationTriggerType
scope: AutomationMessageScope
conditions: AutomationConditions
@@ -201,6 +284,8 @@ export interface AutomationRuleDraft {
cooldownSeconds: number
/** 命中后等多久再回复确认(秒)。0 = 立刻回复。 */
replyDelaySeconds: number
/** `ruleType === 'leave_notification'` 时的通知配置。 */
leaveNotification?: LeaveNotificationConfig
}
/** 顶部状态条数据(全部来自 main 侧真实能力,UI 不做平台判断)。 */
@@ -244,33 +329,41 @@ export const AUTOMATION_RULE_TEMPLATES: AutomationRuleTemplate[] = [
name: '定时发送日报',
description: '按固定时间自动生成并发送群日报',
available: false,
unavailableReason: '沿用现有「定时日报」能力,本轮不迁移'
},
{
id: 'member-exit-notice',
name: '退群通知',
description: '群成员退出时自动发送通知',
available: false,
unavailableReason: '现有「退群监控」已具备该能力,本轮不迁移'
unavailableReason: ''
},
{
id: 'keyword-reply',
name: '关键词自动回复',
description: '消息命中关键词时自动回复指定内容',
available: false,
unavailableReason: '本轮验收对象为「@我生成日报」,关键词模板下一轮开放'
unavailableReason: ''
}
]
/** 内置规则 id。固定值,便于升级时不重复创建。 */
export const BUILTIN_DAILY_REPORT_RULE_ID = 'builtin-mention-me-daily-report'
/**
* 内置「退群通知」规则 id。
*
* 这是一个 **singleton / system rule**:保存永远按这个 id upsert,
* 不允许"每打开一次编辑页多一条规则"。
*/
export const BUILTIN_LEAVE_NOTIFICATION_RULE_ID = 'builtin-leave-notification'
/** 退群通知的默认展示名。 */
export const LEAVE_NOTIFICATION_RULE_NAME = '退群通知'
export const AUTOMATION_STEP_LABELS: Record<AutomationStepKey, string> = {
received: '收到消息',
matched: '规则匹配',
reply: '回复确认',
report: '生成日报',
send: '发送日报图片'
send: '发送日报图片',
exit_received: '检测到成员退出',
exit_matched: '规则匹配',
exit_target: '确定通知目标',
exit_send: '发送退群通知'
}
/**
@@ -316,8 +409,80 @@ export function matchKeyword(
return haystack.includes(needle)
}
/**
* 退群通知的四种发送目标(展示层单一来源)。
*
* UI 单选项与首页卡片摘要都从这里取文案,避免同一句话在两个文件里各写一份、慢慢漂移。
* **顺序即界面顺序**:`当前群聊` 排第一,因为它也是默认值。
*/
export const LEAVE_NOTIFICATION_TARGET_OPTIONS: ReadonlyArray<{
type: LeaveNotificationTargetType
label: string
description: string
}> = [
{
type: 'source_chat',
label: '当前群聊',
description: '将通知发送到发生成员退出的群聊。'
},
{
type: 'file_transfer',
label: '文件传输助手',
description: '将通知发送到文件传输助手。'
},
{
type: 'self',
label: '发给自己',
description: '将通知发送到当前登录微信账号自己的会话。'
},
{
type: 'contact',
label: '指定好友',
description: '将通知发送给一个指定联系人。'
}
]
/**
* 新安装 / 无历史配置时的默认目标。
*
* 「当前群聊」= 旧「退群监控」通知的原始行为(通知发回事件所在群),
* 所以它既是默认值、也排在选项第一位。
*/
export const LEAVE_NOTIFICATION_DEFAULT_TARGET_TYPE: LeaveNotificationTargetType = 'source_chat'
/** 目标类型 → 界面短标签。 */
export function leaveNotificationTargetLabel(type: LeaveNotificationTargetType): string {
return LEAVE_NOTIFICATION_TARGET_OPTIONS.find((option) => option.type === type)?.label ?? ''
}
/**
* 「范围」摘要:退群通知覆盖多少个已监控群聊。
*
* 数字**必须**来自退群监控(`GroupExitMonitorState`),自动化不维护副本。
*/
export function describeMonitoredScope(monitoredCount: number): string {
return `${Math.max(0, Math.floor(Number(monitoredCount) || 0))} 个已监控群聊`
}
/**
* 目标的一句话描述(首页卡片摘要用)。
*
* `contact` 需要外部把联系人显示名传进来 —— shared 不读通讯录。
* 名字拿不到时如实说「未选择好友」,不伪造一个收件人。
*/
export function describeLeaveNotificationTarget(
config: LeaveNotificationConfig | undefined,
contactDisplayName?: string
): string {
const target = config?.target
if (!target) return '未配置'
if (target.type === 'contact') return contactDisplayName?.trim() || '未选择好友'
return leaveNotificationTargetLabel(target.type)
}
/** 触发条件的一句话摘要(UI 展示用,不含任何内部 id)。 */
export function describeRuleTrigger(rule: AutomationRule): string {
if (rule.ruleType === 'leave_notification') return '检测到群成员退出'
const parts: string[] = []
if (rule.conditions.requireMentionMe) parts.push('@我')
if (rule.conditions.keyword.trim()) {
@@ -328,6 +493,7 @@ export function describeRuleTrigger(rule: AutomationRule): string {
/** 动作链的一句话摘要。 */
export function describeRuleActions(rule: AutomationRule): string {
if (rule.ruleType === 'leave_notification') return '发送退群通知'
const labels: Record<AutomationActionType, string> = {
replyText: '回复确认',
generateReport: '生成日报',
@@ -354,6 +520,7 @@ export function createDefaultDailyReportRule(now: number): AutomationRule {
id: BUILTIN_DAILY_REPORT_RULE_ID,
name: '@我生成日报',
enabled: true,
ruleType: 'daily_report',
trigger: 'message',
scope: 'group',
conditions: {
@@ -375,10 +542,66 @@ export function createDefaultDailyReportRule(now: number): AutomationRule {
}
}
/** 构造内置规则「退群通知」(singleton,固定 id)。 */
export function createDefaultLeaveNotificationRule(
now: number,
options: {
template?: string
target?: LeaveNotificationTarget
enabled?: boolean
targetNeedsReview?: boolean
} = {}
): AutomationRule {
return {
id: BUILTIN_LEAVE_NOTIFICATION_RULE_ID,
name: LEAVE_NOTIFICATION_RULE_NAME,
// 规则级启停是**唯一**开关;退群通知没有第二个"发送"开关。
enabled: options.enabled !== false,
ruleType: 'leave_notification',
// 由退群事件驱动,不参与消息匹配。这两个字段保留只是为了 schema 完整。
trigger: 'message',
scope: 'group',
conditions: {
requireMentionMe: false,
keyword: '',
keywordMatchMode: 'contains',
conversationIds: [],
ignoreSelf: true
},
// 退群通知没有动作链,动作由 leaveNotification 表达。
actions: [],
// 不套用消息型规则的 cooldown:两次退群不能被 60 秒窗口合并。
cooldownSeconds: 0,
replyDelaySeconds: AUTOMATION_REPLY_DELAY_DEFAULT_SECONDS,
leaveNotification: {
target: options.target ?? { type: LEAVE_NOTIFICATION_DEFAULT_TARGET_TYPE },
template: normalizeLeaveNotificationTemplate(options.template),
...(options.targetNeedsReview ? { targetNeedsReview: true } : {})
},
createdAt: now,
updatedAt: now
}
}
/**
* 模板归一化。
*
* 复用退群监控那份校验/归一化(含 legacy 模板升级),保证
* 「模板规则只有一处实现」——迁移不产生第二套占位符解析。
*/
export function normalizeLeaveNotificationTemplate(value: unknown): string {
return normalizeGroupExitNotificationTemplate(value)
}
/** 归一化用户提交的草稿:把未知值收敛成合法值,避免脏数据落盘。 */
export function normalizeRuleDraft(input: unknown, fallbackName = '未命名自动化'): AutomationRuleDraft {
export function normalizeRuleDraft(
input: unknown,
fallbackName = '未命名自动化'
): AutomationRuleDraft {
const raw = (input ?? {}) as Partial<AutomationRuleDraft>
const conditions = (raw.conditions ?? {}) as Partial<AutomationConditions>
const ruleType: AutomationRuleType =
raw.ruleType === 'leave_notification' ? 'leave_notification' : 'daily_report'
const keywordMatchMode: KeywordMatchMode =
conditions.keywordMatchMode === 'exact' || conditions.keywordMatchMode === 'prefix'
? conditions.keywordMatchMode
@@ -393,14 +616,18 @@ export function normalizeRuleDraft(input: unknown, fallbackName = '未命名自
enabled: action.enabled !== false,
...(typeof action.text === 'string' ? { text: action.text } : {})
}))
.filter((action) =>
action.type === 'replyText' || action.type === 'generateReport' || action.type === 'sendReportImage'
.filter(
(action) =>
action.type === 'replyText' ||
action.type === 'generateReport' ||
action.type === 'sendReportImage'
)
const cooldown = Number(raw.cooldownSeconds)
return {
const base = {
name: String(raw.name ?? '').trim() || fallbackName,
enabled: raw.enabled !== false,
trigger: 'message',
ruleType,
trigger: 'message' as AutomationTriggerType,
scope,
conditions: {
requireMentionMe: conditions.requireMentionMe !== false,
@@ -415,4 +642,37 @@ export function normalizeRuleDraft(input: unknown, fallbackName = '未命名自
cooldownSeconds: Number.isFinite(cooldown) ? Math.max(0, Math.floor(cooldown)) : 60,
replyDelaySeconds: normalizeReplyDelaySeconds(raw.replyDelaySeconds)
}
if (ruleType !== 'leave_notification') return base
return { ...base, leaveNotification: normalizeLeaveNotificationConfig(raw.leaveNotification) }
}
/**
* 收敛退群通知配置。
*
* 目标是**必填**的:缺失一律回落到默认目标(文件传输助手),
* 而不是留一个"没有目标"的规则 —— 那种规则在运行时只能失败。
* `contact` 缺 contactId 时同样回落,避免存下一条永远发不出去的规则。
*/
export function normalizeLeaveNotificationConfig(input: unknown): LeaveNotificationConfig {
const raw = (input ?? {}) as Partial<LeaveNotificationConfig>
const targetRaw = (raw.target ?? {}) as Partial<LeaveNotificationTarget>
const allowed: LeaveNotificationTargetType[] = ['source_chat', 'self', 'file_transfer', 'contact']
const type: LeaveNotificationTargetType = allowed.includes(
targetRaw.type as LeaveNotificationTargetType
)
? (targetRaw.type as LeaveNotificationTargetType)
: LEAVE_NOTIFICATION_DEFAULT_TARGET_TYPE
const contactId = String(targetRaw.contactId ?? '').trim()
// `contact` 但没有 id → 回落默认目标;宁可发到文件助手,也不能存一条必然失败的规则。
const effectiveType: LeaveNotificationTargetType =
type === 'contact' && !contactId ? LEAVE_NOTIFICATION_DEFAULT_TARGET_TYPE : type
return {
target: {
type: effectiveType,
...(effectiveType === 'contact' ? { contactId } : {})
},
template: normalizeLeaveNotificationTemplate(raw.template),
// 用户手动保存一次即视为已确认目标,清除迁移提示。
...(raw.targetNeedsReview === true ? { targetNeedsReview: true } : {})
}
}
+115
View File
@@ -0,0 +1,115 @@
import {
buildGroupExitNotificationValues,
renderGroupExitNotificationTemplate,
type GroupExitMonitorEvent
} from './group-exit-monitor'
/**
* GroupMemberExitedEvent —— 退群监控交给自动化消费的**稳定事件契约**。
*
* 为什么不让 Automation 直接吃 `GroupExitMonitorEvent`:
* 后者是退群监控的**存储形态**(含 delta / message 展示串 / 历史通知状态),
* 会随监控自身的演进而变。Automation 只需要「谁、在哪个群、什么时候、人数怎么变」,
* 所以这里显式定义一份最小契约 + 一个纯映射函数。
*
* `eventId` 是**唯一键**,直接复用退群记录自己的 id(`GroupExitMonitorEvent.id`)——
* 不允许用昵称 / 群名 / 显示文本做唯一键。
*/
export interface GroupMemberExitedEvent {
/** 稳定事件 id(= 退群记录的 id)。Automation 侧幂等以它为准。 */
eventId: string
/** 发生退群的群(`xxx@chatroom`)。`source_chat` 目标就是它。 */
conversationId: string
/** 群显示名(用户可读,用于日志/展示,**不是**唯一键)。 */
groupName?: string
/** 成员在本群的备注。 */
groupRemark?: string
/** 退群成员的稳定 id(wxid)。唯一键之一,但**不进用户可见日志**。 */
memberId: string
/** 成员显示名(用户可读)。 */
memberName?: string
/** 联系人表里的微信昵称。 */
wechatNickname?: string
/** 通讯录备注。 */
contactRemark?: string
previousCount: number
currentCount: number
/** 事件发生时间(epoch ms)。 */
occurredAt: number
}
/** 纯映射:退群记录 → 自动化事件。 */
export function toGroupMemberExitedEvent(event: GroupExitMonitorEvent): GroupMemberExitedEvent {
const groupName = String(event.groupName || '').trim()
const groupRemark = String(event.groupRemark || '').trim()
const memberName = String(event.memberName || '').trim()
const wechatNickname = String(event.wechatName || '').trim()
const contactRemark = String(event.contactRemark || '').trim()
return {
eventId: String(event.id || '').trim(),
conversationId: String(event.roomId || '').trim(),
...(groupName ? { groupName } : {}),
...(groupRemark ? { groupRemark } : {}),
memberId: String(event.memberWxid || '').trim(),
...(memberName ? { memberName } : {}),
...(wechatNickname ? { wechatNickname } : {}),
...(contactRemark ? { contactRemark } : {}),
previousCount: Number(event.previousCount) || 0,
currentCount: Number(event.currentCount) || 0,
occurredAt: Number(event.detectedAt) || 0
}
}
/**
* 渲染退群通知文本。
*
* **复用**退群监控那份唯一的插值实现(`renderGroupExitNotificationTemplate`)——
* 迁移不允许产生第二套模板 regex,否则预览与实发迟早不一致。
*/
export function renderLeaveNotificationText(
event: GroupMemberExitedEvent,
template: string
): string {
return renderGroupExitNotificationTemplate(
template,
buildGroupExitNotificationValues({
groupName: event.groupName,
wechatNickname: event.wechatNickname,
memberName: event.memberName,
memberId: event.memberId,
groupRemark: event.groupRemark,
previousCount: event.previousCount,
currentCount: event.currentCount,
occurredAt: event.occurredAt
})
)
}
/**
* **只服务效果预览**的样本事件。
*
* 它永远不会进入发送路径:渲染层拿它把模板渲染出来给用户看。
* 时间取"此刻",所以预览里显示的是当前时间,而不是一个伪造的历史日期。
* 集中定义在这里,避免散落的假数据被误当成真实事件。
*
* ⚠️ **样本必须与真实数据同形**,否则预览会骗人:
* `groupRemark` 是「退群成员**在本群的昵称**」(真实来源 `member.groupNickname`),
* 不是群名;群名是 `groupName`。两者取不同的值,区别才看得出来。
*/
export function createGroupExitPreviewSample(now: number): GroupMemberExitedEvent {
return {
eventId: 'preview-sample',
conversationId: 'preview@chatroom',
groupName: 'TraceMemo 交流群',
// 成员在本群的昵称:刻意与 groupName 取不同的值,一眼能看出两者不是一回事。
groupRemark: '小张',
// 明确是占位符,**不要**长得像真实账号 —— 预览里的 {wxid} 不是任何人的 wxid。
memberId: 'wxid_xxxxx',
memberName: '张三',
wechatNickname: '张三',
previousCount: 243,
currentCount: 242,
occurredAt: now
}
}
+119 -46
View File
@@ -1,5 +1,3 @@
import type { WechatActionResult } from './wechat-action'
export interface GroupExitMonitorMember {
wxid: string
nickname?: string
@@ -9,23 +7,6 @@ export interface GroupExitMonitorMember {
avatar?: string
}
export type GroupExitNotificationStatus =
| 'not_requested'
| 'pending'
| 'sent'
| 'blocked'
| 'failed'
export interface GroupExitNotificationState {
status: GroupExitNotificationStatus
actionId?: string
decision?: WechatActionResult['decision']
errorCode?: string
reason?: string
startedAt?: string
finishedAt?: string
}
export interface GroupExitMonitorEvent {
id: string
contactId: string
@@ -44,9 +25,6 @@ export interface GroupExitMonitorEvent {
delta: number
message: string
detectedAt: number
/** 可选通知的结果;退群检测与通知发送彼此独立。 */
notificationStatus?: GroupExitNotificationStatus
notification?: GroupExitNotificationState
}
const LEGACY_GROUP_EXIT_NOTIFICATION_TEMPLATE = [
@@ -65,6 +43,8 @@ const LEGACY_GROUP_EXIT_NOTIFICATION_TEMPLATE = [
export const GROUP_EXIT_NOTIFICATION_TEMPLATE = [
'[退群监测]',
'',
'群聊: {groupName}',
'',
'用户: {user}',
'',
'群备注: {groupRemark}',
@@ -78,6 +58,9 @@ export const GROUP_EXIT_NOTIFICATION_TEMPLATE = [
export const GROUP_EXIT_NOTIFICATION_TEMPLATE_MAX_LENGTH = 2_000
export const GROUP_EXIT_NOTIFICATION_TEMPLATE_PLACEHOLDERS = [
// 群名排在最前:通知一旦发给「文件传输助手 / 自己 / 指定好友」,
// 收件人第一件要知道的事就是**哪个群**退的人。
'groupName',
'user',
'groupRemark',
'wxid',
@@ -86,6 +69,26 @@ export const GROUP_EXIT_NOTIFICATION_TEMPLATE_PLACEHOLDERS = [
'time'
] as const
/**
* 每个变量的**中文含义**(编辑器悬停提示用)。
*
* 为什么必须有:变量清单原先只有 `{groupRemark}` 这种符号,用户看不出含义 ——
* 实测有人把 `{groupRemark}` 当成"群名"(它其实是**成员在本群的昵称**),
* 于是以为通知里已经有群名了。这类误会只能靠把含义写出来消除。
*/
export const GROUP_EXIT_NOTIFICATION_PLACEHOLDER_LABELS: Record<
(typeof GROUP_EXIT_NOTIFICATION_TEMPLATE_PLACEHOLDERS)[number],
string
> = {
groupName: '群聊名(发生退群的群)',
user: '退群成员的显示名',
groupRemark: '退群成员在本群的昵称(不是群名)',
wxid: '成员的微信号',
previousCount: '退群前人数',
currentCount: '退群后人数',
time: '退群时间'
}
export interface GroupExitNotificationTemplateValidation {
valid: boolean
template?: string
@@ -134,26 +137,20 @@ export function formatGroupExitMonitorTime(timestamp: number): string {
)}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
}
/** 根据模板生成通知文本。 */
export function renderGroupExitMonitorNotification(
event: GroupExitMonitorEvent,
template = GROUP_EXIT_NOTIFICATION_TEMPLATE
/**
* 模板插值的**唯一实现**。
*
* 退群监控的「复制退群信息」与自动化的「退群通知」都走这里 ——
* 迁移不允许产生第二套 regex,否则预览与实际发送迟早不一致。
*/
export function renderGroupExitNotificationTemplate(
template: string,
values: GroupExitNotificationValues
): string {
const user =
event.wechatName?.trim() || event.memberName?.trim() || event.memberWxid || '未读取到'
const groupRemark = event.groupRemark?.trim() || '未设置'
const wxid = event.memberWxid?.trim() || '未读取到'
const values = {
user,
groupRemark,
wxid,
previousCount: String(event.previousCount),
currentCount: String(event.currentCount),
time: formatGroupExitMonitorTime(event.detectedAt)
}
return normalizeGroupExitNotificationTemplate(template).replace(
/\{(user|groupRemark|wxid|previousCount|currentCount|time)\}/g,
/\{(groupName|user|groupRemark|wxid|previousCount|currentCount|time)\}/g,
(placeholder) => {
if (placeholder === '{groupName}') return values.groupName
if (placeholder === '{user}') return values.user
if (placeholder === '{groupRemark}') return values.groupRemark
if (placeholder === '{wxid}') return values.wxid
@@ -164,12 +161,64 @@ export function renderGroupExitMonitorNotification(
)
}
/** 主进程中的辅助函数,供生成通知和预览时共用。 */
export function buildMemberLeftNotification(
export interface GroupExitNotificationValues {
/** 发生退群的**群显示名**(含群备注)。 */
groupName: string
/** 退群成员的显示名(微信名 → 群昵称 → 微信号 依次回退)。 */
user: string
/** 退群成员**在本群的昵称**。⚠️ 不是群名 —— 群名是 `groupName`。 */
groupRemark: string
wxid: string
previousCount: string
currentCount: string
time: string
}
/**
* 变量取值的**唯一实现**,含全部 fallback 规则。
*
* ⚠️ 这些 fallback 是历史产品行为(`未读取到` / `未设置`),迁移**不得**改变输出:
* 退群监控与自动化都从这里取值,所以不存在"两处 fallback 漂移"的可能。
*/
export function buildGroupExitNotificationValues(input: {
groupName?: string
wechatNickname?: string
memberName?: string
memberId?: string
groupRemark?: string
previousCount: number
currentCount: number
occurredAt: number
}): GroupExitNotificationValues {
return {
groupName: input.groupName?.trim() || '未读取到',
user: input.wechatNickname?.trim() || input.memberName?.trim() || input.memberId || '未读取到',
groupRemark: input.groupRemark?.trim() || '未设置',
wxid: input.memberId?.trim() || '未读取到',
previousCount: String(input.previousCount),
currentCount: String(input.currentCount),
time: formatGroupExitMonitorTime(input.occurredAt)
}
}
/** 根据模板生成通知文本(退群监控侧入口,保持既有签名与输出)。 */
export function renderGroupExitMonitorNotification(
event: GroupExitMonitorEvent,
template = GROUP_EXIT_NOTIFICATION_TEMPLATE
): string {
return renderGroupExitMonitorNotification(event, template)
return renderGroupExitNotificationTemplate(
template,
buildGroupExitNotificationValues({
groupName: event.groupName,
wechatNickname: event.wechatName,
memberName: event.memberName,
memberId: event.memberWxid,
groupRemark: event.groupRemark,
previousCount: event.previousCount,
currentCount: event.currentCount,
occurredAt: event.detectedAt
})
)
}
export interface GroupExitMonitorState {
@@ -185,10 +234,6 @@ export interface GroupExitMonitorState {
monitorSelectionConfigured?: boolean
/** 管理页选中的群。 */
monitoredRoomIds?: string[]
/** 需要发送通知的群。 */
notificationRoomIds?: string[]
/** 自动通知模板。 */
notificationTemplate?: string
lastCheckedAt?: number
lastReadAt: number
unreadCount: number
@@ -219,3 +264,31 @@ export function findRemovedGroupMembers(
seen.add(member.wxid)
)
}
/**
* 把 `群聊: {groupName}` 补进一份**已有**模板。
*
* 放在 shared 而不是 main:主进程的模板升级迁移要用它,
* 编辑器的「一键补上群聊名」也要用它 —— 只允许有一份实现。
*
* 三条约束:
* 1. **幂等**:已经含 `{groupName}` 就原样返回;
* 2. **不破坏用户内容**:只在标题行后插入一行,其余文本与顺序一字不动;
* 3. 调用方负责"只补一次"—— 否则用户删掉这一行后重启它又回来,
* 那种"删不掉"的行为比缺失信息更糟。
*/
export function insertGroupNamePlaceholder(template: string): string {
const normalized = String(template ?? '').replace(/\r\n?/g, '\n')
if (!normalized.trim()) return template
if (normalized.includes('{groupName}')) return template
const lines = normalized.split('\n')
const titleIndex = lines.findIndex((line) => line.trim())
// 首行是标题(`[退群监测]` 这类)就插在它后面,否则插到最前面。
const insertAt = titleIndex >= 0 && lines[titleIndex].trim().startsWith('[') ? titleIndex + 1 : 0
// 前后各留一个空行(与标题、与下文都分开);紧随其后的多余空行由下面的压缩处理掉。
return [...lines.slice(0, insertAt), '', '群聊: {groupName}', '', ...lines.slice(insertAt)]
.join('\n')
.replace(/\n{3,}/g, '\n\n')
.trim()
}
+18 -8
View File
@@ -1,4 +1,10 @@
export type WechatActionOrigin =
/**
* 退群监控历史发送用的来源。
*
* 迁移后**没有任何生产者**了(退群监控不再自己发送)。保留它只为两件事:
* 让磁盘上的历史 audit 记录仍能正常显示,以及让类型仍是穷举的。
*/
| 'member_monitor'
| 'scheduled_report'
| 'user_tts'
@@ -10,12 +16,19 @@ export type WechatActionOrigin =
* 也不进 Send Log —— 排查「消息到底发没发出去」时会缺一份证据。
*/
| 'user_manual'
/** Automation v1(@我生成日报)。与 `shared/automation.ts` 的 AUTOMATION_SEND_ORIGIN 一致。 */
/** Automation(@我生成日报 / 退群通知)。与 `shared/automation.ts` 的 AUTOMATION_SEND_ORIGIN 一致。 */
| 'automation'
| 'unknown'
| (string & {})
export type WechatActionPurpose =
/**
* 历史用途:退群监控自己发通知。
*
* 迁移后**没有任何生产者**,也已从 `AUTOMATION_PURPOSE_ALLOWLIST` 摘除 ——
* 即"旧链路在代码上已经不可能再发出去"。保留类型成员只为让历史 audit 记录可读
* (界面仍把它显示成「退群通知」)。
*/
| 'member_left_notification'
| 'scheduled_report'
| 'tts_voice'
@@ -28,14 +41,16 @@ export type WechatActionPurpose =
/** 日报图片成功后的后置词;只有图片 sent 后才会创建。 */
| 'manual_report_postfix'
/**
* Automation v1 的两个用途。
* Automation 的三个用途。
*
* ⚠️ 这两个值必须同步登记进 `wechat-action-gateway.ts` 的
* ⚠️ 这三个值必须同步登记进 `wechat-action-gateway.ts` 的
* `AUTOMATION_PURPOSE_ALLOWLIST`,否则会被策略层以 `ACTION_NOT_ALLOWED` 拦下。
* 与 `shared/automation.ts` 的 `AUTOMATION_SEND_PURPOSE` 保持一致。
*/
| 'automation_reply'
| 'automation_report'
/** 退群通知(由 Automation 发出)。目标可以是群 / 自己 / 文件传输助手 / 指定好友。 */
| 'automation_leave_notification'
| (string & {})
export type WechatActionTriggerType = 'automation' | 'user'
@@ -99,11 +114,6 @@ export interface PolicyDecision {
reason?: string
}
export interface WechatActionMemberEventReference {
id: string
roomId: string
}
export interface WechatActionAuditRecord {
actionId: string
idempotencyKey?: string
+19
View File
@@ -0,0 +1,19 @@
/**
* 微信协议层的**固定身份**。
*
* 为什么要有这个文件:这些值不是「昵称」,是协议里写死的 username。
* 用昵称去搜索(`nickname === '文件传输助手'`)在改过备注、多语言、
* 或同名联系人存在时会直接选错发送对象 —— 而发错人是不可撤销的。
*/
/**
* 文件传输助手。
*
* 微信客户端的固定 username,不随账号语言、昵称、备注变化。
* **真机核对项**:需在真机实测一次。
*/
export const WECHAT_FILE_HELPER_USERNAME = 'filehelper'
export function isFileHelperUsername(value: string | undefined | null): boolean {
return String(value || '').trim().toLowerCase() === WECHAT_FILE_HELPER_USERNAME
}
@@ -0,0 +1,300 @@
import { render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import React from 'react'
import { describe, expect, it, vi } from 'vitest'
import { ToastProvider } from '../../src/renderer/src/components/ui'
import {
AutomationWorkspace,
type AutomationOpenRuleRequest
} from '../../src/renderer/src/features/automation/AutomationWorkspace'
import {
BUILTIN_DAILY_REPORT_RULE_ID,
createDefaultDailyReportRule,
createDefaultLeaveNotificationRule,
type AutomationRule
} from '../../src/shared/automation'
/**
* 规则类型切换的**目标归属**回归。
*
* 真实事故:从「退群通知」卡片进编辑器(编辑器内部记的是"没有具体目标"),
* 切到「@我生成日报」Tab 后保存 —— 结果改掉了**内置日报**(用户看到的就是
* "我编辑退群通知,保存却改到了另一条日报上")。
*
* 根因两条,都要锁死:
* 1. 切换类型时继承了上一个类型的 `mode`,留下 `mode='edit'` 却没有目标的中间态;
* 2. 编辑目标**不可见** —— 表单不显示它在改哪条规则。
*/
const DAILY_NAME = '我的日报'
function installApi(): {
rules: AutomationRule[]
updateAutomationRule: ReturnType<typeof vi.fn>
createAutomationRule: ReturnType<typeof vi.fn>
saveLeaveNotificationRule: ReturnType<typeof vi.fn>
} {
const renamedDaily: AutomationRule = {
...createDefaultDailyReportRule(Date.now()),
name: DAILY_NAME
}
const leaveRule = createDefaultLeaveNotificationRule(Date.now())
const rules = [renamedDaily, leaveRule]
const updateAutomationRule = vi.fn(async (id: string, draft: AutomationRule) => ({
...renamedDaily,
...draft,
id
}))
const createAutomationRule = vi.fn(async (draft: AutomationRule) => ({
...renamedDaily,
...draft,
id: 'created-rule'
}))
const saveLeaveNotificationRule = vi.fn(async (draft: AutomationRule) => draft)
window.api = {
getAutomationStatus: vi.fn().mockResolvedValue({
listening: true,
listeningDegraded: false,
todayExecutions: 0,
todaySuccesses: 0,
sendCapability: {
supported: true,
ready: true,
canSendText: true,
canSendImage: true,
message: 'ready'
}
}),
listAutomationRules: vi.fn().mockResolvedValue(rules),
listAutomationGroups: vi.fn().mockResolvedValue([]),
listAutomationExecutions: vi.fn().mockResolvedValue([]),
listSendableContacts: vi.fn().mockResolvedValue([]),
getGroupExitMonitorState: vi.fn().mockResolvedValue({
events: [],
enabled: true,
running: true,
nativeMonitorActive: true,
monitoredGroupCount: 3,
monitorSelectionConfigured: true,
monitoredRoomIds: [],
lastReadAt: 0,
unreadCount: 0
}),
onGroupExitMonitorState: vi.fn(() => () => undefined),
updateAutomationRule,
createAutomationRule,
saveLeaveNotificationRule
} as unknown as typeof window.api
return { rules, updateAutomationRule, createAutomationRule, saveLeaveNotificationRule }
}
const renderWorkspace = (): void => {
render(
<ToastProvider>
<AutomationWorkspace dbReady onOpenExitMonitorGroups={() => {}} />
</ToastProvider>
)
}
const editingTarget = (): string =>
screen.getByTestId('automation-editing-target').textContent ?? ''
const leaveCard = async (): Promise<HTMLElement> =>
(await screen.findByText('退群通知')).closest('article') as HTMLElement
describe('规则类型切换 · 编辑目标归属', () => {
it('从退群通知进编辑器时,「正在编辑」显示退群通知', async () => {
const user = userEvent.setup()
installApi()
renderWorkspace()
await user.click(within(await leaveCard()).getByRole('button', { name: '编辑' }))
expect(editingTarget()).toContain('退群通知')
})
/*
* 事故核心:切到日报后,界面必须**明说**现在改的是哪条。
* 以前这里什么都不显示,用户以为还在改退群通知。
*/
it('切到日报后,「正在编辑」显示那条日报的名字(不再隐式指向未知目标)', async () => {
const user = userEvent.setup()
installApi()
renderWorkspace()
await user.click(within(await leaveCard()).getByRole('button', { name: '编辑' }))
await user.click(screen.getByRole('radio', { name: '@我生成日报' }))
expect(editingTarget()).toContain(DAILY_NAME)
expect(editingTarget()).not.toContain('退群通知')
})
it('切到日报后保存:只更新那一条日报,退群通知与新建都不受影响', async () => {
const user = userEvent.setup()
const api = installApi()
renderWorkspace()
await user.click(within(await leaveCard()).getByRole('button', { name: '编辑' }))
await user.click(screen.getByRole('radio', { name: '@我生成日报' }))
await user.click(screen.getByRole('button', { name: '保存' }))
await waitFor(() => expect(api.updateAutomationRule).toHaveBeenCalledTimes(1))
// 必须更新"屏幕上显示的那条"。
expect(api.updateAutomationRule.mock.calls[0][0]).toBe(BUILTIN_DAILY_REPORT_RULE_ID)
// 绝不能顺手新建一条重复规则,也不能动退群通知。
expect(api.createAutomationRule).not.toHaveBeenCalled()
expect(api.saveLeaveNotificationRule).not.toHaveBeenCalled()
})
it('从日报卡片进入并保存:更新日报,且不会误存到退群通知', async () => {
const user = userEvent.setup()
const api = installApi()
renderWorkspace()
const dailyCard = (await screen.findByText(DAILY_NAME)).closest('article') as HTMLElement
await user.click(within(dailyCard).getByRole('button', { name: '编辑' }))
expect(editingTarget()).toContain(DAILY_NAME)
await user.click(screen.getByRole('button', { name: '保存' }))
await waitFor(() => expect(api.updateAutomationRule).toHaveBeenCalledTimes(1))
expect(api.saveLeaveNotificationRule).not.toHaveBeenCalled()
})
it('从日报切到退群通知后保存:走退群通知通道,不改日报', async () => {
const user = userEvent.setup()
const api = installApi()
renderWorkspace()
const dailyCard = (await screen.findByText(DAILY_NAME)).closest('article') as HTMLElement
await user.click(within(dailyCard).getByRole('button', { name: '编辑' }))
await user.click(screen.getByRole('radio', { name: '退群通知' }))
expect(editingTarget()).toContain('退群通知')
await user.click(screen.getByRole('button', { name: '保存' }))
await waitFor(() => expect(api.saveLeaveNotificationRule).toHaveBeenCalledTimes(1))
expect(api.updateAutomationRule).not.toHaveBeenCalled()
})
it('内置日报不存在时切到日报:显示「新建自动化」,保存走新建而不是更新', async () => {
const user = userEvent.setup()
const api = installApi()
// 只剩退群通知(等价于用户把内置日报删掉了)。
;(window.api.listAutomationRules as ReturnType<typeof vi.fn>).mockResolvedValue([
createDefaultLeaveNotificationRule(Date.now())
])
renderWorkspace()
await user.click(within(await leaveCard()).getByRole('button', { name: '编辑' }))
await user.click(screen.getByRole('radio', { name: '@我生成日报' }))
expect(editingTarget()).toContain('新建自动化')
await user.click(screen.getByRole('button', { name: '保存' }))
await waitFor(() => expect(api.createAutomationRule).toHaveBeenCalledTimes(1))
expect(api.updateAutomationRule).not.toHaveBeenCalled()
expect(api.saveLeaveNotificationRule).not.toHaveBeenCalled()
})
/*
* 日报可以有多条。切走再切回时必须回到**刚才那条**,
* 否则用户会在不知情的情况下被带到内置日报上(等于换个方式误改规则)。
*/
it('从别的日报进入,切走再切回仍指向那条日报,不会被换回内置日报', async () => {
const user = userEvent.setup()
const api = installApi()
const OTHER_NAME = '我的另一条日报'
;(window.api.listAutomationRules as ReturnType<typeof vi.fn>).mockResolvedValue([
{ ...createDefaultDailyReportRule(Date.now()), name: DAILY_NAME },
{
...createDefaultDailyReportRule(Date.now()),
id: 'custom-daily',
name: OTHER_NAME
},
createDefaultLeaveNotificationRule(Date.now())
])
renderWorkspace()
const otherCard = (await screen.findByText(OTHER_NAME)).closest('article') as HTMLElement
await user.click(within(otherCard).getByRole('button', { name: '编辑' }))
expect(editingTarget()).toContain(OTHER_NAME)
await user.click(screen.getByRole('radio', { name: '退群通知' }))
await user.click(screen.getByRole('radio', { name: '@我生成日报' }))
expect(editingTarget()).toContain(OTHER_NAME)
expect(editingTarget()).not.toContain(DAILY_NAME)
// 保存也必须落在这一条上。
await user.click(screen.getByRole('button', { name: '保存' }))
await waitFor(() => expect(api.updateAutomationRule).toHaveBeenCalledTimes(1))
expect(api.updateAutomationRule.mock.calls[0][0]).toBe('custom-daily')
})
/*
* 同类 bug 的第二处:`mode === 'edit' || !target ? createRule : updateRule` ——
* "编辑态但没有目标"被静默当成"新建",用户以为在改 A,结果多出一条新规则。
* 现在必须明确报错,且不能给 User 一个看起来能保存的空表单。
*/
it('编辑态但目标规则已不存在:不给新建入口,明确提示', async () => {
const api = installApi()
// 内置日报不存在(等价于用户把它删掉了)。
;(window.api.listAutomationRules as ReturnType<typeof vi.fn>).mockResolvedValue([
createDefaultLeaveNotificationRule(Date.now())
])
function DeepLinkHarness(): React.ReactElement {
const [request] = React.useState<AutomationOpenRuleRequest>({
ruleType: 'daily_report',
requestId: 1
})
return <AutomationWorkspace dbReady openRuleRequest={request} />
}
render(
<ToastProvider>
<DeepLinkHarness />
</ToastProvider>
)
expect(await screen.findByText(/要编辑的规则已不存在/)).toBeVisible()
// 关键:没有可点的「保存」——否则用户点下去就会凭空新建一条规则。
expect(screen.queryByRole('button', { name: '保存' })).toBeNull()
expect(api.createAutomationRule).not.toHaveBeenCalled()
expect(api.updateAutomationRule).not.toHaveBeenCalled()
})
it('切类型不会静默新建出重复规则', async () => {
const user = userEvent.setup()
const api = installApi()
renderWorkspace()
await user.click(within(await leaveCard()).getByRole('button', { name: '编辑' }))
for (const label of ['@我生成日报', '退群通知', '@我生成日报', '退群通知']) {
await user.click(screen.getByRole('radio', { name: label }))
}
expect(api.createAutomationRule).not.toHaveBeenCalled()
expect(api.updateAutomationRule).not.toHaveBeenCalled()
expect(api.saveLeaveNotificationRule).not.toHaveBeenCalled()
})
it('Tab 文案说明它是「切换规则类型」,不是改当前规则的类型', async () => {
const user = userEvent.setup()
installApi()
renderWorkspace()
await user.click(within(await leaveCard()).getByRole('button', { name: '编辑' }))
expect(screen.getByText('切换规则类型')).toBeVisible()
expect(
screen.getByRole('radiogroup', { name: '切换规则类型' }) as HTMLElement
).toBeInTheDocument()
})
})
@@ -1,4 +1,4 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import type { ReactElement } from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -48,7 +48,6 @@ const state: GroupExitMonitorState = {
monitoredGroupCount: 3,
monitorSelectionConfigured: true,
monitoredRoomIds: ['研发群@chatroom', '设计群@chatroom'],
notificationRoomIds: [],
lastCheckedAt: 1_756_600_000_000,
lastReadAt: 0,
unreadCount: 1
@@ -163,25 +162,23 @@ describe('GroupExitMonitorWorkspace', () => {
await user.click(screen.getByRole('checkbox', { name: '监控研发群' }))
await user.click(screen.getByRole('button', { name: '保存监控群聊' }))
await waitFor(() => expect(setGroups).toHaveBeenCalledWith(['研发群@chatroom'], []))
await waitFor(() => expect(setGroups).toHaveBeenCalledWith(['研发群@chatroom']))
expect(screen.getByRole('heading', { name: '退群监控' })).toBeVisible()
expect(screen.getByText('已监控群聊')).toBeVisible()
})
it('filters group rows by selection and persists notification targets separately', async () => {
it('filters group rows by selection and saves the monitoring scope', async () => {
const user = userEvent.setup()
const configuredState: GroupExitMonitorState = {
...state,
events: [],
monitorSelectionConfigured: true,
monitoredRoomIds: ['研发群@chatroom'],
notificationRoomIds: [],
monitoredGroupCount: 1,
unreadCount: 0
}
const savedState: GroupExitMonitorState = {
...configuredState,
notificationRoomIds: ['研发群@chatroom']
}
const setGroups = vi.fn().mockResolvedValue(savedState)
window.api = {
@@ -209,12 +206,9 @@ describe('GroupExitMonitorWorkspace', () => {
await user.click(screen.getByRole('combobox', { name: '筛选群聊状态' }))
await user.click(await screen.findByRole('option', { name: '全部' }))
await user.click(screen.getByRole('checkbox', { name: '是否通知研发群' }))
await user.click(screen.getByRole('button', { name: '保存监控群聊' }))
await waitFor(() =>
expect(setGroups).toHaveBeenCalledWith(['研发群@chatroom'], ['研发群@chatroom'])
)
await waitFor(() => expect(setGroups).toHaveBeenCalledWith(['研发群@chatroom']))
})
it('selects every group even when a search term is active', async () => {
@@ -224,7 +218,6 @@ describe('GroupExitMonitorWorkspace', () => {
events: [],
monitorSelectionConfigured: true,
monitoredRoomIds: [],
notificationRoomIds: [],
monitoredGroupCount: 0,
unreadCount: 0
}
@@ -244,7 +237,7 @@ describe('GroupExitMonitorWorkspace', () => {
await user.click(screen.getByRole('button', { name: '保存监控群聊' }))
await waitFor(() =>
expect(setGroups).toHaveBeenCalledWith(['研发群@chatroom', '设计群@chatroom'], [])
expect(setGroups).toHaveBeenCalledWith(['研发群@chatroom', '设计群@chatroom'])
)
})
@@ -275,43 +268,17 @@ describe('GroupExitMonitorWorkspace', () => {
await waitFor(() => expect(screen.getByRole('tooltip')).toHaveTextContent('请先绑定个人微信'))
})
it('shows the notification template from the management page header', async () => {
/*
* 迁移后这里**不再有模板入口与「通知群聊」列**:
* 模板的唯一编辑处是「自动化 → 规则 → 退群通知」。
* 退群监控只负责监控范围与退群事实。
*/
it('管理群聊页不再提供通知模板与通知群聊配置', async () => {
const user = userEvent.setup()
window.api = {
getGroupExitMonitorState: vi.fn().mockResolvedValue(state),
onGroupExitMonitorState: vi.fn(() => () => undefined),
getPersonalWechatSendCapability: vi.fn().mockResolvedValue({
supported: true,
ready: false,
status: 'needs_binding',
capabilities: { text: false, image: false, voice: false },
senderStatus: {} as never,
message: '请先绑定个人微信'
}),
setGroupExitMonitorGroups: vi.fn().mockResolvedValue(state),
checkGroupExitMonitorNow: vi.fn().mockResolvedValue(state),
clearGroupExitMonitorEvents: vi.fn().mockResolvedValue(state)
} as typeof window.api
renderWorkspace(<GroupExitMonitorWorkspace dbReady contacts={groups} />)
await user.click(await screen.findByRole('button', { name: '管理群聊' }))
await user.click(screen.getByRole('button', { name: '查看退群监测模板' }))
expect(screen.getByRole('heading', { name: '退群监测模板' })).toBeVisible()
const template = screen.getByLabelText('退群监测模板内容')
expect((template as HTMLTextAreaElement).value).toContain('用户: {user}')
expect((template as HTMLTextAreaElement).value).toContain('群备注: {groupRemark}')
})
it('edits and persists the notification template', async () => {
const user = userEvent.setup()
const savedTemplate = '[退群监测]\n用户: {user}\n群: {groupRemark}'
const setTemplate = vi.fn().mockResolvedValue({ ...state, notificationTemplate: savedTemplate })
window.api = {
getGroupExitMonitorState: vi.fn().mockResolvedValue(state),
onGroupExitMonitorState: vi.fn(() => () => undefined),
getPersonalWechatSendCapability: vi.fn().mockResolvedValue(null),
setGroupExitMonitorNotificationTemplate: setTemplate,
setGroupExitMonitorGroups: vi.fn().mockResolvedValue(state),
checkGroupExitMonitorNow: vi.fn().mockResolvedValue(state),
clearGroupExitMonitorEvents: vi.fn().mockResolvedValue(state)
@@ -319,16 +286,16 @@ describe('GroupExitMonitorWorkspace', () => {
renderWorkspace(<GroupExitMonitorWorkspace dbReady contacts={groups} />)
await user.click(await screen.findByRole('button', { name: '管理群聊' }))
await user.click(screen.getByRole('button', { name: '查看退群监测模板' }))
const template = screen.getByLabelText('退群监测模板内容')
fireEvent.change(template, { target: { value: savedTemplate } })
await user.click(screen.getByRole('button', { name: '保存模板' }))
await waitFor(() => expect(setTemplate).toHaveBeenCalledWith(savedTemplate))
expect(screen.queryByRole('heading', { name: '退群监测模板' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: '查看退群监测模板' })).toBeNull()
expect(screen.queryByRole('heading', { name: '退群监测模板' })).toBeNull()
expect(screen.queryByText('通知群聊')).toBeNull()
expect(screen.queryByLabelText('退群监测模板内容')).toBeNull()
// 监控选择本身仍然可用。
expect(screen.getByRole('checkbox', { name: '监控研发群' })).toBeInTheDocument()
})
it('copies the exit notice built from the template saved on the management page', async () => {
it('复制退群信息用的模板来自自动化规则,而不是退群监控自己存的', async () => {
const user = userEvent.setup()
const template = [
'[退群监测]',
@@ -340,11 +307,19 @@ describe('GroupExitMonitorWorkspace', () => {
].join('\n')
const copyText = vi.fn().mockResolvedValue({ success: true })
window.api = {
getGroupExitMonitorState: vi
.fn()
.mockResolvedValue({ ...state, notificationTemplate: template }),
getGroupExitMonitorState: vi.fn().mockResolvedValue(state),
onGroupExitMonitorState: vi.fn(() => () => undefined),
copyText,
// 模板唯一来源:自动化里的退群通知规则。
listAutomationRules: vi.fn().mockResolvedValue([
{
id: 'builtin-leave-notification',
name: '退群通知',
enabled: true,
ruleType: 'leave_notification',
leaveNotification: { target: { type: 'file_transfer' }, template }
}
]),
checkGroupExitMonitorNow: vi.fn().mockResolvedValue(state),
clearGroupExitMonitorEvents: vi.fn().mockResolvedValue(state)
} as typeof window.api
@@ -396,7 +371,6 @@ describe('GroupExitMonitorWorkspace', () => {
events: [],
monitorSelectionConfigured: false,
monitoredRoomIds: [],
notificationRoomIds: [],
monitoredGroupCount: 0,
unreadCount: 0
}
@@ -0,0 +1,665 @@
import React from 'react'
import { render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import type { ReactElement } from 'react'
import { describe, expect, it, vi } from 'vitest'
import { ToastProvider, TooltipProvider } from '../../src/renderer/src/components/ui'
import {
AutomationWorkspace,
type AutomationOpenRuleRequest
} from '../../src/renderer/src/features/automation/AutomationWorkspace'
import { GroupExitMonitorWorkspace } from '../../src/renderer/src/features/group-exit-monitor/GroupExitMonitorWorkspace'
import {
BUILTIN_LEAVE_NOTIFICATION_RULE_ID,
createDefaultDailyReportRule,
createDefaultLeaveNotificationRule,
type AutomationRule
} from '../../src/shared/automation'
import type { GroupExitMonitorState } from '../../src/shared/group-exit-monitor'
/**
* 「自动化 → 规则 → 退群通知」现在是**真实配置页**。
*
* 这组测试锁的是「真实化」之后的契约:
* - 已监控群聊数量来自退群监控(不是硬编码的 243);
* - 好友清单来自 main 侧的可发送联系人接口(不是 mock 的张三李四);
* - 保存真的会调用 singleton upsert;
* - **效果预览永远不发送**;
* - 退群监控里旧的通知群聊列与模板入口已经不存在。
*/
const MONITOR_STATE: GroupExitMonitorState = {
events: [],
enabled: true,
running: true,
nativeMonitorActive: true,
monitoredGroupCount: 7,
monitorSelectionConfigured: true,
monitoredRoomIds: ['a@chatroom', 'b@chatroom', 'c@chatroom'],
lastReadAt: 0,
unreadCount: 0
}
const SENDABLE_CONTACTS = [
{ id: 'wxid_friend_a', name: '好友甲' },
{ id: 'wxid_friend_b', name: '好友乙' }
]
interface ApiSpies {
saveLeaveNotificationRule: ReturnType<typeof vi.fn>
createAutomationRule: ReturnType<typeof vi.fn>
updateAutomationRule: ReturnType<typeof vi.fn>
deleteAutomationRule: ReturnType<typeof vi.fn>
setAutomationRuleEnabled: ReturnType<typeof vi.fn>
setGroupExitMonitorGroups: ReturnType<typeof vi.fn>
listSendableContacts: ReturnType<typeof vi.fn>
listAutomationRules: ReturnType<typeof vi.fn>
}
function installApi(
overrides: { leaveRule?: AutomationRule } = {}
): { spies: ApiSpies; rules: AutomationRule[] } {
const leaveRule = overrides.leaveRule ?? createDefaultLeaveNotificationRule(Date.now())
const rules = [createDefaultDailyReportRule(Date.now()), leaveRule]
const spies: ApiSpies = {
saveLeaveNotificationRule: vi.fn(async (draft: AutomationRule) => ({
...leaveRule,
...draft,
id: BUILTIN_LEAVE_NOTIFICATION_RULE_ID
})),
createAutomationRule: vi.fn().mockResolvedValue(null),
updateAutomationRule: vi.fn().mockResolvedValue(null),
deleteAutomationRule: vi.fn().mockResolvedValue(false),
setAutomationRuleEnabled: vi.fn().mockResolvedValue(null),
setGroupExitMonitorGroups: vi.fn().mockResolvedValue(MONITOR_STATE),
listSendableContacts: vi.fn().mockResolvedValue(SENDABLE_CONTACTS),
listAutomationRules: vi.fn().mockResolvedValue(rules)
}
window.api = {
getAutomationStatus: vi.fn().mockResolvedValue({
listening: true,
listeningDegraded: false,
todayExecutions: 0,
todaySuccesses: 0,
sendCapability: {
supported: true,
ready: true,
canSendText: true,
canSendImage: true,
message: '个人微信发送能力已就绪'
}
}),
listAutomationRules: spies.listAutomationRules,
listAutomationGroups: vi.fn().mockResolvedValue([]),
listAutomationExecutions: vi.fn().mockResolvedValue([]),
listSendableContacts: spies.listSendableContacts,
getGroupExitMonitorState: vi.fn().mockResolvedValue(MONITOR_STATE),
onGroupExitMonitorState: vi.fn(() => () => undefined),
saveLeaveNotificationRule: spies.saveLeaveNotificationRule,
createAutomationRule: spies.createAutomationRule,
updateAutomationRule: spies.updateAutomationRule,
deleteAutomationRule: spies.deleteAutomationRule,
setAutomationRuleEnabled: spies.setAutomationRuleEnabled,
setGroupExitMonitorGroups: spies.setGroupExitMonitorGroups
} as unknown as typeof window.api
return { spies, rules }
}
const renderWithProviders = (element: ReactElement): void => {
render(<ToastProvider>{element}</ToastProvider>)
}
const renderMonitorWithProviders = (element: ReactElement): void => {
render(
<TooltipProvider>
<ToastProvider>{element}</ToastProvider>
</TooltipProvider>
)
}
/** 找到首页那张「退群通知」卡片(现在有两张卡,都有「编辑」)。 */
async function leaveNotificationCard(): Promise<HTMLElement> {
return (await screen.findByText('退群通知')).closest('article') as HTMLElement
}
/** 从自动化首页那张退群通知卡片的「编辑」进编辑器。 */
async function openLeaveNotificationEditor(overrides: {
leaveRule?: AutomationRule
} = {}): Promise<{ spies: ApiSpies }> {
const user = userEvent.setup()
const { spies } = installApi(overrides)
renderWithProviders(<AutomationWorkspace dbReady onOpenExitMonitorGroups={() => {}} />)
const card = await leaveNotificationCard()
await user.click(within(card).getByRole('button', { name: '编辑' }))
return { spies }
}
const previewRecipient = (): string =>
screen.getByTestId('leave-preview-recipient').textContent ?? ''
const previewFact = (label: string): string => {
const rows = Array.from(document.querySelectorAll('.automation-preview-facts > div'))
for (const row of rows) {
if (row.querySelector('dt')?.textContent === label) {
return row.querySelector('dd')?.textContent ?? ''
}
}
return ''
}
const targetRadio = (label: string): HTMLElement => screen.getByRole('radio', { name: label })
describe('自动化 · 规则类型 Tab', () => {
it('两种规则类型都是真规则,切换互不影响', async () => {
const user = userEvent.setup()
installApi()
renderWithProviders(<AutomationWorkspace dbReady />)
await screen.findByText('退群通知')
await user.click(screen.getAllByRole('button', { name: '新建自动化' })[0])
expect(screen.getByLabelText('关键词')).toBeInTheDocument()
await user.click(screen.getByRole('radio', { name: '退群通知' }))
expect(screen.getByLabelText('退群通知模板内容')).toBeInTheDocument()
expect(screen.queryByLabelText('关键词')).toBeNull()
await user.click(screen.getByRole('radio', { name: '@我生成日报' }))
expect(screen.getByLabelText('关键词')).toBeInTheDocument()
})
it('首页卡片显示真实状态与真实目标,不再有「待配置 / UI 预览」', async () => {
installApi()
renderWithProviders(<AutomationWorkspace dbReady />)
await screen.findByText('退群通知')
const card = screen.getByText('退群通知').closest('article') as HTMLElement
expect(card.textContent).toContain('运行中')
expect(card.textContent).toContain('当前群聊')
expect(card.textContent).toContain('3 个已监控群聊')
expect(document.body.textContent).not.toContain('待配置')
expect(document.body.textContent).not.toContain('UI 预览')
})
it('迁移待重选的规则在首页显示为「配置异常」', async () => {
installApi({
leaveRule: createDefaultLeaveNotificationRule(Date.now(), {
enabled: false,
targetNeedsReview: true
})
})
renderWithProviders(<AutomationWorkspace dbReady />)
const card = (await screen.findByText('退群通知')).closest('article') as HTMLElement
expect(card.textContent).toContain('配置异常')
})
})
describe('退群通知 · 真实数据', () => {
it('已监控群聊数量来自退群监控', async () => {
await openLeaveNotificationEditor()
expect(await screen.findByText('3 个群聊')).toBeVisible()
expect(screen.queryByText('243 个群聊')).toBeNull()
})
it('好友清单来自 main 侧的可发送联系人接口', async () => {
const user = userEvent.setup()
await openLeaveNotificationEditor()
await user.click(targetRadio('指定好友'))
expect(screen.getByRole('radio', { name: '好友甲' })).toBeInTheDocument()
expect(screen.getByRole('radio', { name: '好友乙' })).toBeInTheDocument()
expect(screen.queryByRole('radio', { name: '张三' })).toBeNull()
})
it('Section 1 没有「触发范围」,只有唯一的「管理监控群聊」入口', async () => {
await openLeaveNotificationEditor()
expect(screen.queryByText('触发范围')).toBeNull()
expect(screen.queryByText('全部已监控群聊')).toBeNull()
expect(screen.queryByText('指定已监控群聊')).toBeNull()
expect(screen.getByRole('button', { name: /管理监控群聊/ })).toBeEnabled()
})
it('规则级启停只有一个开关,没有第二个「发送退群通知」', async () => {
await openLeaveNotificationEditor()
expect(screen.getAllByRole('switch')).toHaveLength(1)
expect(screen.getByRole('switch', { name: '启用这条自动化' })).toBeVisible()
expect(screen.queryByText('发送退群通知')).toBeNull()
})
})
describe('退群通知 · 四种目标', () => {
it('是四选一,「当前群聊」排第一且默认选中', async () => {
await openLeaveNotificationEditor()
const order = Array.from(
document.querySelectorAll('.automation-leave-targets .automation-leave-radio label')
).map((label) => label.textContent?.trim())
expect(order).toEqual(['当前群聊', '文件传输助手', '发给自己', '指定好友'])
expect(targetRadio('当前群聊')).toBeChecked()
})
it('只有「指定好友」才展开好友选择器', async () => {
const user = userEvent.setup()
await openLeaveNotificationEditor()
for (const label of ['文件传输助手', '当前群聊', '发给自己']) {
await user.click(targetRadio(label))
expect(screen.queryByPlaceholderText('搜索好友')).toBeNull()
}
await user.click(targetRadio('指定好友'))
expect(screen.getByPlaceholderText('搜索好友')).toBeVisible()
})
it('好友只能选一个', async () => {
const user = userEvent.setup()
await openLeaveNotificationEditor()
await user.click(targetRadio('指定好友'))
await user.click(screen.getByRole('radio', { name: '好友甲' }))
expect(screen.getByRole('radio', { name: '好友甲' })).toBeChecked()
await user.click(screen.getByRole('radio', { name: '好友乙' }))
expect(screen.getByRole('radio', { name: '好友乙' })).toBeChecked()
expect(screen.getByRole('radio', { name: '好友甲' })).not.toBeChecked()
})
})
describe('退群通知 · 保存与取消', () => {
it('保存会写入 singleton 规则(含目标、模板、开关)', async () => {
const user = userEvent.setup()
const { spies } = await openLeaveNotificationEditor()
// 刻意选一个**与默认不同**的目标:这样才验证了"保存的是用户选的"。
await user.click(targetRadio('发给自己'))
await user.click(screen.getByRole('button', { name: '保存' }))
await waitFor(() => expect(spies.saveLeaveNotificationRule).toHaveBeenCalledTimes(1))
const draft = spies.saveLeaveNotificationRule.mock.calls[0][0] as AutomationRule
expect(draft.id).toBe(BUILTIN_LEAVE_NOTIFICATION_RULE_ID)
expect(draft.ruleType).toBe('leave_notification')
expect(draft.enabled).toBe(true)
expect(draft.leaveNotification?.target).toEqual({ type: 'self' })
expect(draft.leaveNotification?.template).toContain('[退群监测]')
})
it('取消不写任何东西', async () => {
const user = userEvent.setup()
const { spies } = await openLeaveNotificationEditor()
await user.click(targetRadio('发给自己'))
await user.click(screen.getByRole('button', { name: '取消' }))
expect(spies.saveLeaveNotificationRule).not.toHaveBeenCalled()
// 回到首页:卡片上的目标仍是持久化的那个(默认「当前群聊」),不是刚点的「发给自己」。
expect((await screen.findByText('退群通知')).closest('article')?.textContent).toContain(
'当前群聊'
)
})
it('重新打开时恢复的是已持久化配置,不是上次未保存的改动', async () => {
const user = userEvent.setup()
const { spies } = await openLeaveNotificationEditor()
await user.click(targetRadio('发给自己'))
await user.click(screen.getByRole('button', { name: '取消' }))
await user.click(within(await leaveNotificationCard()).getByRole('button', { name: '编辑' }))
expect(targetRadio('当前群聊')).toBeChecked()
expect(spies.saveLeaveNotificationRule).not.toHaveBeenCalled()
})
it('启用开关也会被保存', async () => {
const user = userEvent.setup()
const { spies } = await openLeaveNotificationEditor()
await user.click(screen.getByRole('switch', { name: '启用这条自动化' }))
await user.click(screen.getByRole('button', { name: '保存' }))
await waitFor(() => expect(spies.saveLeaveNotificationRule).toHaveBeenCalledTimes(1))
const draft = spies.saveLeaveNotificationRule.mock.calls[0][0] as AutomationRule
expect(draft.enabled).toBe(false)
})
})
describe('退群通知 · 效果预览', () => {
it('预览用的是模板渲染结果,不是写死的示例', async () => {
await openLeaveNotificationEditor()
const bubble = document.querySelector('.automation-chat-bubble.pre')
expect(bubble?.textContent).toContain('[退群监测]')
// 占位符已按样本事件替换(同一套 shared 渲染规则)。
expect(bubble?.textContent).toContain('用户: 张三')
expect(bubble?.textContent).toContain('人数: 243 -> 242')
})
it('改发送目标 → 顶部模拟收件人跟着变', async () => {
const user = userEvent.setup()
await openLeaveNotificationEditor()
// 默认 = 当前群聊 ⇒ 顶部收件会话就是发生退群的那个群。
expect(previewRecipient()).toBe('TraceMemo 交流群')
await user.click(targetRadio('文件传输助手'))
expect(previewRecipient()).toBe('文件传输助手')
await user.click(targetRadio('发给自己'))
expect(previewRecipient()).toBe('我')
})
it('指定好友时预览收件人跟着好友变,未选时如实说未选择', async () => {
const user = userEvent.setup()
await openLeaveNotificationEditor()
await user.click(targetRadio('指定好友'))
expect(previewRecipient()).toBe('未选择好友')
await user.click(screen.getByRole('radio', { name: '好友甲' }))
expect(previewRecipient()).toBe('好友甲')
})
it('底部摘要随目标变化,且不含「执行方式」', async () => {
const user = userEvent.setup()
await openLeaveNotificationEditor()
expect(previewFact('触发事件')).toBe('成员退出')
expect(previewFact('监控来源')).toBe('3 个已监控群聊')
expect(previewFact('通知目标')).toBe('发生退群事件的群聊')
expect(document.querySelector('.automation-preview-facts')?.textContent).not.toContain('执行方式')
await user.click(targetRadio('文件传输助手'))
expect(previewFact('通知目标')).toBe('文件传输助手')
})
it('预览永远不触发发送与保存', async () => {
const user = userEvent.setup()
const { spies } = await openLeaveNotificationEditor()
await user.click(targetRadio('当前群聊'))
await user.click(targetRadio('指定好友'))
for (const spy of [
spies.saveLeaveNotificationRule,
spies.createAutomationRule,
spies.updateAutomationRule,
spies.setAutomationRuleEnabled
]) {
expect(spy).not.toHaveBeenCalled()
}
})
it('编辑器里不出现底层实现词', async () => {
await openLeaveNotificationEditor()
expect(document.body.textContent).not.toMatch(/AutomationRule|trigger matcher|ActionRunner|WCDB/)
})
})
describe('退群通知 · 通知内容与变量', () => {
it('变量清单以 {groupName} 打头,且可以插进模板', async () => {
const user = userEvent.setup()
await openLeaveNotificationEditor()
const chips = Array.from(document.querySelectorAll('.automation-leave-template-chip')).map(
(chip) => chip.textContent
)
expect(chips).toEqual([
'{groupName}',
'{user}',
'{groupRemark}',
'{wxid}',
'{previousCount}',
'{currentCount}',
'{time}'
])
await user.click(screen.getByRole('button', { name: '插入变量 groupName' }))
const textarea = screen.getByLabelText('退群通知模板内容') as HTMLTextAreaElement
expect(textarea.value).toContain('{groupName}')
})
/*
* 预览必须能同时看出「哪个群」和「谁退的」:
* groupName 与 groupRemark 一旦取值相同,两者的区别就从界面上消失了。
*/
it('预览里群名与成员群昵称是两个不同的值', async () => {
await openLeaveNotificationEditor()
const bubble = document.querySelector('.automation-chat-bubble.pre')
expect(bubble?.textContent).toContain('群聊: TraceMemo 交流群')
expect(bubble?.textContent).toContain('群备注: 小张')
})
it('底部说明直说「群名 vs 成员群昵称」的区别,且不含旧语义残句', async () => {
await openLeaveNotificationEditor()
const note = document.querySelector('.automation-leave-template-note')?.textContent ?? ''
expect(note).toContain('未读取到')
// 实测有人把 {groupRemark} 当成群名 —— 这句必须直说区别。
expect(note).toContain('群聊名')
expect(note).toContain('不是群名')
// 旧文案两句都是错的:回退链被安到了 groupRemark 上;后半句是"只能发回原群"时代的残句。
expect(note).not.toContain('通知仅发送到本规则配置的通知群聊')
expect(note).not.toContain('群备注为空时可回退显示群昵称')
})
/*
* 变量清单原先只有 {groupRemark} 这种符号,用户看不出含义,
* 于是有人把它当成群名。每个 chip 必须带中文说明。
*/
it('每个变量 chip 都带中文含义说明', async () => {
await openLeaveNotificationEditor()
const chips = Array.from(document.querySelectorAll('.automation-leave-template-chip'))
const titleOf = (token: string): string =>
chips
.find((chip) => chip.textContent?.trim() === token)
?.getAttribute('title') ?? ''
expect(titleOf('{groupName}')).toBe('{groupName}:群聊名(发生退群的群)')
expect(titleOf('{groupRemark}')).toContain('不是群名')
expect(titleOf('{user}')).toContain('退群成员')
expect(titleOf('{time}')).toContain('退群时间')
})
})
describe('退群通知 · 群聊名护栏', () => {
/** 一份没有群名变量的模板(模拟用户主动删掉了那一行)。 */
const ruleWithoutGroupName = (): AutomationRule => ({
...createDefaultLeaveNotificationRule(Date.now()),
leaveNotification: {
target: { type: 'source_chat' },
template: '[退群监测]\n\n用户: {user}'
}
})
it('目标是「当前群聊」时不提示 —— 通知就发在群里,群名是多余的', async () => {
await openLeaveNotificationEditor({ leaveRule: ruleWithoutGroupName() })
expect(screen.queryByText(/收件人看不出是哪个群退的人/)).toBeNull()
expect(screen.queryByRole('button', { name: '补上群聊名' })).toBeNull()
})
it('目标改成非当前群聊时才提示,并给一键补上', async () => {
const user = userEvent.setup()
await openLeaveNotificationEditor({ leaveRule: ruleWithoutGroupName() })
await user.click(targetRadio('文件传输助手'))
expect(await screen.findByText(/收件人看不出是哪个群退的人/)).toBeVisible()
await user.click(screen.getByRole('button', { name: '补上群聊名' }))
const textarea = screen.getByLabelText('退群通知模板内容') as HTMLTextAreaElement
expect(textarea.value).toContain('群聊: {groupName}')
// 补完提示就消失。
expect(screen.queryByText(/收件人看不出是哪个群退的人/)).toBeNull()
})
it('模板本来就含群名时,任何目标都不提示', async () => {
const user = userEvent.setup()
await openLeaveNotificationEditor()
await user.click(targetRadio('发给自己'))
expect(screen.queryByText(/收件人看不出是哪个群退的人/)).toBeNull()
})
})
describe('退群监控 → 退群通知自动化', () => {
it('退群监控页顶部有自动化入口', async () => {
installApi()
renderMonitorWithProviders(
<GroupExitMonitorWorkspace dbReady onOpenLeaveNotificationAutomation={() => {}} />
)
expect(await screen.findByRole('button', { name: '配置退群通知自动化' })).toBeVisible()
})
it('点入口直接落到「自动化 → 规则 → 退群通知」', async () => {
const user = userEvent.setup()
installApi()
function Harness(): React.ReactElement {
const [page, setPage] = React.useState<'exit-monitor' | 'automation'>('exit-monitor')
const [request, setRequest] = React.useState<AutomationOpenRuleRequest | null>(null)
return page === 'exit-monitor' ? (
<GroupExitMonitorWorkspace
dbReady
onOpenLeaveNotificationAutomation={() => {
setRequest({ ruleType: 'leave_notification', requestId: 1 })
setPage('automation')
}}
/>
) : (
<AutomationWorkspace dbReady openRuleRequest={request} />
)
}
renderMonitorWithProviders(<Harness />)
await user.click(await screen.findByRole('button', { name: '配置退群通知自动化' }))
expect(await screen.findByLabelText('退群通知模板内容')).toBeVisible()
expect(screen.getByRole('radio', { name: '退群通知' })).toBeChecked()
})
it('「管理监控群聊 →」把用户送回退群监控的管理群聊页', async () => {
const user = userEvent.setup()
installApi()
const onOpenExitMonitorGroups = vi.fn()
renderWithProviders(
<AutomationWorkspace dbReady onOpenExitMonitorGroups={onOpenExitMonitorGroups} />
)
await user.click(within(await leaveNotificationCard()).getByRole('button', { name: '编辑' }))
await user.click(screen.getByRole('button', { name: /管理监控群聊/ }))
expect(onOpenExitMonitorGroups).toHaveBeenCalledTimes(1)
})
})
describe('退群通知 · 规则缺失时的兜底', () => {
/** 深链的对象引用必须稳定,否则 effect 每次渲染都会重跑。 */
function DeepLinkHarness(): React.ReactElement {
const [request] = React.useState<AutomationOpenRuleRequest>({
ruleType: 'leave_notification',
requestId: 1
})
return <AutomationWorkspace dbReady openRuleRequest={request} />
}
/*
* 深链进来时编辑器是**同步**打开的,而规则列表还在 IPC 回来的路上。
* 这一帧必然还没有规则 —— 它是最正常的加载状态,不能说成"尚未就绪 / 版本不匹配"。
*/
it('规则还在读取时显示加载态,不把正常加载描述成故障', async () => {
const { spies } = installApi()
let release: (rules: AutomationRule[]) => void = () => undefined
spies.listAutomationRules.mockReturnValue(
new Promise<AutomationRule[]>((resolve) => {
release = resolve
})
)
renderWithProviders(<DeepLinkHarness />)
expect(await screen.findByText('正在读取退群通知规则…')).toBeVisible()
// 旧兜底文案(把正常加载说成故障)必须消失。这里用**完整旧句子**匹配 ——
// 宽泛的 /尚未就绪/ 会命中顶部「发送能力」卡片,那是另一块 UI。
expect(screen.queryByText(/退群通知规则尚未就绪/)).toBeNull()
expect(screen.queryByText(/没有找到内置的退群通知规则/)).toBeNull()
release([
createDefaultDailyReportRule(Date.now()),
createDefaultLeaveNotificationRule(Date.now())
])
expect(await screen.findByLabelText('退群通知模板内容')).toBeVisible()
})
it('读完了确实没有这条规则时,给出准确说明与重试入口', async () => {
const user = userEvent.setup()
const { spies } = installApi()
// 只有日报规则 —— 等价于「主进程还是迁移前的旧构建」。
spies.listAutomationRules.mockResolvedValue([createDefaultDailyReportRule(Date.now())])
renderWithProviders(<DeepLinkHarness />)
expect(await screen.findByText('没有找到内置的退群通知规则。')).toBeVisible()
expect(screen.getByText(/请重启 TraceMemo/)).toBeVisible()
expect(screen.queryByText('正在读取退群通知规则…')).toBeNull()
const before = spies.listAutomationRules.mock.calls.length
await user.click(screen.getByRole('button', { name: '重新加载' }))
await waitFor(() =>
expect(spies.listAutomationRules.mock.calls.length).toBeGreaterThan(before)
)
})
it('重试后规则回来了就直接进编辑器', async () => {
const user = userEvent.setup()
const { spies } = installApi()
spies.listAutomationRules
.mockResolvedValueOnce([createDefaultDailyReportRule(Date.now())])
.mockResolvedValue([
createDefaultDailyReportRule(Date.now()),
createDefaultLeaveNotificationRule(Date.now())
])
renderWithProviders(<DeepLinkHarness />)
await user.click(await screen.findByRole('button', { name: '重新加载' }))
expect(await screen.findByLabelText('退群通知模板内容')).toBeVisible()
})
})
describe('退群监控 · 旧通知 UI 已移除', () => {
it('管理群聊页没有「通知群聊」列,也没有「模板」按钮', async () => {
const user = userEvent.setup()
installApi()
renderMonitorWithProviders(
<GroupExitMonitorWorkspace
dbReady
contacts={[
{ m_nsUsrName: 'group_a@chatroom', m_nsNickName: '群 A', md5: 'md5-a', type: 'group' }
]}
onOpenLeaveNotificationAutomation={() => {}}
/>
)
await user.click(await screen.findByRole('button', { name: '管理群聊' }))
expect(screen.queryByText('通知群聊')).toBeNull()
expect(screen.queryByRole('button', { name: '查看退群监测模板' })).toBeNull()
// 监控选择本身仍然可用。
expect(screen.getByLabelText('监控群 A')).toBeInTheDocument()
})
})
+129 -18
View File
@@ -289,9 +289,6 @@ let groupExitMonitorState = {
monitoredGroupCount: 2,
monitorSelectionConfigured: true,
monitoredRoomIds: ['group_regular@chatroom', 'group_folded@chatroom'],
notificationRoomIds: [],
notificationTemplate:
'[退群监测]\n\n用户: {user}\n\n群备注: {groupRemark}\n\n微信号: {wxid}\n\n人数: {previousCount} -> {currentCount}\n\n退群时间: {time}',
lastCheckedAt: fixtureNowMs - 30 * 1000,
lastReadAt: 0,
unreadCount: 1
@@ -304,33 +301,20 @@ const cloneGroupExitMonitorState = () => ({
).length
})
handle('group-exit-monitor:getState', () => cloneGroupExitMonitorState())
handle('group-exit-monitor:setGroups', (roomIds, notificationRoomIds) => {
// 只保存**监控范围**:通知配置已迁到自动化规则。
handle('group-exit-monitor:setGroups', (roomIds) => {
const selected = Array.isArray(roomIds)
? roomIds.filter((roomId) => typeof roomId === 'string' && roomId.endsWith('@chatroom'))
: []
const notifications = Array.isArray(notificationRoomIds)
? notificationRoomIds.filter(
(roomId) => typeof roomId === 'string' && selected.includes(roomId)
)
: []
groupExitMonitorState = {
...groupExitMonitorState,
monitorSelectionConfigured: true,
monitoredRoomIds: [...new Set(selected)],
notificationRoomIds: [...new Set(notifications)],
monitoredGroupCount: selected.length,
lastCheckedAt: fixtureNowMs
}
return cloneGroupExitMonitorState()
})
handle('group-exit-monitor:setTemplate', (template) => {
if (typeof template !== 'string' || !template.trim()) throw new Error('模板不能为空')
groupExitMonitorState = {
...groupExitMonitorState,
notificationTemplate: template.trim()
}
return cloneGroupExitMonitorState()
})
handle('group-exit-monitor:checkNow', () => {
groupExitMonitorState = {
...groupExitMonitorState,
@@ -358,6 +342,133 @@ handle('group-exit-monitor:markRead', (readAt) => {
return cloneGroupExitMonitorState()
})
// ---- Automation v1(@我生成日报)----
//
// 这组桩只服务 UI / 视觉评审:让自动化首页拿到一份确定性的规则与群列表,
// 而不是整页挂着「自动化接口尚未就绪」。
// 它**不**参与任何真实发送,也**不**代表自动化功能已迁移。
const automationGroupOptions = () =>
contacts
.filter(
(contact) =>
contact.type === 'group' || String(contact.m_nsUsrName || '').endsWith('@chatroom')
)
.map((contact) => ({
id: contact.m_nsUsrName,
name: contact.m_nsNickName || contact.m_nsUsrName
}))
const builtinDailyReportRule = () => ({
id: 'builtin-mention-me-daily-report',
name: '@我生成日报',
enabled: true,
ruleType: 'daily_report',
trigger: 'message',
scope: 'group',
conditions: {
requireMentionMe: true,
keyword: '日报',
keywordMatchMode: 'contains',
conversationIds: [],
ignoreSelf: true
},
actions: [
{ type: 'replyText', enabled: true, text: '收到,正在生成今日日报' },
{ type: 'generateReport', enabled: true },
{ type: 'sendReportImage', enabled: true }
],
cooldownSeconds: 60,
replyDelaySeconds: 2,
createdAt: fixtureNowMs,
updatedAt: fixtureNowMs
})
/*
* ⚠️ 这里的默认值必须与 `src/shared/automation.ts` 的
* `createDefaultLeaveNotificationRule()` **逐字一致**。
*
* 它扮演"一个全新安装的 main 进程":一旦漂移,E2E 与截图验证的就不是真实默认行为。
* 由 `tests/integration/automation-default-contract.test.ts` 锁住。
*/
let leaveNotificationRule = {
id: 'builtin-leave-notification',
name: '退群通知',
enabled: true,
ruleType: 'leave_notification',
trigger: 'message',
scope: 'group',
conditions: {
requireMentionMe: false,
keyword: '',
keywordMatchMode: 'contains',
conversationIds: [],
ignoreSelf: true
},
actions: [],
cooldownSeconds: 0,
replyDelaySeconds: 2,
leaveNotification: {
// 默认目标 = 当前群聊(旧退群监控通知的原始行为)。
target: { type: 'source_chat' },
template:
'[退群监测]\n\n群聊: {groupName}\n\n用户: {user}\n\n群备注: {groupRemark}\n\n微信号: {wxid}\n\n人数: {previousCount} -> {currentCount}\n\n退群时间: {time}'
},
createdAt: fixtureNowMs,
updatedAt: fixtureNowMs
}
/** 「指定好友」候选:个人联系人,排除群 / 公众号 / 文件传输助手 / 自己。 */
const sendableContactOptions = () =>
contacts
.filter(
(contact) =>
contact.type !== 'group' &&
!String(contact.m_nsUsrName || '').endsWith('@chatroom') &&
!String(contact.m_nsUsrName || '').startsWith('gh_') &&
contact.m_nsUsrName !== 'filehelper'
)
.map((contact) => ({
id: contact.m_nsUsrName,
name: contact.m_nsNickName || contact.m_nsUsrName
}))
handle('automation:getStatus', () => ({
listening: connected,
listeningDegraded: false,
todayExecutions: connected ? 3 : 0,
todaySuccesses: connected ? 3 : 0,
sendCapability: {
supported: personalWechatSupported,
ready: connected,
canSendText: connected,
canSendImage: connected,
message: connected ? '个人微信发送能力已就绪' : '尚未绑定个人微信发送能力'
}
}))
handle('automation:listRules', () => [
builtinDailyReportRule(),
structuredClone(leaveNotificationRule)
])
handle('automation:listGroups', () => automationGroupOptions())
handle('automation:listExecutions', () => [])
handle('automation:clearExecutions', () => true)
handle('automation:listSendableContacts', () => sendableContactOptions())
handle('automation:saveLeaveNotificationRule', (draft) => {
leaveNotificationRule = {
...leaveNotificationRule,
...(draft && typeof draft === 'object' ? draft : {}),
id: 'builtin-leave-notification',
ruleType: 'leave_notification',
updatedAt: fixtureNowMs
}
if (leaveNotificationRule.leaveNotification) {
const { targetNeedsReview, ...rest } = leaveNotificationRule.leaveNotification
void targetNeedsReview
leaveNotificationRule.leaveNotification = rest
}
return structuredClone(leaveNotificationRule)
})
const scheduledReportTasks = []
const scheduledReportExecutions = []
const generatedReports = []
@@ -0,0 +1,74 @@
import { readFileSync } from 'fs-extra'
import { join } from 'path'
import { describe, expect, it } from 'vitest'
import { createDefaultLeaveNotificationRule } from '../../src/shared/automation'
import { GROUP_EXIT_NOTIFICATION_TEMPLATE } from '../../src/shared/group-exit-monitor'
/**
* E2E main 替身(`tests/e2e/support/electron-main.cjs`)的**默认值契约**。
*
* 替身扮演"一个全新安装的 main 进程"。它里面的默认值必须与真实默认值逐字一致,
* 否则 E2E 与截图验证的就是一个**不存在的世界**。
*
* 这个契约是被真实事故逼出来的:迁移时把默认目标从"文件传输助手"改成"当前群聊"、
* 并往默认模板里加了「群聊: {groupName}」一行,但替身没跟着改 ——
* 于是截图里看到的是"当前群聊没被选中、模板也没有群聊行",
* 而组件测试却全绿(组件测试用的是内存 mock,不是替身)。
*/
const DOUBLE_PATH = join(process.cwd(), 'tests/e2e/support/electron-main.cjs')
/**
* 取出替身里整条退群通知规则(该声明在文件里只出现一次)。
*
* 起点取 `let leaveNotificationRule = {` 而不是 `leaveNotification:` ——
* `enabled` / `ruleType` / `cooldownSeconds` 都在嵌套的 leaveNotification 之前。
*/
function leaveNotificationBlock(): string {
const source = readFileSync(DOUBLE_PATH, 'utf8')
const start = source.indexOf('let leaveNotificationRule = {')
expect(start, '替身里找不到 leaveNotificationRule').toBeGreaterThan(-1)
return source.slice(start)
}
describe('E2E main 替身的默认值契约', () => {
const defaults = createDefaultLeaveNotificationRule(0)
it('退群通知的默认目标与真实默认值一致', () => {
const block = leaveNotificationBlock()
const match = block.match(/target:\s*\{\s*type:\s*'([a-z_]+)'/)
expect(match?.[1]).toBe(defaults.leaveNotification?.target.type)
})
it('退群通知的默认模板与真实默认值逐字一致', () => {
const block = leaveNotificationBlock()
const match = block.match(/template:\s*'((?:[^'\\]|\\.)*)'/)
expect(match, '替身里找不到 template 字面量').not.toBeNull()
// 替身里是单引号 + \n 转义的 JS 字面量,反转义后与 shared 常量比对。
const template = (match as RegExpMatchArray)[1].replace(/\\n/g, '\n')
expect(template).toBe(defaults.leaveNotification?.template)
expect(template).toBe(GROUP_EXIT_NOTIFICATION_TEMPLATE)
})
it('替身里必须存在群名占位符(否则截图看不出群名变量)', () => {
expect(leaveNotificationBlock()).toContain('群聊: {groupName}')
})
it('起停/冷却语义与真实默认值一致', () => {
const block = leaveNotificationBlock()
expect(block).toMatch(/enabled:\s*true/)
expect(block).toMatch(/ruleType:\s*'leave_notification'/)
// 退群通知不套用消息型规则的冷却,否则两次退群会被并成一次。
expect(block).toMatch(/cooldownSeconds:\s*0/)
})
it('替身里的日报规则类型与真实分派一致', () => {
const source = readFileSync(DOUBLE_PATH, 'utf8')
expect(source).toContain("ruleType: 'daily_report'")
})
})
+18 -8
View File
@@ -73,14 +73,6 @@ describe('preload IPC contract', () => {
expect(invoke).toHaveBeenLastCalledWith('group-exit-monitor:setEnabled', false)
await api.setGroupExitMonitorGroups(['room@chatroom'])
expect(invoke).toHaveBeenLastCalledWith('group-exit-monitor:setGroups', ['room@chatroom'])
await api.setGroupExitMonitorGroups(['room@chatroom'], ['room@chatroom'])
expect(invoke).toHaveBeenLastCalledWith(
'group-exit-monitor:setGroups',
['room@chatroom'],
['room@chatroom']
)
await api.setGroupExitMonitorNotificationTemplate('用户: {user}')
expect(invoke).toHaveBeenLastCalledWith('group-exit-monitor:setTemplate', '用户: {user}')
await api.checkGroupExitMonitorNow()
expect(invoke).toHaveBeenLastCalledWith('group-exit-monitor:checkNow')
await api.clearGroupExitMonitorEvents()
@@ -90,6 +82,24 @@ describe('preload IPC contract', () => {
await api.listWechatActionLogs()
expect(invoke).toHaveBeenLastCalledWith('wechat-action-log:list')
// 退群通知:保存走 singleton upsert,联系人候选由 main 侧过滤。
const leaveDraft = {
name: '退群通知',
enabled: true,
ruleType: 'leave_notification',
trigger: 'message',
scope: 'group',
conditions: {},
actions: [],
cooldownSeconds: 0,
replyDelaySeconds: 2,
leaveNotification: { target: { type: 'file_transfer' }, template: '[退群监测]' }
} as never
await api.saveLeaveNotificationRule(leaveDraft)
expect(invoke).toHaveBeenLastCalledWith('automation:saveLeaveNotificationRule', leaveDraft)
await api.listSendableContacts()
expect(invoke).toHaveBeenLastCalledWith('automation:listSendableContacts')
await api.getImage('fixture-md5', 'fixture.dat', 'fixture-session', {
force: true,
priority: 0
+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'
})