mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-05 04:20:34 +08:00
643 lines
22 KiB
TypeScript
643 lines
22 KiB
TypeScript
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
|