diff --git a/src/main/index.ts b/src/main/index.ts index ee92840..aee0e7e 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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') diff --git a/src/main/services/automation-action-runner.ts b/src/main/services/automation-action-runner.ts index 524a8a4..eb9cc08 100644 --- a/src/main/services/automation-action-runner.ts +++ b/src/main/services/automation-action-runner.ts @@ -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 executeAction?: (request: WechatActionRequest) => Promise @@ -235,6 +273,93 @@ export class AutomationActionRunner { return { steps, status: 'success', pngPath } } + /** + * 退群通知:把「检测到成员退出」这件事跑成一次发送。 + * + * 与 `run()` 的差别:没有动作链、没有回复等待、没有 cooldown + * (退群是低频事件,且**不能被时间窗合并** —— 两个成员先后退出就是两条通知)。 + */ + async runLeaveNotification(input: LeaveNotificationRunInput): Promise { + 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) } + } + } + /** * 统一发送出口。 * diff --git a/src/main/services/automation-rule-store.ts b/src/main/services/automation-rule-store.ts index 58180c3..226fa3f 100644 --- a/src/main/services/automation-rule-store.ts +++ b/src/main/services/automation-rule-store.ts @@ -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 + 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 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 + 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() diff --git a/src/main/services/automation-service.ts b/src/main/services/automation-service.ts index 9f45bb5..b2e9ac2 100644 --- a/src/main/services/automation-service.ts +++ b/src/main/services/automation-service.ts @@ -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() + /** + * 退群事件幂等表:`ruleId:eventId` → 登记时间。 + * + * 与消息型 `claims` 分开:退群事件没有 `localId` 这个概念, + * 硬塞进同一张表会让两边的 key 语义混在一起。 + */ + private readonly exitClaims = new Map() /** * 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 { + 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> + 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 = [] + 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 { + 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')) diff --git a/src/main/services/group-exit-monitor-service.ts b/src/main/services/group-exit-monitor-service.ts index aa156ee..26f5780 100644 --- a/src/main/services/group-exit-monitor-service.ts +++ b/src/main/services/group-exit-monitor-service.ts @@ -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[] + /* + * 历史遗留字段(`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 + export interface GroupExitMonitorServiceDependencies { - actionGateway?: GroupExitActionGateway + onGroupExit?: GroupExitEventHandler } -type GroupExitActionGateway = Pick & - Partial< - Pick - > - 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() - private notificationRoomIds = new Set() private loaded = false private accountRoot = '' private groupNamesByRoomId = new Map() @@ -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 { + /** + * 保存**监控范围**。 + * + * 参数只剩监控目标 —— 旧版第二个参数(通知群聊)已经迁到自动化规则里, + * 本服务不再持有任何通知配置。 + */ + async setMonitoredRoomIds(roomIds: string[]): Promise { 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 { - 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 { - 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 { - 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 - 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 diff --git a/src/main/services/leave-notification-migration.ts b/src/main/services/leave-notification-migration.ts new file mode 100644 index 0000000..d5191d6 --- /dev/null +++ b/src/main/services/leave-notification-migration.ts @@ -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() + 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 + } +} diff --git a/src/main/services/leave-notification-target.ts b/src/main/services/leave-notification-target.ts new file mode 100644 index 0000000..ba09ad0 --- /dev/null +++ b/src/main/services/leave-notification-target.ts @@ -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( + 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 + } + } + } + } +} diff --git a/src/main/services/wechat-action-gateway.ts b/src/main/services/wechat-action-gateway.ts index a29608f..29d7208 100644 --- a/src/main/services/wechat-action-gateway.ts +++ b/src/main/services/wechat-action-gateway.ts @@ -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 send?: (request: PersonalWechatSendRequest) => Promise - getMemberEvent?: ( - sourceId: string - ) => - | WechatActionMemberEventReference - | Promise - | undefined getUserDataPath?: () => string now?: () => Date wait?: (milliseconds: number) => Promise @@ -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() private readonly inFlight = new Map>() private auditState: LoadedAuditState | null = null private automationSendTail: Promise = 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 { - 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 { 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' } } diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 118150e..4dd0aa6 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -304,14 +304,10 @@ declare global { limit?: number }) => Promise setGroupExitMonitorEnabled: (enabled: boolean) => Promise - setGroupExitMonitorGroups: ( - roomIds: string[], - notificationRoomIds?: string[] - ) => Promise - setGroupExitMonitorNotificationTemplate: (template: string) => Promise + /** 保存**监控范围**。通知配置已迁到「自动化 → 退群通知」。 */ + setGroupExitMonitorGroups: (roomIds: string[]) => Promise checkGroupExitMonitorNow: () => Promise clearGroupExitMonitorEvents: () => Promise - resendGroupExitMonitorEvent: (eventId: string) => Promise markGroupExitMonitorRead: (readAt?: number) => Promise listWechatActionLogs: () => Promise getAutomationStatus: () => Promise @@ -326,6 +322,10 @@ declare global { listAutomationExecutions: (query?: { limit?: number }) => Promise clearAutomationExecutions: () => Promise listAutomationGroups: () => Promise> + /** 保存「退群通知」规则(singleton upsert)。 */ + saveLeaveNotificationRule: (draft: AutomationRuleDraft) => Promise + /** 「指定好友」的可选项;已在 main 侧过滤掉群聊 / 公众号 / 文件传输助手 / 自己。 */ + listSendableContacts: () => Promise> onGroupExitMonitorState: (callback: (state: GroupExitMonitorState) => void) => () => void search: (keyword: string) => Promise searchKnowledge: (request: KnowledgeSearchIpcRequest) => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index 6af72c7..39e4842 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -182,21 +182,13 @@ const api = { ipcRenderer.invoke('group-exit-monitor:listEvents', query), setGroupExitMonitorEnabled: (enabled: boolean): Promise => ipcRenderer.invoke('group-exit-monitor:setEnabled', enabled), - setGroupExitMonitorGroups: ( - roomIds: string[], - notificationRoomIds?: string[] - ): Promise => - notificationRoomIds === undefined - ? ipcRenderer.invoke('group-exit-monitor:setGroups', roomIds) - : ipcRenderer.invoke('group-exit-monitor:setGroups', roomIds, notificationRoomIds), - setGroupExitMonitorNotificationTemplate: (template: string): Promise => - ipcRenderer.invoke('group-exit-monitor:setTemplate', template), + /** 保存**监控范围**。通知配置已迁到自动化规则,这里不再有第二个参数。 */ + setGroupExitMonitorGroups: (roomIds: string[]): Promise => + ipcRenderer.invoke('group-exit-monitor:setGroups', roomIds), checkGroupExitMonitorNow: (): Promise => ipcRenderer.invoke('group-exit-monitor:checkNow'), clearGroupExitMonitorEvents: (): Promise => ipcRenderer.invoke('group-exit-monitor:clearEvents'), - resendGroupExitMonitorEvent: (eventId: string): Promise => - ipcRenderer.invoke('group-exit-monitor:resendEvent', eventId), markGroupExitMonitorRead: (readAt?: number): Promise => ipcRenderer.invoke('group-exit-monitor:markRead', readAt), listWechatActionLogs: (): Promise => @@ -220,6 +212,16 @@ const api = { /** 「在哪些聊天生效」的可选项。`id` 为 `xxx@chatroom`,与规则内 conversationIds 同口径。 */ listAutomationGroups: (): Promise> => ipcRenderer.invoke('automation:listGroups'), + /** + * 保存「退群通知」规则(singleton upsert,id 由 main 侧固定,渲染层不拼 id)。 + */ + saveLeaveNotificationRule: (draft: AutomationRuleDraft): Promise => + ipcRenderer.invoke('automation:saveLeaveNotificationRule', draft), + /** + * 「指定好友」的可选项。已在 main 侧过滤掉群聊 / 公众号 / 文件传输助手 / 自己。 + */ + listSendableContacts: (): Promise> => + ipcRenderer.invoke('automation:listSendableContacts'), onGroupExitMonitorState: (callback: (state: GroupExitMonitorState) => void) => { const listener = (_event: Electron.IpcRendererEvent, state: GroupExitMonitorState): void => callback(state) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 40e5f8f..f789f58 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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() const connectionOperationRef = React.useRef(0) const [activePage, setActivePage] = useState('archive') + /** + * 「退群监控 ⇄ 自动化」之间的跨页深链。 + * + * 项目没有 react-router,一级菜单就是 `activePage` 这一份 state, + * 所以深链同样是 state —— 用一个单调递增的 requestId 表达"又点了一次", + * 子页面据此重新落位(同一个对象引用不会重复触发 effect)。 + */ + const [automationOpenRuleRequest, setAutomationOpenRuleRequest] = + React.useState(null) + const [exitMonitorOpenViewRequest, setExitMonitorOpenViewRequest] = + React.useState(null) const [archiveJumpTime, setArchiveJumpTime] = useState(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 { ) case 'agent-hub': diff --git a/src/renderer/src/features/automation/AutomationRuleTypeTabs.tsx b/src/renderer/src/features/automation/AutomationRuleTypeTabs.tsx new file mode 100644 index 0000000..7b0635c --- /dev/null +++ b/src/renderer/src/features/automation/AutomationRuleTypeTabs.tsx @@ -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 ( +
+ 切换规则类型 + onValueChange(next as AutomationRuleType)} + aria-label="切换规则类型" + > + {RULE_TYPES.map((type) => ( + + {AUTOMATION_RULE_TYPE_LABELS[type]} + + ))} + +
+ ) +} diff --git a/src/renderer/src/features/automation/AutomationWorkspace.tsx b/src/renderer/src/features/automation/AutomationWorkspace.tsx index d94b2f3..9acf834 100644 --- a/src/renderer/src/features/automation/AutomationWorkspace.tsx +++ b/src/renderer/src/features/automation/AutomationWorkspace.tsx @@ -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([]) const [groups, setGroups] = React.useState([]) const [executions, setExecutions] = React.useState([]) + const [sendableContacts, setSendableContacts] = React.useState([]) + 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(null) const reload = React.useCallback(async (): Promise => { - 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(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 => { setBusyRuleId(rule.id) const updated = await automationApi.setRuleEnabled(rule.id, enabled) @@ -103,11 +197,32 @@ export function AutomationWorkspace({ const handleSave = async (draft: AutomationRuleDraft): Promise => { 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 => { + // 静默 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 => { if (!pendingDelete) return const target = pendingDelete @@ -166,7 +320,14 @@ export function AutomationWorkspace({

当群里出现符合条件的消息时,TraceMemo 会自动执行你配置的动作。

{tab === 'rules' && !showEditor ? ( - + ) : null} @@ -223,24 +384,154 @@ export function AutomationWorkspace({ ) : null} {showEditor && editor ? ( - setEditor(null)} - onSave={(draft) => void handleSave(draft)} - /> +
+ {/* + 切类型 = **换一条规则来编辑**,不是"把这条规则改成另一类"。 + + 以前这里只改 `ruleType`,把上个类型的 `mode`/`rule` 一起带过来, + 于是"从退群通知切到日报"会留下 `mode='edit' + rule=null` 的中间态, + 保存时再隐式回落到内置日报 —— 用户以为在改退群通知,实际改掉了日报。 + 现在切换时**显式重算编辑目标**,并把当前目标名显示出来。 + */} + { + 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 显示一个、保存改另一个。 + */} +

+ 正在编辑:{editorTargetName} +

+ + {editor.ruleType === 'daily_report' ? ( + /* + * 编辑态却没有目标(规则被删/列表刷新后消失)—— 不能让用户对着一个 + * 标题写着「编辑自动化」的空表单填半天,最后保存时报错。 + */ + editor.mode === 'edit' && !dailyReportTarget ? ( +
+

要编辑的规则已不存在(可能已被删除)。

+ +
+ ) : ( + setEditor(null)} + onSave={(draft) => void handleSave(draft)} + /> + ) + ) : leaveNotificationRule ? ( + setEditor(null)} + onSave={(input) => void handleLeaveNotificationSave(input)} + /> + ) : loading ? ( + /* + * **正在读,不是"没有"。** + * + * 从「退群监控 → 退群通知自动化」深链进来时,编辑器是同步打开的, + * 而规则列表还在 IPC 回来的路上 —— 这一帧必然还没有规则。 + * 旧写法在这里直接渲染「尚未就绪 / 版本不匹配」,把最正常的一种 + * 加载状态说成了故障,必须区分开。 + */ +
+ + 正在读取退群通知规则… +
+ ) : ( + /* + * 读完了**确实没有**这条规则。 + * + * `builtin-leave-notification` 由主进程 `AutomationRuleStore` 首次加载时自动播种, + * 所以走到这里通常意味着:跑着的主进程还是迁移前的旧构建 + * (渲染层已经升级,`out/main` 没有)。如实说明并给一个重试入口。 + */ +
+

没有找到内置的退群通知规则。

+ + 这条规则应由主进程在首次启动时自动创建。如果你刚更新过版本, + 请重启 TraceMemo —— 已经跑起来的主进程不会自动换成新代码。 + + +
+ )} +
) : tab === 'rules' ? ( 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' }) + } /> ) : ( )} + {step.detail ? ( +

{step.detail}

+ ) : null} {step.error ?

{step.error}

: null} diff --git a/src/renderer/src/features/automation/LeaveNotificationEditor.tsx b/src/renderer/src/features/automation/LeaveNotificationEditor.tsx new file mode 100644 index 0000000..8251b7f --- /dev/null +++ b/src/renderer/src/features/automation/LeaveNotificationEditor.tsx @@ -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(() => 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 ( +
+
+
+

退群通知

+

当已监控群聊检测到成员退出时,自动发送通知。

+
+ 启用这条自动化 + +
+
+
+ + +
+
+ + {config.targetNeedsReview ? ( +

+ 旧版「通知群聊」是逐群配置的,无法无损转换成新的单一通知目标。已保留你的通知模板, + 但在你重新选择通知目标之前不会发送任何通知。 +

+ ) : null} + {contactMissing ? ( +

+ 之前选择的联系人已不存在或当前无法发送,请重新选择通知目标。 +

+ ) : null} + {sendCapabilityWarning ? ( +

{sendCapabilityWarning}

+ ) : null} + +
+
+
+
+

1 · 什么时候触发

+
+ +
+
+ 触发事件 + 检测到群成员退出 + 这是「退群监控」提供的事件,由本规则响应。 +
+
+ +
+
+ 监控来源 + 退群监控 +
+
+ + {/* + 只读摘要 + 唯一入口。 + 「哪些群被监控」完全由退群监控决定,本规则不做二次筛选 —— + 所以这里不给任何勾选控件,只把当前范围如实说出来。 + */} +
+
+ 已监控群聊 + {monitoredCount} 个群聊 + 监控范围由「退群监控」管理 +
+ +
+
+ +
+
+

2 · 通知发送到哪里

+
+ + selectTarget(value as LeaveNotificationTargetType)} + aria-label="通知发送到哪里" + className="automation-leave-targets" + > + {LEAVE_NOTIFICATION_TARGET_OPTIONS.map((option) => ( +
+ +
+ + {option.description} +
+
+ ))} +
+ + {targetType === 'contact' ? ( +
+ 选择好友 + + setConfig((current) => ({ + ...current, + target: { type: 'contact', contactId: id }, + targetNeedsReview: undefined + })) + } + ariaLabel="选择好友" + searchPlaceholder="搜索好友" + emptyText="没有可发送的联系人" + /> +
+ ) : null} +
+ +
+
+

3 · 通知内容

+
+ + {/* + 护栏:通知发给「别人」时,不带群名的通知等于「张三退群了」。 + 主进程已替用户补过一次(一次性迁移),这里兜的是"用户后来主动删掉了"的情况 —— + 只提示 + 一键补上,不强行改用户文本。 + */} + {missingGroupName ? ( +
+

这份内容里没有群聊名,收件人看不出是哪个群退的人。

+ +
+ ) : null} + + + setConfig((current) => ({ ...current, template: next })) + } + /> +
+
+ + +
+
+ ) +} diff --git a/src/renderer/src/features/automation/LeaveNotificationPreview.tsx b/src/renderer/src/features/automation/LeaveNotificationPreview.tsx new file mode 100644 index 0000000..38b40a2 --- /dev/null +++ b/src/renderer/src/features/automation/LeaveNotificationPreview.tsx @@ -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 ( +
+
+

效果预览

+ 仅预览,不会真实发送 +
+ +
+
+ {chatTitle} +
+
+ {/* 退群监控产生的系统事件,不来自任何消息文本 */} +

检测到成员退出

+ +
+
+ 我 + {bubbleText} +
+ +
+
+
+ +
+
+
触发事件
+
成员退出
+
+
+
监控来源
+
{describeMonitoredScope(monitoredCount)}
+
+
+
通知目标
+
{targetSummary}
+
+
+
+ ) +} diff --git a/src/renderer/src/features/automation/LeaveNotificationTargetPicker.tsx b/src/renderer/src/features/automation/LeaveNotificationTargetPicker.tsx new file mode 100644 index 0000000..b3e77b0 --- /dev/null +++ b/src/renderer/src/features/automation/LeaveNotificationTargetPicker.tsx @@ -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 ( + + setFilter(event.target.value)} + placeholder={searchPlaceholder} + aria-label={searchPlaceholder} + /> +
+ {visibleContacts.length === 0 ? ( +

{emptyText}

+ ) : ( + visibleContacts.map((contact) => ( +
+ + +
+ )) + )} +
+
+ ) +} diff --git a/src/renderer/src/features/automation/LeaveNotificationTemplateEditor.tsx b/src/renderer/src/features/automation/LeaveNotificationTemplateEditor.tsx new file mode 100644 index 0000000..a1ba627 --- /dev/null +++ b/src/renderer/src/features/automation/LeaveNotificationTemplateEditor.tsx @@ -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(null) + + const insertVariable = (variable: string): void => { + const element = textareaRef.current + onChange( + insertTemplateVariable( + template, + variable, + element?.selectionStart ?? null, + element?.selectionEnd ?? null + ) + ) + } + + return ( +
+