import { app } from 'electron' import { createHash, randomUUID } from 'crypto' import fs from 'fs-extra' import path from 'path' import type { PersonalWechatSendCapability, PersonalWechatSendRequest, PersonalWechatSendResult, PersonalWechatSenderStatus } from '../../shared/personal-wechat' import type { PolicyDecision, WechatActionAuditRecord, WechatActionContent, WechatActionErrorCode, WechatActionRequest, WechatActionResult } from '../../shared/wechat-action' import { personalWechatCapabilityService } from './personal-wechat-capability-service' import { wechatSendGateway } from './wechat-send-gateway' const MAX_AUDIT_RECORDS = 500 const MAX_CONTENT_PREVIEW_LENGTH = 240 export const AUTOMATION_SEND_INTERVAL_MS = 3_000 const AUTOMATION_PURPOSE_ALLOWLIST = new Set([ 'scheduled_report', // Automation(@我生成日报)。必须与 `src/shared/automation.ts` 的 // `AUTOMATION_SEND_PURPOSE` 保持一致,否则会被下面 evaluateWechatActionPolicy // 以 ACTION_NOT_ALLOWED 拦下 —— 那是有意的闸门,不是 bug。 'automation_reply', 'automation_report', // 退群通知(由 Automation 发出)。目标可以是群 / 自己 / 文件传输助手 / 指定好友, // 所以**不绑定**任何 "只能发回原群" 的作用域锁。 'automation_leave_notification', // 定时日报(由 Automation 的 `scheduled_report` 规则发出)。目标是四选一, // 同样**不绑定**任何作用域锁。 'automation_scheduled_report', // 定时日报的「后置词」(图片 sent 之后补发的那条文本)。与图片是两个独立 // purpose,各自有幂等位 —— 少这一条会被 evaluateWechatActionPolicy 拦下。 'automation_scheduled_report_postfix', 'manual_report_image', 'manual_report_postfix' ]) export interface ReportImageSequenceRequest { recipient: WechatActionRequest['recipient'] imagePath: string postfixText: string } export interface ReportImageSequenceResult { image: WechatActionResult postfix?: WechatActionResult } export interface WechatActionGatewayDependencies { getCapability?: () => Promise send?: (request: PersonalWechatSendRequest) => Promise getUserDataPath?: () => string now?: () => Date wait?: (milliseconds: number) => Promise } interface LoadedAuditState { path: string records: WechatActionAuditRecord[] } const defaultDependencies = (): Required< Pick< WechatActionGatewayDependencies, 'getCapability' | 'send' | 'getUserDataPath' | 'now' | 'wait' > > => ({ getCapability: () => personalWechatCapabilityService.getPersonalWechatSendCapability(), // 高层业务审计之后仍然统一走 WechatSendGateway,保证每一次真实发送都有 Send Log。 send: (request) => wechatSendGateway.sendPersonal(request), getUserDataPath: () => app.getPath('userData'), now: () => new Date(), wait: (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)) }) /** * 统一管理微信发送操作,在真正发送前完成必要检查; * 具体发送由底层服务处理,使用者只需提供发送内容和对象。 */ export class WechatActionGateway { private readonly deps: Required< Pick< WechatActionGatewayDependencies, 'getCapability' | 'send' | 'getUserDataPath' | 'now' | 'wait' > > & Omit< WechatActionGatewayDependencies, 'getCapability' | 'send' | 'getUserDataPath' | 'now' | 'wait' > private readonly inFlight = new Map>() private auditState: LoadedAuditState | null = null private automationSendTail: Promise = Promise.resolve() private lastAutomationSendStartedAt: number | undefined constructor(deps: WechatActionGatewayDependencies = {}) { this.deps = { ...defaultDependencies(), ...deps } } /** * 用户确认后的日报发送序列。两步都进入 automation 发送队列,从而复用既有 3 秒间隔; * 后置词 action 只在图片明确 sent 后创建,图片失败/blocked 时严格短路。 */ async executeReportImageSequence( request: ReportImageSequenceRequest ): Promise { const image = await this.execute({ origin: 'user_manual', purpose: 'manual_report_image', triggerType: 'automation', recipient: request.recipient, content: { type: 'image', path: String(request.imagePath || '') } }) if (image.status !== 'sent') return { image } const postfixText = String(request.postfixText || '').trim() if (!postfixText) return { image } const postfix = await this.execute({ origin: 'user_manual', purpose: 'manual_report_postfix', triggerType: 'automation', recipient: request.recipient, content: { type: 'text', text: postfixText } }) return { image, postfix } } listAuditRecords(): WechatActionAuditRecord[] { return this.loadAuditState().records.map((record) => ({ ...record })) } async execute(request: WechatActionRequest): Promise { const normalized = normalizeActionRequest(request) const idempotencyKey = normalized ? this.idempotencyKey(normalized) : undefined if (idempotencyKey) { const existing = await this.findExisting(idempotencyKey) if (existing) return existing const running = this.inFlight.get(idempotencyKey) if (running) return running } const promise = this.executeOnce(request, normalized, idempotencyKey) if (idempotencyKey) this.inFlight.set(idempotencyKey, promise) try { return await promise } finally { if (idempotencyKey && this.inFlight.get(idempotencyKey) === promise) { this.inFlight.delete(idempotencyKey) } } } private async executeOnce( originalRequest: WechatActionRequest, request: WechatActionRequest | null, idempotencyKey?: string ): Promise { const startedAt = this.deps.now().toISOString() const actionId = String(originalRequest?.id || '').trim() || randomUUID() if (!request) { return this.finishBlocked( actionId, originalRequest, idempotencyKey, startedAt, validationErrorCode(originalRequest), validationErrorReason(originalRequest) ) } const policy = evaluateWechatActionPolicy(request) if (policy.decision !== 'allow') { return this.finishBlocked( actionId, request, idempotencyKey, startedAt, policy.reasonCode || 'POLICY_BLOCKED', policy.reason || '该发送动作未通过策略检查' ) } const capabilityType = request.content.type let capability: PersonalWechatSendCapability | undefined try { capability = await this.deps.getCapability() } catch (error) { return this.finishFailed( actionId, request, idempotencyKey, startedAt, 'SEND_CAPABILITY_UNAVAILABLE', error instanceof Error ? error.message : String(error) ) } if (!capability || !capability.ready) { return this.finishFailed( actionId, request, idempotencyKey, startedAt, 'SEND_CAPABILITY_UNAVAILABLE', capability?.error || capability?.message || '个人微信发送能力不可用' ) } if (!capability.capabilities?.[capabilityType]) { return this.finishFailed( actionId, request, idempotencyKey, startedAt, 'SEND_NOT_READY', capability.message || `个人微信尚未准备好发送${contentTypeLabel(capabilityType)}` ) } let sendResult: PersonalWechatSendResult try { sendResult = await this.send(request) } catch (error) { return this.finishFailed( actionId, request, idempotencyKey, startedAt, 'SEND_FAILED', error instanceof Error ? error.message : String(error) ) } if (!sendResult.success) { return this.finishFailed( actionId, request, idempotencyKey, startedAt, 'SEND_FAILED', sendResult.error || '微信发送失败', sendResult ) } return this.finishSent(actionId, request, idempotencyKey, startedAt, sendResult) } private send(request: WechatActionRequest): Promise { const sendRequest = toPersonalWechatSendRequest(request) if (request.triggerType !== 'automation') return this.deps.send(sendRequest) const queued = this.automationSendTail.then(async () => { const now = this.deps.now().getTime() const remaining = this.lastAutomationSendStartedAt !== undefined ? Math.max(0, AUTOMATION_SEND_INTERVAL_MS - (now - this.lastAutomationSendStartedAt)) : 0 if (remaining > 0) await this.deps.wait(remaining) this.lastAutomationSendStartedAt = this.deps.now().getTime() return this.deps.send(sendRequest) }) this.automationSendTail = queued.then( () => undefined, () => undefined ) return queued } private async findExisting(idempotencyKey: string): Promise { const state = this.loadAuditState() const existing = state.records.find((record) => record.idempotencyKey === idempotencyKey) if (!existing) return undefined return { actionId: existing.actionId, status: existing.sendStatus, decision: existing.decision, ...(existing.errorCode ? { errorCode: existing.errorCode } : {}), ...(existing.decisionReason ? { reason: existing.decisionReason } : {}), startedAt: existing.startedAt, finishedAt: existing.finishedAt } } private idempotencyKey(request: WechatActionRequest): string | undefined { const explicit = String(request.idempotencyKey || '').trim() if (explicit) return explicit if (request.origin === 'scheduled_report' && request.executionId) { return `scheduled_report:${request.executionId}` } return undefined } private finishSent( actionId: string, request: WechatActionRequest, idempotencyKey: string | undefined, startedAt: string, sendResult: PersonalWechatSendResult ): WechatActionResult { const finishedAt = this.deps.now().toISOString() const result: WechatActionResult = { actionId, status: 'sent', decision: 'allow', startedAt, finishedAt, sendResult } this.writeAudit(request, idempotencyKey, result) return result } private finishFailed( actionId: string, request: WechatActionRequest, idempotencyKey: string | undefined, startedAt: string, errorCode: WechatActionErrorCode, reason: string, sendResult?: PersonalWechatSendResult ): WechatActionResult { const finishedAt = this.deps.now().toISOString() const result: WechatActionResult = { actionId, status: 'failed', decision: 'allow', errorCode, reason, startedAt, finishedAt, ...(sendResult ? { sendResult } : {}) } this.writeAudit(request, idempotencyKey, result) return result } private finishBlocked( actionId: string, request: WechatActionRequest, idempotencyKey: string | undefined, startedAt: string, errorCode: WechatActionErrorCode, reason: string ): WechatActionResult { const finishedAt = this.deps.now().toISOString() const result: WechatActionResult = { actionId, status: 'blocked', decision: 'block', errorCode, reason, startedAt, finishedAt } this.writeAudit(request, idempotencyKey, result) return result } private loadAuditState(): LoadedAuditState { const filePath = this.auditFilePath() if (this.auditState?.path === filePath) return this.auditState let records: WechatActionAuditRecord[] = [] try { const value = fs.readJsonSync(filePath) as unknown if (Array.isArray(value)) records = normalizeAuditRecords(value) else if ( value && typeof value === 'object' && Array.isArray((value as { actions?: unknown }).actions) ) { records = normalizeAuditRecords((value as { actions: unknown[] }).actions) } } catch { records = [] } this.auditState = { path: filePath, records } return this.auditState } private writeAudit( request: WechatActionRequest, idempotencyKey: string | undefined, result: WechatActionResult ): void { if ( !request || !request.recipient || (request.recipient.type !== 'group' && request.recipient.type !== 'contact') || !request.content || (request.content.type !== 'text' && request.content.type !== 'image' && request.content.type !== 'voice') ) { return } const state = this.loadAuditState() const record: WechatActionAuditRecord = { actionId: result.actionId, ...(idempotencyKey ? { idempotencyKey } : {}), origin: request.origin, purpose: request.purpose, triggerType: request.triggerType, ...(request.sourceId ? { sourceId: request.sourceId } : {}), ...(request.executionId ? { executionId: request.executionId } : {}), recipientType: request.recipient.type, recipientId: request.recipient.id, ...(request.recipient.name ? { recipientName: request.recipient.name } : {}), contentType: request.content.type, ...contentAudit(request.content), createdAt: result.startedAt, startedAt: result.startedAt, finishedAt: result.finishedAt, decision: result.decision, ...(result.reason ? { decisionReason: result.reason } : {}), sendStatus: result.status, ...(result.errorCode ? { errorCode: result.errorCode } : {}) } const withoutKey = state.records.filter((item) => item.actionId !== record.actionId) state.records = [record, ...withoutKey].slice(0, MAX_AUDIT_RECORDS) try { fs.ensureDirSync(path.dirname(state.path)) fs.writeJsonSync(state.path, state.records, { spaces: 2 }) } catch (error) { console.warn('[WechatActionGateway] 保存 Action 审计失败:', error) } } private auditFilePath(): string { return path.join(this.deps.getUserDataPath(), 'actions', 'wechat-actions.json') } } export function evaluateWechatActionPolicy(request: WechatActionRequest): PolicyDecision { if (!request || typeof request !== 'object') { return { decision: 'block', source: 'deterministic', reasonCode: 'INVALID_REQUEST', reason: '发送动作请求格式无效' } } const recipient = request.recipient if ( !recipient || typeof recipient !== 'object' || (recipient.type !== 'group' && recipient.type !== 'contact') || !String(recipient.id || '').trim() ) { return { decision: 'block', source: 'deterministic', reasonCode: 'INVALID_RECIPIENT', reason: '发送动作必须指定有效的收件人' } } if (request.triggerType === 'automation' && !AUTOMATION_PURPOSE_ALLOWLIST.has(request.purpose)) { return { decision: 'block', source: 'deterministic', reasonCode: 'ACTION_NOT_ALLOWED', reason: `自动化动作不允许执行 purpose=${request.purpose}` } } return { decision: 'allow', source: 'deterministic' } } /** 发送前会根据固定规则检查是否允许发送。 */ export function shouldUseAiPolicy(request: WechatActionRequest): boolean { void request return false } export function normalizeActionRequest(value: unknown): WechatActionRequest | null { if (!value || typeof value !== 'object') return null const input = value as Partial const origin = String(input.origin || '').trim() const purpose = String(input.purpose || '').trim() const triggerType = input.triggerType const recipient = input.recipient const content = input.content if (!origin || !purpose || (triggerType !== 'automation' && triggerType !== 'user')) return null if (!recipient || typeof recipient !== 'object') return null const recipientType = (recipient as { type?: unknown }).type const recipientId = String((recipient as { id?: unknown }).id || '').trim() if (recipientType !== 'group' && recipientType !== 'contact') return null if (!recipientId) return null if (!content || typeof content !== 'object') return null const contentType = (content as { type?: unknown }).type if (contentType === 'text') { const text = String((content as { text?: unknown }).text || '').trim() if (!text || text.length > 2_000) return null return { ...input, origin, purpose, triggerType, ...(input.id ? { id: String(input.id).trim() } : {}), ...(input.idempotencyKey ? { idempotencyKey: String(input.idempotencyKey).trim() } : {}), ...(input.sourceId ? { sourceId: String(input.sourceId).trim() } : {}), ...(input.executionId ? { executionId: String(input.executionId).trim() } : {}), recipient: { type: recipientType, id: recipientId, ...((recipient as { name?: unknown }).name ? { name: String((recipient as { name?: unknown }).name).trim() } : {}) }, content: { type: 'text', text } } as WechatActionRequest } if (contentType !== 'image' && contentType !== 'voice') return null const filePath = String((content as { path?: unknown }).path || '').trim() if (!filePath) return null return { ...input, origin, purpose, triggerType, ...(input.id ? { id: String(input.id).trim() } : {}), ...(input.idempotencyKey ? { idempotencyKey: String(input.idempotencyKey).trim() } : {}), ...(input.sourceId ? { sourceId: String(input.sourceId).trim() } : {}), ...(input.executionId ? { executionId: String(input.executionId).trim() } : {}), recipient: { type: recipientType, id: recipientId, ...((recipient as { name?: unknown }).name ? { name: String((recipient as { name?: unknown }).name).trim() } : {}) }, content: { type: contentType, path: filePath } } as WechatActionRequest } function validationErrorCode(value: unknown): WechatActionErrorCode { if (!value || typeof value !== 'object') return 'INVALID_REQUEST' const recipient = (value as { recipient?: unknown }).recipient if (!recipient || typeof recipient !== 'object') return 'INVALID_RECIPIENT' const recipientValue = recipient as { type?: unknown; id?: unknown } if ( (recipientValue.type !== 'group' && recipientValue.type !== 'contact') || !String(recipientValue.id || '').trim() ) { return 'INVALID_RECIPIENT' } return 'INVALID_REQUEST' } function validationErrorReason(value: unknown): string { return validationErrorCode(value) === 'INVALID_RECIPIENT' ? '发送动作必须指定有效的收件人' : '发送动作请求格式无效' } function toPersonalWechatSendRequest(request: WechatActionRequest): PersonalWechatSendRequest { const base = { to: request.recipient.id, isGroup: request.recipient.type === 'group' } if (request.content.type === 'text') return { ...base, type: 'text', text: request.content.text } if (request.content.type === 'image') { return { ...base, type: 'image', filePath: request.content.path } } const metadata = request.metadata && typeof request.metadata === 'object' ? request.metadata : {} const fromId = typeof metadata.fromId === 'string' ? metadata.fromId.trim() : '' const durationMs = typeof metadata.durationMs === 'number' && Number.isFinite(metadata.durationMs) ? metadata.durationMs : undefined return { ...base, type: 'voice', filePath: request.content.path, ...(fromId ? { fromId } : {}), ...(durationMs !== undefined ? { durationMs } : {}) } } /** * 把网关结果还原成既有调用方期望的 `PersonalWechatSendResult`。 * * 为什么需要:手动发送的 IPC(`wechat-personal:send`)返回契约是 * `PersonalWechatSendResult`,界面上靠 `response.success` / `response.error` 判读。 * 改成走网关之后必须把这个契约**原样**还回去,否则「统一发送路径」会把 UI 一起改坏。 * * 网关成功时会把底层 `sendResult` 原样挂在 `action.sendResult` 上,优先用它 * (它带着真实的 `status`);拿不到时按 `action.status` 合成一个。 */ export function toPersonalWechatSendResult( action: WechatActionResult, fallbackStatus: PersonalWechatSenderStatus ): PersonalWechatSendResult { const raw = action.sendResult if (raw && typeof raw === 'object' && 'success' in (raw as Record)) { return raw as PersonalWechatSendResult } if (action.status === 'sent') return { success: true, status: fallbackStatus } return { success: false, status: fallbackStatus, error: action.reason || action.errorCode || '微信发送失败' } } function contentAudit( content: WechatActionContent ): Pick { const raw = content.type === 'text' ? content.text : content.path const preview = content.type === 'text' ? raw : path.basename(raw) return { contentPreview: preview.slice(0, MAX_CONTENT_PREVIEW_LENGTH), contentHash: createHash('sha256').update(raw).digest('hex') } } function contentTypeLabel(type: WechatActionContent['type']): string { return type === 'text' ? '文字' : type === 'image' ? '图片' : '语音' } function normalizeAuditRecords(values: unknown[]): WechatActionAuditRecord[] { return values .filter((value): value is WechatActionAuditRecord => { if (!value || typeof value !== 'object') return false const record = value as Partial return Boolean( String(record.actionId || '').trim() && String(record.recipientId || '').trim() && String(record.contentType || '').trim() && String(record.startedAt || '').trim() && String(record.finishedAt || '').trim() ) }) .slice(0, MAX_AUDIT_RECORDS) } export const wechatActionGateway = new WechatActionGateway() /** 兼容性名称,方便已有调用继续使用。 */ export const personalWechatActionService = wechatActionGateway