import { createHash, randomUUID } from 'node:crypto' import { matchAutomationRule, type AutomationMatchInput } from '../../shared/automation-matcher' 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 { getSelfAccountInfo, listContacts } from './chat-service' import { getPersonalWechatSendCapability } from './personal-wechat-capability-service' import { resolveLeaveNotificationTarget, type LeaveNotificationTargetResolution } from './leave-notification-target' import { automationRuleStore, type AutomationRuleStore } from './automation-rule-store' import { automationExecutionLogService, type AutomationExecutionLogService } from './automation-execution-log-service' import { automationActionRunner, type AutomationActionRunner } from './automation-action-runner' /** * AutomationService —— Automation v1 的编排层。 * * ```text * MessageListener * ↓ NormalizedIncomingMessage * AutomationService.handleMessage * ↓ TriggerMatcher(shared,纯函数;规则编辑页的预览共用同一份) * ↓ cooldown → 同消息幂等 → 发送能力预检 * AutomationActionRunner * ↓ * Execution Log * ``` * * 刻意不引入 EventBus / RxJS / MQ / 工作流引擎 —— 这条链路的每一步都是同步可读的。 * * **防循环 / 防刷屏四道闸**(缺一不可): * 1. `isSelf` 防护:自己发的消息不触发(TriggerMatcher 内); * 2. MessageListener dedup:同一条消息不会重复投递; * 3. cooldown:同一规则 + 同一会话的最小间隔; * 4. 同消息幂等:`ruleId:sessionId:localId` 只执行一次。 * * 第 1 条是关键:TraceMemo 回复的那句「收到,正在生成今日日报」本身含关键词「日报」, * 少了它就会自己触发自己,形成死循环。 */ /** * gate 表的容量上限。超了才清理「已解锁」的条目 —— 不是为了省内存, * 而是防止长期运行下 ruleId × conversationId 无界增长。 */ const GATE_MAX_ENTRIES = 5_000 /** * 一条规则在一个会话里的 gate 状态。 * * ``` * blocked = inFlight || now < triggeredAt + cooldown * ``` */ interface ConversationRuleGate { /** **第一次**触发的时间(cooldown 起点)。执行完成时**不重置**。 */ triggeredAt: number /** 当前是否有这条规则在这个会话里的执行还在跑。 */ inFlight: boolean /** 本次执行期间被 gate 挡下的消息数(仅诊断日志用)。 */ blockedSinceTrigger: number } /** 幂等登记表的存活时间与容量上限(与 MessageListener 的 dedup 同思路)。 */ const CLAIM_TTL_MS = 10 * 60 * 1000 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 runner?: AutomationActionRunner getCapability?: () => Promise /** 由主进程注入:MessageListener 是否在运行。 */ isListening?: () => boolean now?: () => number /** gate 表容量上限。默认 `GATE_MAX_ENTRIES`;单测用小值来触发清理路径。 */ gateMaxEntries?: number } interface ClaimEntry { at: number } export class AutomationService { private readonly ruleStore: AutomationRuleStore private readonly executionLog: AutomationExecutionLogService private readonly runner: AutomationActionRunner private readonly getCapability: () => Promise private readonly isListening: () => boolean private readonly now: () => number /** `ruleId:sessionId:localId` → 登记时间。 */ private readonly claims = new Map() /** * 退群事件幂等表:`ruleId:eventId` → 登记时间。 * * 与消息型 `claims` 分开:退群事件没有 `localId` 这个概念, * 硬塞进同一张表会让两边的 key 语义混在一起。 */ private readonly exitClaims = new Map() /** * Automation rule ↔ conversation gate。 * * 粒度是 **`ruleId + conversationId`**(不是 sender,也不是整个会话): * 群 A 的「@我生成日报」被触发后,群 B 的同一条规则、或同群里的**另一条规则**都不受影响。 */ private readonly gates = new Map() /** 仅用于诊断统计(`[Automation] blockedMessages=N`),**不进用户执行日志**。 */ private blockedMessageCount = 0 private readonly gateMaxEntries: number private selfUsernames: string[] = [] private selfUsernamesAt = 0 constructor( private readonly client: Wcdb4Client, dependencies: AutomationServiceDependencies = {} ) { this.ruleStore = dependencies.ruleStore ?? automationRuleStore this.executionLog = dependencies.executionLog ?? automationExecutionLogService this.runner = dependencies.runner ?? automationActionRunner this.getCapability = dependencies.getCapability ?? getPersonalWechatSendCapability this.isListening = dependencies.isListening ?? (() => true) this.now = dependencies.now ?? (() => Date.now()) this.gateMaxEntries = dependencies.gateMaxEntries ?? GATE_MAX_ENTRIES } /** * 消息入口。**绝不抛** —— 它挂在 MessageListener 的回调上, * 抛出去会影响后续监听者。 */ async handleMessage(message: NormalizedIncomingMessage): Promise { let rules: AutomationRule[] try { rules = this.ruleStore.listRules() } catch (error) { this.warn(`读取规则失败: ${errorText(error)}`) return } if (!rules.length) return const selfUsernames = this.resolveSelfUsernames() const input: AutomationMatchInput = { ...(message.content !== undefined ? { content: message.content } : {}), mentionTargets: message.mentionTargets, isSelf: message.isSelf, isGroup: message.isGroup, // conversationId 与规则里的 conversationIds 同口径:群为 `xxx@chatroom`。 conversationId: message.sessionId } for (const rule of rules) { // ① Gate 检查放在 TriggerMatcher **之前**(纯读,无副作用)。 // // Automation rule conversation gate: // Once a rule is triggered for a conversation, ignore subsequent messages for // this rule while the current execution is still running OR until the configured // cooldown has elapsed from the original trigger time. // // Messages arriving during this blocked window must not be matched, replied to, // executed, or written to the user-facing execution log. // // The rule becomes eligible again only after BOTH: // 1. the previous execution has finished; and // 2. the cooldown since the original trigger has expired. // // Important: cooldown starts at the original trigger time, not when execution finishes. // // 也就是说:第一个任务执行期间,以及配置的触发间隔内,后续消息全部静默忽略。 if (this.isGated(rule, message.sessionId)) { this.blockedMessageCount += 1 this.noteBlocked(rule, message.sessionId) continue } // ② 匹配(同步)。放在 gate 之后 —— 阻塞窗口内的消息不该被匹配。 let matched: boolean try { matched = matchAutomationRule(rule, input, selfUsernames).matched } catch (error) { this.warn(`规则匹配异常 ruleId=${rule.id}: ${errorText(error)}`) continue } if (!matched) continue // ③ Gate claim:check → 写入。**中间不能有 await**,否则两条几乎同时到达的消息 // 会一起穿过检查。上面 ① 到这里的唯一代码是同步的 matcher,所以这里仍是原子的。 if (!this.claimGate(rule, message.sessionId)) { this.blockedMessageCount += 1 this.noteBlocked(rule, message.sessionId) continue } // ④ 同一条消息只处理一次(native 事件重复 / 多窗口回读都可能重复投递)。 if (!this.claim(rule, message)) { this.leaveGate(rule, message.sessionId) continue } const sourceDisplayName = this.resolveDisplayName(message) try { await this.execute(rule, message, sourceDisplayName) } finally { // 无论 success / failed / 抛异常都要解除 in-flight。 // 漏掉这一步会让这条规则在这个会话里**永久锁死**。 this.leaveGate(rule, message.sessionId) } } } /** 顶部状态条数据。全部来自真实能力,不做渲染层平台猜测。 */ async getStatus(): Promise { const now = this.now() const startOfToday = new Date(now) startOfToday.setHours(0, 0, 0, 0) const counts = this.executionLog.countSince(startOfToday.getTime()) let capability: PersonalWechatSendCapability | null = null try { capability = await this.getCapability() } catch { capability = null } return { listening: this.isListening(), // 底层只能回读「最近活跃会话」(见 message-listener-service.ts 的 BLOCKER 注释), // UI 必须如实告知,不允许把它包装成「全局监听」。 listeningDegraded: true, todayExecutions: counts.total, todaySuccesses: counts.success, sendCapability: { supported: Boolean(capability?.supported), ready: Boolean(capability?.ready), canSendText: Boolean(capability?.capabilities?.text), canSendImage: Boolean(capability?.capabilities?.image), message: capability?.message ?? '暂时无法获知微信发送能力' } } } /** * 退群事件入口。**绝不抛** —— 它挂在 `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')) .map((contact) => ({ id: String(contact.m_nsUsrName || ''), name: String(contact.m_nsNickName || contact.m_nsUsrName || '') })) .filter((group) => group.id) .sort((a, b) => a.name.localeCompare(b.name, 'zh-Hans-CN')) } catch (error) { this.warn(`读取群列表失败: ${errorText(error)}`) return [] } } // --------------------------------------------------------------------------- // 内部 // --------------------------------------------------------------------------- private async execute( rule: AutomationRule, message: NormalizedIncomingMessage, sourceDisplayName: string ): Promise { const executionId = randomUUID() const startedAt = this.now() const isGroup = message.isGroup // 发送能力预检:环境发不出去时**不要执行**,直接留下一条说明清楚的失败记录。 // 「假装规则正常运行」比直接报错更糟 —— 用户会以为发出去了。 const missing = await this.checkSendCapability(rule, isGroup) if (missing) { this.executionLog.record( this.buildExecution({ executionId, rule, startedAt, sourceDisplayName, status: 'failed', durationMs: this.now() - startedAt, steps: [], errorSummary: missing }) ) this.warn(`发送能力不足,跳过执行 ruleId=${rule.id} code=capability_missing`) return } // cooldown 起点已经在 `claimGate()` 记过了(= 第一次触发时间)。 // 这里**不再**写任何时间戳 —— 否则「执行真正开始」的时间会把 cooldown 起点往后推。 let result: Awaited> try { result = await this.runner.run({ executionId, rule, conversationId: message.sessionId, isGroup, sourceDisplayName }) } 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、正文、source、文件路径一律不进日志。 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}` : ''}` ) } /** * 检查当前环境是否具备规则所需的发送能力。 * * 返回「缺失的能力」说明;返回 `undefined` 表示可以执行。 * 私聊场景下 `sendReportImage` 同样受限,所以不做特殊豁免。 */ private async checkSendCapability( rule: AutomationRule, isGroup: boolean ): Promise { const needsText = rule.actions.some((action) => action.type === 'replyText' && action.enabled) const needsImage = rule.actions.some( (action) => action.type === 'sendReportImage' && action.enabled ) if (!needsText && !needsImage) return undefined let capability: PersonalWechatSendCapability | null = null try { capability = await this.getCapability() } catch { return '暂时无法获知微信发送能力,已跳过本次执行' } if (!capability?.supported) { return capability?.message || '当前系统不支持微信消息发送' } const target = isGroup ? '群聊' : '联系人' if (needsText && !capability.capabilities?.text) { return `当前环境无法发送文字,已跳过本次执行(无法回复${target})` } if (needsImage && !capability.capabilities?.image) { return '当前环境无法发送图片,已跳过本次执行(无法发送日报)' } return undefined } private buildExecution(input: { executionId: string rule: AutomationRule startedAt: number sourceDisplayName: string status: AutomationExecution['status'] durationMs: number steps: AutomationExecution['steps'] errorSummary?: string }): AutomationExecution { return { executionId: input.executionId, ruleId: input.rule.id, ruleName: input.rule.name, triggerTime: input.startedAt, sourceDisplayName: input.sourceDisplayName, status: input.status, durationMs: input.durationMs, steps: input.steps, ...(input.errorSummary ? { errorSummary: input.errorSummary } : {}) } } private gateKey(rule: AutomationRule, conversationId: string): string { // 粒度:规则 × 会话。**不含 senderId** —— 产品语义是「这个群里这条规则刚被触发过」, // 不是「张三 60 秒内不能再触发」。 return `${rule.id}:${conversationId}` } private cooldownMs(rule: AutomationRule): number { const seconds = Number(rule.cooldownSeconds) if (!Number.isFinite(seconds) || seconds <= 0) return 0 return seconds * 1000 } /** * 是否处于阻塞窗口。**纯读**,所以可以放在 TriggerMatcher 之前。 * * ``` * blocked = inFlight || now < triggeredAt + cooldown * ``` * * `triggeredAt` 是**第一次触发**的时间,不是执行完成时间: * 任务跑得比 cooldown 久时,真正的解锁时间是 * `max(执行完成, triggeredAt + cooldown)`,而不是「完成之后再等一个 cooldown」。 */ private isGated(rule: AutomationRule, conversationId: string): boolean { const gate = this.gates.get(this.gateKey(rule, conversationId)) if (!gate) return false if (gate.inFlight) return true const cooldownMs = this.cooldownMs(rule) if (cooldownMs <= 0) return false // 用「当前时间 vs triggeredAt」现算,**不依赖 setTimeout** —— // 事件循环卡顿 / app suspend / 定时器漂移都不会把冷却算错。 return this.now() < gate.triggeredAt + cooldownMs } /** * 原子地占用 gate(check + 写入)。 * * 返回 `false` 表示这一刻已经被阻塞(调用方按「忽略」处理,不匹配、不执行、不写日志)。 * 调用方必须保证**从 `isGated()` 到这里之间没有 await**。 */ private claimGate(rule: AutomationRule, conversationId: string): boolean { if (this.isGated(rule, conversationId)) return false this.gates.set(this.gateKey(rule, conversationId), { triggeredAt: this.now(), inFlight: true, blockedSinceTrigger: 0 }) this.evictGatesIfNeeded() return true } /** * 解除 in-flight。**保留 `triggeredAt`** —— cooldown 仍以第一次触发时间为起点。 * * 无论 success / failed / 抛异常都必须走这里,否则这条规则在这个会话里会永久锁死。 */ private leaveGate(rule: AutomationRule, conversationId: string): void { const gate = this.gates.get(this.gateKey(rule, conversationId)) if (!gate) return gate.inFlight = false // 本次执行期间被挡下的消息数,汇总成**一行**诊断日志。 // 只允许出现数量与 ruleId(不含群名 / wxid / 昵称 / 正文)。 if (gate.blockedSinceTrigger > 0) { this.info(`blockedMessages=${gate.blockedSinceTrigger} ruleId=${rule.id}`) gate.blockedSinceTrigger = 0 } } /** 记一次「被 gate 拦下的消息」。**只计数**,绝不写入用户执行日志。 */ private noteBlocked(rule: AutomationRule, conversationId: string): void { const gate = this.gates.get(this.gateKey(rule, conversationId)) if (gate) gate.blockedSinceTrigger += 1 } /** 仅供诊断:累计被 gate 拦下的消息数。不接 IPC、不进 UI。 */ getBlockedMessageCount(): number { return this.blockedMessageCount } /** * gate 表有界:**只清理已经解锁的条目**。 * * 硬不变量:**正在执行(`inFlight === true`)的 gate 永不被淘汰** —— * 淘汰它就等于把一条正在跑的规则提前放开,会立刻产生第二次并发执行。 * 所以这里的条件是 `!inFlight && 已过 cooldown`,两个都要满足; * 清理不掉就一直留着(宁可让表大一点,也不能让正在跑的规则失去保护)。 */ private evictGatesIfNeeded(): void { if (this.gates.size <= this.gateMaxEntries) return const now = this.now() const rules = this.ruleStore.listRules() for (const [key, gate] of this.gates) { if (this.gates.size <= this.gateMaxEntries) break if (gate.inFlight) continue const separator = key.lastIndexOf(':') const ruleId = separator < 0 ? key : key.slice(0, separator) const rule = rules.find((item) => item.id === ruleId) const cooldownMs = rule ? this.cooldownMs(rule) : 0 if (now >= gate.triggeredAt + cooldownMs) this.gates.delete(key) } } private claim(rule: AutomationRule, message: NormalizedIncomingMessage): boolean { const key = `${rule.id}:${message.sessionId}:${message.localId}` const now = this.now() if (this.claims.has(key)) return false this.claims.set(key, { at: now }) if (this.claims.size > CLAIM_MAX_ENTRIES) this.evictClaims(now) return true } /** 先按 TTL 清理;仍超限则按插入顺序丢最旧的一批。 */ private evictClaims(now: number): void { for (const [key, entry] of this.claims) { if (now - entry.at > CLAIM_TTL_MS) this.claims.delete(key) } if (this.claims.size <= CLAIM_MAX_ENTRIES) return const overflow = this.claims.size - CLAIM_MAX_ENTRIES let removed = 0 for (const key of this.claims.keys()) { this.claims.delete(key) removed += 1 if (removed >= overflow) break } } private resolveSelfUsernames(): string[] { const now = this.now() if (this.selfUsernames.length && now - this.selfUsernamesAt < SELF_USERNAME_TTL_MS) { return this.selfUsernames } try { this.selfUsernames = (this.client.getMyUsernameCandidates?.() ?? []).filter(Boolean) this.selfUsernamesAt = now } catch (error) { this.warn(`读取自身 username 失败: ${errorText(error)}`) } return this.selfUsernames } /** * 会话显示名。 * * 拿不到会话昵称时**降级成「群聊 / 联系人」**,绝不回落到 wxid —— * 那个值一旦漏进执行日志就等于把隐私写进了用户可见的界面。 */ private resolveDisplayName(message: NormalizedIncomingMessage): string { try { const session = this.client.getSessions().find((item) => item.username === message.sessionId) const nickname = String(session?.nickname || '').trim() if (nickname) return nickname } catch { // 忽略:走下面的通用降级名 } return message.isGroup ? '群聊' : '联系人' } /** 日志统一出口:只允许不可逆的运维信息,禁止任何身份信息。 */ private info(message: string): void { console.log(`[Automation] ${message}`) } private warn(message: string): void { console.warn(`[Automation] ${message}`) } } function errorText(error: unknown): string { return error instanceof Error ? error.message : String(error) } let instance: AutomationService | null = null /** * 主进程启动时调用一次。 * * 用惰性初始化而不是模块级单例,是因为它需要 `Wcdb4Client`, * 而那个实例要等数据库解锁后才存在。 */ export function initAutomationService( client: Wcdb4Client, dependencies: AutomationServiceDependencies = {} ): AutomationService { instance = new AutomationService(client, dependencies) return instance } export function getAutomationService(): AutomationService { if (!instance) throw new Error('AutomationService 尚未初始化') return instance }