Files
WechatExplorer/src/main/services/wechat-action-gateway.ts
T
电摇小子 4d5c6459fe feat: 定时日报并入自动化,统一规则管理与发送链路
发送目标与退群通知统一为 来源群 / 自己 / 文件传输助手 / 指定好友
日报页补「定时日报已并入自动化」指引条
2026-09-28 17:11:50 +07:00

643 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<PersonalWechatSendCapability>
send?: (request: PersonalWechatSendRequest) => Promise<PersonalWechatSendResult>
getUserDataPath?: () => string
now?: () => Date
wait?: (milliseconds: number) => Promise<void>
}
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<string, Promise<WechatActionResult>>()
private auditState: LoadedAuditState | null = null
private automationSendTail: Promise<void> = Promise.resolve()
private lastAutomationSendStartedAt: number | undefined
constructor(deps: WechatActionGatewayDependencies = {}) {
this.deps = { ...defaultDependencies(), ...deps }
}
/**
* 用户确认后的日报发送序列。两步都进入 automation 发送队列,从而复用既有 3 秒间隔;
* 后置词 action 只在图片明确 sent 后创建,图片失败/blocked 时严格短路。
*/
async executeReportImageSequence(
request: ReportImageSequenceRequest
): Promise<ReportImageSequenceResult> {
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<WechatActionResult> {
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<WechatActionResult> {
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<PersonalWechatSendResult> {
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<WechatActionResult | undefined> {
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<WechatActionRequest>
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<string, unknown>)) {
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<WechatActionAuditRecord, 'contentPreview' | 'contentHash'> {
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<WechatActionAuditRecord>
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