mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-03 18:33:14 +08:00
feat: 建立发送网关与动作审计
This commit is contained in:
@@ -2,16 +2,17 @@ import { app, BrowserWindow } from 'electron'
|
|||||||
import fs from 'fs-extra'
|
import fs from 'fs-extra'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import * as chat from './chat-service'
|
import * as chat from './chat-service'
|
||||||
import { personalWechatCapabilityService } from './personal-wechat-capability-service'
|
import { wechatActionGateway, type WechatActionGateway } from './wechat-action-gateway'
|
||||||
import { personalWechatSendService } from './personal-wechat-send-service'
|
|
||||||
import {
|
import {
|
||||||
|
buildMemberLeftNotification,
|
||||||
findRemovedGroupMembers,
|
findRemovedGroupMembers,
|
||||||
groupExitMemberName,
|
groupExitMemberName,
|
||||||
normalizeGroupExitNotificationTemplate,
|
normalizeGroupExitNotificationTemplate,
|
||||||
renderGroupExitMonitorNotification,
|
|
||||||
validateGroupExitNotificationTemplate,
|
validateGroupExitNotificationTemplate,
|
||||||
type GroupExitMonitorEvent,
|
type GroupExitMonitorEvent,
|
||||||
type GroupExitMonitorMember,
|
type GroupExitMonitorMember,
|
||||||
|
type GroupExitNotificationStatus,
|
||||||
|
type GroupExitNotificationState,
|
||||||
type GroupExitMonitorState
|
type GroupExitMonitorState
|
||||||
} from '../../shared/group-exit-monitor'
|
} from '../../shared/group-exit-monitor'
|
||||||
|
|
||||||
@@ -39,7 +40,17 @@ const GROUP_READ_CONCURRENCY = 8
|
|||||||
const DUPLICATE_WINDOW_MS = 2 * 60 * 1000
|
const DUPLICATE_WINDOW_MS = 2 * 60 * 1000
|
||||||
const MAX_EVENTS = 500
|
const MAX_EVENTS = 500
|
||||||
|
|
||||||
|
export interface GroupExitMonitorServiceDependencies {
|
||||||
|
actionGateway?: GroupExitActionGateway
|
||||||
|
}
|
||||||
|
|
||||||
|
type GroupExitActionGateway = Pick<WechatActionGateway, 'execute'> &
|
||||||
|
Partial<
|
||||||
|
Pick<WechatActionGateway, 'registerMemberEvent' | 'registerMemberEvents' | 'clearMemberEvents'>
|
||||||
|
>
|
||||||
|
|
||||||
class GroupExitMonitorService {
|
class GroupExitMonitorService {
|
||||||
|
private readonly actionGateway: GroupExitActionGateway
|
||||||
private active = false
|
private active = false
|
||||||
private nativeMonitorActive = false
|
private nativeMonitorActive = false
|
||||||
private snapshots = new Map<string, GroupSnapshotRecord>()
|
private snapshots = new Map<string, GroupSnapshotRecord>()
|
||||||
@@ -59,6 +70,10 @@ class GroupExitMonitorService {
|
|||||||
private scopeGeneration = 0
|
private scopeGeneration = 0
|
||||||
private notificationTemplate = normalizeGroupExitNotificationTemplate(undefined)
|
private notificationTemplate = normalizeGroupExitNotificationTemplate(undefined)
|
||||||
|
|
||||||
|
constructor(deps: GroupExitMonitorServiceDependencies = {}) {
|
||||||
|
this.actionGateway = deps.actionGateway || wechatActionGateway
|
||||||
|
}
|
||||||
|
|
||||||
getState(): GroupExitMonitorState {
|
getState(): GroupExitMonitorState {
|
||||||
this.ensureLoaded()
|
this.ensureLoaded()
|
||||||
return {
|
return {
|
||||||
@@ -84,6 +99,7 @@ class GroupExitMonitorService {
|
|||||||
this.accountRoot &&
|
this.accountRoot &&
|
||||||
path.resolve(currentRoot) !== path.resolve(this.accountRoot)
|
path.resolve(currentRoot) !== path.resolve(this.accountRoot)
|
||||||
) {
|
) {
|
||||||
|
this.actionGateway.clearMemberEvents?.()
|
||||||
this.events = []
|
this.events = []
|
||||||
this.lastReadAt = 0
|
this.lastReadAt = 0
|
||||||
this.monitorSelectionConfigured = true
|
this.monitorSelectionConfigured = true
|
||||||
@@ -189,6 +205,7 @@ class GroupExitMonitorService {
|
|||||||
clearEvents(): GroupExitMonitorState {
|
clearEvents(): GroupExitMonitorState {
|
||||||
this.ensureLoaded()
|
this.ensureLoaded()
|
||||||
this.events = []
|
this.events = []
|
||||||
|
this.actionGateway.clearMemberEvents?.()
|
||||||
this.lastReadAt = Date.now()
|
this.lastReadAt = Date.now()
|
||||||
this.save()
|
this.save()
|
||||||
this.broadcast()
|
this.broadcast()
|
||||||
@@ -225,6 +242,7 @@ class GroupExitMonitorService {
|
|||||||
if (!currentRoomIds.has(roomId)) this.snapshots.delete(roomId)
|
if (!currentRoomIds.has(roomId)) this.snapshots.delete(roomId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const notificationTasks: Promise<void>[] = []
|
||||||
for (const next of groups) {
|
for (const next of groups) {
|
||||||
if (scopeGeneration !== this.scopeGeneration) return
|
if (scopeGeneration !== this.scopeGeneration) return
|
||||||
if (next.membersValid === false) continue
|
if (next.membersValid === false) continue
|
||||||
@@ -246,10 +264,12 @@ class GroupExitMonitorService {
|
|||||||
for (const member of removed) {
|
for (const member of removed) {
|
||||||
const event = this.recordExit(next, member, previous.members.length, next.members.length)
|
const event = this.recordExit(next, member, previous.members.length, next.members.length)
|
||||||
if (event && this.notificationRoomIds.has(next.roomId)) {
|
if (event && this.notificationRoomIds.has(next.roomId)) {
|
||||||
void this.notifyGroup(next, event)
|
notificationTasks.push(this.notifyGroup(next, event))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (notificationTasks.length) await Promise.all(notificationTasks)
|
||||||
|
if (!this.active || scopeGeneration !== this.scopeGeneration) return
|
||||||
this.lastCheckedAt = Date.now()
|
this.lastCheckedAt = Date.now()
|
||||||
this.save()
|
this.save()
|
||||||
this.broadcast()
|
this.broadcast()
|
||||||
@@ -388,8 +408,10 @@ class GroupExitMonitorService {
|
|||||||
currentCount,
|
currentCount,
|
||||||
delta: currentCount - previousCount,
|
delta: currentCount - previousCount,
|
||||||
message,
|
message,
|
||||||
detectedAt
|
detectedAt,
|
||||||
|
notificationStatus: 'not_requested'
|
||||||
}
|
}
|
||||||
|
this.actionGateway.registerMemberEvent?.(event)
|
||||||
this.events = [event, ...this.events].slice(0, MAX_EVENTS)
|
this.events = [event, ...this.events].slice(0, MAX_EVENTS)
|
||||||
console.log(
|
console.log(
|
||||||
`[GroupMonitor] detected member exit roomId=${group.roomId} member=${member.wxid} ${previousCount}->${currentCount}`
|
`[GroupMonitor] detected member exit roomId=${group.roomId} member=${member.wxid} ${previousCount}->${currentCount}`
|
||||||
@@ -401,29 +423,64 @@ class GroupExitMonitorService {
|
|||||||
group: GroupSnapshotRecord,
|
group: GroupSnapshotRecord,
|
||||||
event: GroupExitMonitorEvent
|
event: GroupExitMonitorEvent
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
event.notificationStatus = 'pending'
|
||||||
|
event.notification = { status: 'pending' }
|
||||||
|
this.save()
|
||||||
|
this.broadcast()
|
||||||
try {
|
try {
|
||||||
const capability = await personalWechatCapabilityService.getPersonalWechatSendCapability()
|
const result = await this.actionGateway.execute({
|
||||||
if (!capability.ready || !capability.capabilities.text) {
|
idempotencyKey: `member_left_notification:${event.id}`,
|
||||||
console.warn(`[GroupMonitor] 跳过群聊通知 roomId=${group.roomId}: ${capability.message}`)
|
origin: 'member_monitor',
|
||||||
return
|
purpose: 'member_left_notification',
|
||||||
}
|
triggerType: 'automation',
|
||||||
// 具体传输方式由发送服务按平台处理。
|
sourceId: event.id,
|
||||||
const result = await personalWechatSendService.send({
|
recipient: {
|
||||||
type: 'text',
|
type: 'group',
|
||||||
to: group.roomId,
|
id: group.roomId,
|
||||||
isGroup: true,
|
name: group.groupName
|
||||||
text: renderGroupExitMonitorNotification(event, this.notificationTemplate)
|
},
|
||||||
|
content: {
|
||||||
|
type: 'text',
|
||||||
|
text: buildMemberLeftNotification(event, this.notificationTemplate)
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
memberId: event.memberWxid,
|
||||||
|
memberName: event.memberName,
|
||||||
|
detectedAt: event.detectedAt,
|
||||||
|
eventType: 'member_left',
|
||||||
|
eventRoomId: event.roomId
|
||||||
|
}
|
||||||
})
|
})
|
||||||
if (!result.success) {
|
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') {
|
||||||
console.warn(
|
console.warn(
|
||||||
`[GroupMonitor] 群聊通知发送失败 roomId=${group.roomId}: ${result.error || ''}`
|
`[GroupMonitor] 群聊通知未发送 roomId=${group.roomId} status=${result.status} code=${result.errorCode || ''}`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
event.notificationStatus = 'failed'
|
||||||
|
event.notification = {
|
||||||
|
status: 'failed',
|
||||||
|
errorCode: 'UNKNOWN',
|
||||||
|
reason: error instanceof Error ? error.message : String(error)
|
||||||
|
}
|
||||||
console.warn(
|
console.warn(
|
||||||
`[GroupMonitor] 群聊通知异常 roomId=${group.roomId}:`,
|
`[GroupMonitor] 群聊通知异常 roomId=${group.roomId}:`,
|
||||||
error instanceof Error ? error.message : String(error)
|
error instanceof Error ? error.message : String(error)
|
||||||
)
|
)
|
||||||
|
} finally {
|
||||||
|
this.save()
|
||||||
|
this.broadcast()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -437,6 +494,7 @@ class GroupExitMonitorService {
|
|||||||
try {
|
try {
|
||||||
const stored = fs.readJsonSync(this.filePath()) as StoredState
|
const stored = fs.readJsonSync(this.filePath()) as StoredState
|
||||||
this.events = normalizeEvents(stored.events)
|
this.events = normalizeEvents(stored.events)
|
||||||
|
this.actionGateway.registerMemberEvents?.(this.events)
|
||||||
this.lastReadAt = Number(stored.lastReadAt) || 0
|
this.lastReadAt = Number(stored.lastReadAt) || 0
|
||||||
this.accountRoot = String(stored.accountRoot || '')
|
this.accountRoot = String(stored.accountRoot || '')
|
||||||
// 没有显式范围时按空范围处理,保留已有选择。
|
// 没有显式范围时按空范围处理,保留已有选择。
|
||||||
@@ -556,13 +614,41 @@ function normalizeEvents(
|
|||||||
? Number(value.delta)
|
? Number(value.delta)
|
||||||
: currentCount - previousCount,
|
: currentCount - previousCount,
|
||||||
message: String(value.message || `${memberName}退出了${groupName}`),
|
message: String(value.message || `${memberName}退出了${groupName}`),
|
||||||
detectedAt
|
detectedAt,
|
||||||
|
...(value.notificationStatus
|
||||||
|
? { notificationStatus: normalizeNotificationStatus(value.notificationStatus) }
|
||||||
|
: {}),
|
||||||
|
...(value.notification && typeof value.notification === 'object'
|
||||||
|
? { notification: normalizeNotification(value.notification) }
|
||||||
|
: {})
|
||||||
})
|
})
|
||||||
if (normalized.length >= MAX_EVENTS) break
|
if (normalized.length >= MAX_EVENTS) break
|
||||||
}
|
}
|
||||||
return normalized
|
return normalized
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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<GroupExitNotificationState>
|
||||||
|
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 {
|
function isContactEvent(rawPayload: string): boolean {
|
||||||
const payload = String(rawPayload || '').trim()
|
const payload = String(rawPayload || '').trim()
|
||||||
if (!payload) return false
|
if (!payload) return false
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import { promises as fs } from 'fs'
|
|||||||
import path from 'path'
|
import path from 'path'
|
||||||
import type {
|
import type {
|
||||||
PersonalWechatSendCapability,
|
PersonalWechatSendCapability,
|
||||||
PersonalWechatSendRequest
|
PersonalWechatSendRequest,
|
||||||
|
PersonalWechatSendResult
|
||||||
} from '../../shared/personal-wechat'
|
} from '../../shared/personal-wechat'
|
||||||
|
import type { WechatActionRequest, WechatActionResult } from '../../shared/wechat-action'
|
||||||
import type { AgentHubStatus } from '../../shared/agent-hub'
|
import type { AgentHubStatus } from '../../shared/agent-hub'
|
||||||
import type {
|
import type {
|
||||||
ScheduledReportCreateInput,
|
ScheduledReportCreateInput,
|
||||||
@@ -33,8 +35,8 @@ import {
|
|||||||
import type { SaveGeneratedReportRequest } from '../../shared/report-history'
|
import type { SaveGeneratedReportRequest } from '../../shared/report-history'
|
||||||
import { saveGeneratedReport } from '../report-history-service'
|
import { saveGeneratedReport } from '../report-history-service'
|
||||||
import { generateAgentGroupReport } from './agent-group-report-service'
|
import { generateAgentGroupReport } from './agent-group-report-service'
|
||||||
import { personalWechatSendService } from './personal-wechat-send-service'
|
|
||||||
import { personalWechatCapabilityService } from './personal-wechat-capability-service'
|
import { personalWechatCapabilityService } from './personal-wechat-capability-service'
|
||||||
|
import { WechatActionGateway, wechatActionGateway } from './wechat-action-gateway'
|
||||||
import { getContactAvatars, isReady as isChatReady, resolveMd5 } from './chat-service'
|
import { getContactAvatars, isReady as isChatReady, resolveMd5 } from './chat-service'
|
||||||
import { agentHubService, type AgentHubNotificationResult } from './agent-hub-service'
|
import { agentHubService, type AgentHubNotificationResult } from './agent-hub-service'
|
||||||
|
|
||||||
@@ -55,7 +57,8 @@ export interface ScheduledReportDependencies {
|
|||||||
saveGeneratedReport: (
|
saveGeneratedReport: (
|
||||||
request: SaveGeneratedReportRequest
|
request: SaveGeneratedReportRequest
|
||||||
) => ReturnType<typeof saveGeneratedReport>
|
) => ReturnType<typeof saveGeneratedReport>
|
||||||
send: (request: PersonalWechatSendRequest) => ReturnType<typeof personalWechatSendService.send>
|
send: (request: PersonalWechatSendRequest) => Promise<PersonalWechatSendResult>
|
||||||
|
sendAction?: (input: ScheduledReportSendActionInput) => Promise<WechatActionResult>
|
||||||
sendNotification: (input: { to?: string; text: string }) => Promise<AgentHubNotificationResult>
|
sendNotification: (input: { to?: string; text: string }) => Promise<AgentHubNotificationResult>
|
||||||
getNotificationRecipient: () => string | undefined
|
getNotificationRecipient: () => string | undefined
|
||||||
getAgentHubStatus: () => AgentHubStatus
|
getAgentHubStatus: () => AgentHubStatus
|
||||||
@@ -65,11 +68,43 @@ export interface ScheduledReportDependencies {
|
|||||||
now?: () => Date
|
now?: () => Date
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ScheduledReportSendActionInput {
|
||||||
|
target: string
|
||||||
|
filePath: string
|
||||||
|
executionId: string
|
||||||
|
triggerType: 'scheduled' | 'manual'
|
||||||
|
retryCount?: number
|
||||||
|
taskId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildScheduledReportActionRequest(
|
||||||
|
input: ScheduledReportSendActionInput
|
||||||
|
): WechatActionRequest {
|
||||||
|
return {
|
||||||
|
idempotencyKey:
|
||||||
|
input.retryCount && input.retryCount > 0
|
||||||
|
? `scheduled_report:${input.executionId}:retry:${input.retryCount}`
|
||||||
|
: `scheduled_report:${input.executionId}`,
|
||||||
|
origin: 'scheduled_report',
|
||||||
|
purpose: 'scheduled_report',
|
||||||
|
triggerType: input.triggerType === 'scheduled' ? 'automation' : 'user',
|
||||||
|
sourceId: input.executionId,
|
||||||
|
executionId: input.executionId,
|
||||||
|
recipient: { type: 'group', id: input.target },
|
||||||
|
content: { type: 'image', path: input.filePath },
|
||||||
|
metadata: {
|
||||||
|
taskId: input.taskId,
|
||||||
|
retryCount: input.retryCount || 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const defaultDependencies = (): ScheduledReportDependencies => ({
|
const defaultDependencies = (): ScheduledReportDependencies => ({
|
||||||
getCapability: () => personalWechatCapabilityService.getPersonalWechatSendCapability(),
|
getCapability: () => personalWechatCapabilityService.getPersonalWechatSendCapability(),
|
||||||
generateReport: generateAgentGroupReport,
|
generateReport: generateAgentGroupReport,
|
||||||
saveGeneratedReport,
|
saveGeneratedReport,
|
||||||
send: (request) => personalWechatSendService.send(request),
|
send: (request) => sendRequestThroughGateway(request),
|
||||||
|
sendAction: (input) => wechatActionGateway.execute(buildScheduledReportActionRequest(input)),
|
||||||
sendNotification: (input) => agentHubService.sendNotification(input),
|
sendNotification: (input) => agentHubService.sendNotification(input),
|
||||||
getNotificationRecipient: () => agentHubService.getNotificationRecipient(),
|
getNotificationRecipient: () => agentHubService.getNotificationRecipient(),
|
||||||
getAgentHubStatus: () => agentHubService.getStatus(),
|
getAgentHubStatus: () => agentHubService.getStatus(),
|
||||||
@@ -78,6 +113,35 @@ const defaultDependencies = (): ScheduledReportDependencies => ({
|
|||||||
isDatabaseReady: () => isChatReady()
|
isDatabaseReady: () => isChatReady()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
async function sendRequestThroughGateway(
|
||||||
|
request: PersonalWechatSendRequest
|
||||||
|
): Promise<PersonalWechatSendResult> {
|
||||||
|
const content =
|
||||||
|
request.type === 'text'
|
||||||
|
? { type: 'text' as const, text: request.text }
|
||||||
|
: { type: request.type, path: request.filePath }
|
||||||
|
const action = await wechatActionGateway.execute({
|
||||||
|
origin: 'scheduled_report',
|
||||||
|
purpose: 'scheduled_report',
|
||||||
|
triggerType: 'automation',
|
||||||
|
recipient: {
|
||||||
|
type: request.isGroup ? 'group' : 'contact',
|
||||||
|
id: request.to
|
||||||
|
},
|
||||||
|
content
|
||||||
|
})
|
||||||
|
const sendResult = action.sendResult
|
||||||
|
const status =
|
||||||
|
sendResult && typeof sendResult === 'object' && 'status' in sendResult
|
||||||
|
? (sendResult as { status: PersonalWechatSendResult['status'] }).status
|
||||||
|
: ({} as PersonalWechatSendResult['status'])
|
||||||
|
return {
|
||||||
|
success: action.status === 'sent',
|
||||||
|
status,
|
||||||
|
...(action.reason || action.errorCode ? { error: action.reason || action.errorCode } : {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const rangeValues = new Set<ScheduledReportRange>(['today', 'yesterday', '7days', 'recent24h'])
|
const rangeValues = new Set<ScheduledReportRange>(['today', 'yesterday', '7days', 'recent24h'])
|
||||||
|
|
||||||
const reportRangeLabel = (range: ScheduledReportRange): string =>
|
const reportRangeLabel = (range: ScheduledReportRange): string =>
|
||||||
@@ -148,7 +212,21 @@ export class ScheduledReportService {
|
|||||||
private readonly retrying = new Map<string, Promise<ScheduledReportExecution>>()
|
private readonly retrying = new Map<string, Promise<ScheduledReportExecution>>()
|
||||||
|
|
||||||
constructor(deps?: Partial<ScheduledReportDependencies>) {
|
constructor(deps?: Partial<ScheduledReportDependencies>) {
|
||||||
this.deps = { ...defaultDependencies(), ...deps }
|
const defaults = defaultDependencies()
|
||||||
|
const merged = { ...defaults, ...deps }
|
||||||
|
if (deps?.send && !deps.sendAction) {
|
||||||
|
// 保留注入的传输实现,方便测试和内嵌场景,同时让日报发送继续使用统一的发送入口。
|
||||||
|
const legacyGateway = new WechatActionGateway({
|
||||||
|
getCapability: merged.getCapability,
|
||||||
|
send: merged.send,
|
||||||
|
getUserDataPath: () => merged.storageDir,
|
||||||
|
...(merged.now ? { now: merged.now } : {})
|
||||||
|
})
|
||||||
|
merged.sendAction = (input) => legacyGateway.execute(buildScheduledReportActionRequest(input))
|
||||||
|
} else if (!merged.sendAction) {
|
||||||
|
merged.sendAction = defaults.sendAction
|
||||||
|
}
|
||||||
|
this.deps = merged
|
||||||
}
|
}
|
||||||
|
|
||||||
async start(): Promise<void> {
|
async start(): Promise<void> {
|
||||||
@@ -626,8 +704,6 @@ export class ScheduledReportService {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await update({ currentStage: 'precheck' })
|
await update({ currentStage: 'precheck' })
|
||||||
let capability: PersonalWechatSendCapability | null = null
|
|
||||||
let capabilityCheckError = ''
|
|
||||||
|
|
||||||
await update({ currentStage: 'data' })
|
await update({ currentStage: 'data' })
|
||||||
await update({ currentStage: 'ai' })
|
await update({ currentStage: 'ai' })
|
||||||
@@ -717,27 +793,6 @@ export class ScheduledReportService {
|
|||||||
}
|
}
|
||||||
await update({ reportId, htmlPath, pngPath, currentStage: 'send' })
|
await update({ reportId, htmlPath, pngPath, currentStage: 'send' })
|
||||||
|
|
||||||
try {
|
|
||||||
capability = await this.deps.getCapability()
|
|
||||||
} catch (error) {
|
|
||||||
capabilityCheckError = error instanceof Error ? error.message : String(error)
|
|
||||||
}
|
|
||||||
if (!capability?.ready || !capability.capabilities.image) {
|
|
||||||
const technicalMessage =
|
|
||||||
capabilityCheckError ||
|
|
||||||
capability?.error ||
|
|
||||||
capability?.message ||
|
|
||||||
'个人微信发送能力不可用'
|
|
||||||
return finishError(
|
|
||||||
technicalMessage,
|
|
||||||
'send',
|
|
||||||
'waiting_to_send',
|
|
||||||
{ sendStatus: 'unavailable', sendError: technicalMessage },
|
|
||||||
'partial_success',
|
|
||||||
'WECHAT_SEND_UNAVAILABLE'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const target = this.resolveTarget(task)
|
const target = this.resolveTarget(task)
|
||||||
if (!target) {
|
if (!target) {
|
||||||
return finishError(
|
return finishError(
|
||||||
@@ -750,13 +805,14 @@ export class ScheduledReportService {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
await update({ sendTarget: target, currentStage: 'send' })
|
await update({ sendTarget: target, currentStage: 'send' })
|
||||||
let sent: Awaited<ReturnType<ScheduledReportDependencies['send']>>
|
let action: WechatActionResult
|
||||||
try {
|
try {
|
||||||
sent = await this.deps.send({
|
action = await this.deps.sendAction!({
|
||||||
type: 'image',
|
target,
|
||||||
to: target,
|
filePath: pngPath,
|
||||||
isGroup: true,
|
executionId: execution.id,
|
||||||
filePath: pngPath
|
triggerType,
|
||||||
|
taskId: task.id
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return finishError(
|
return finishError(
|
||||||
@@ -771,14 +827,21 @@ export class ScheduledReportService {
|
|||||||
'WECHAT_SEND_FAILED'
|
'WECHAT_SEND_FAILED'
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (!sent.success) {
|
if (action.status !== 'sent') {
|
||||||
|
const unavailable =
|
||||||
|
action.errorCode === 'SEND_CAPABILITY_UNAVAILABLE' ||
|
||||||
|
action.errorCode === 'SEND_NOT_READY'
|
||||||
|
const actionError = action.reason || action.errorCode || '微信发送失败'
|
||||||
return finishError(
|
return finishError(
|
||||||
sent.error || '微信发送失败',
|
actionError,
|
||||||
'send',
|
'send',
|
||||||
|
unavailable ? 'waiting_to_send' : 'partial_success',
|
||||||
|
{
|
||||||
|
sendStatus: unavailable ? 'unavailable' : 'failed',
|
||||||
|
sendError: actionError
|
||||||
|
},
|
||||||
'partial_success',
|
'partial_success',
|
||||||
{ sendStatus: 'failed', sendError: sent.error || '微信发送失败' },
|
unavailable ? 'WECHAT_SEND_UNAVAILABLE' : 'WECHAT_SEND_FAILED'
|
||||||
'partial_success',
|
|
||||||
'WECHAT_SEND_FAILED'
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return this.finalizeExecution(task, execution, {
|
return this.finalizeExecution(task, execution, {
|
||||||
@@ -844,38 +907,28 @@ export class ScheduledReportService {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
let capability: PersonalWechatSendCapability | null = null
|
|
||||||
try {
|
|
||||||
capability = await this.deps.getCapability()
|
|
||||||
} catch (error) {
|
|
||||||
return finishRetryError(error, 'WECHAT_SEND_UNAVAILABLE', 'unavailable', 'waiting_to_send')
|
|
||||||
}
|
|
||||||
if (!capability.ready || !capability.capabilities.image) {
|
|
||||||
return finishRetryError(
|
|
||||||
capability.error || capability.message || '个人微信发送能力不可用',
|
|
||||||
'WECHAT_SEND_UNAVAILABLE',
|
|
||||||
'unavailable',
|
|
||||||
'waiting_to_send'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const target = working.sendTarget || this.resolveTarget(task)
|
const target = working.sendTarget || this.resolveTarget(task)
|
||||||
if (!target) {
|
if (!target) {
|
||||||
return finishRetryError('未找到指定微信群', 'WECHAT_SEND_FAILED', 'failed', 'partial_success')
|
return finishRetryError('未找到指定微信群', 'WECHAT_SEND_FAILED', 'failed', 'partial_success')
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const sent = await this.deps.send({
|
const action = await this.deps.sendAction!({
|
||||||
type: 'image',
|
target,
|
||||||
to: target,
|
filePath: working.pngPath,
|
||||||
isGroup: true,
|
executionId: working.id,
|
||||||
filePath: working.pngPath
|
triggerType: working.triggerType || 'scheduled',
|
||||||
|
retryCount,
|
||||||
|
taskId: task.id
|
||||||
})
|
})
|
||||||
if (!sent.success) {
|
if (action.status !== 'sent') {
|
||||||
|
const unavailable =
|
||||||
|
action.errorCode === 'SEND_CAPABILITY_UNAVAILABLE' ||
|
||||||
|
action.errorCode === 'SEND_NOT_READY'
|
||||||
return finishRetryError(
|
return finishRetryError(
|
||||||
sent.error || '微信发送失败',
|
action.reason || action.errorCode || '微信发送失败',
|
||||||
'WECHAT_SEND_FAILED',
|
unavailable ? 'WECHAT_SEND_UNAVAILABLE' : 'WECHAT_SEND_FAILED',
|
||||||
'failed',
|
unavailable ? 'unavailable' : 'failed',
|
||||||
'partial_success'
|
unavailable ? 'waiting_to_send' : 'partial_success'
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -932,18 +985,21 @@ export class ScheduledReportService {
|
|||||||
...patch,
|
...patch,
|
||||||
finishedAt: (this.deps.now?.() || new Date()).toISOString()
|
finishedAt: (this.deps.now?.() || new Date()).toISOString()
|
||||||
}
|
}
|
||||||
await this.persistExecution(completed)
|
// 让执行记录在通知完成前保持进行中,避免日报已结束但通知状态尚未更新。
|
||||||
|
const notified = notification
|
||||||
|
? await this.notifyExecution(task, completed, notification)
|
||||||
|
: completed
|
||||||
|
await this.persistExecution(notified)
|
||||||
const taskIndex = this.tasks!.findIndex((item) => item.id === task.id)
|
const taskIndex = this.tasks!.findIndex((item) => item.id === task.id)
|
||||||
if (taskIndex >= 0) {
|
if (taskIndex >= 0) {
|
||||||
this.tasks![taskIndex] = {
|
this.tasks![taskIndex] = {
|
||||||
...this.tasks![taskIndex],
|
...this.tasks![taskIndex],
|
||||||
lastRunAt: completed.finishedAt,
|
lastRunAt: notified.finishedAt,
|
||||||
updatedAt: completed.finishedAt!
|
updatedAt: notified.finishedAt!
|
||||||
}
|
}
|
||||||
await this.saveTasks()
|
await this.saveTasks()
|
||||||
}
|
}
|
||||||
if (notification) return this.notifyExecution(task, completed, notification)
|
return { ...notified }
|
||||||
return { ...completed }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async persistExecution(execution: ScheduledReportExecution): Promise<void> {
|
private async persistExecution(execution: ScheduledReportExecution): Promise<void> {
|
||||||
|
|||||||
@@ -0,0 +1,619 @@
|
|||||||
|
import { app } from 'electron'
|
||||||
|
import { createHash, randomUUID } from 'crypto'
|
||||||
|
import fs from 'fs-extra'
|
||||||
|
import path from 'path'
|
||||||
|
import type {
|
||||||
|
PersonalWechatSendCapability,
|
||||||
|
PersonalWechatSendRequest,
|
||||||
|
PersonalWechatSendResult
|
||||||
|
} from '../../shared/personal-wechat'
|
||||||
|
import type {
|
||||||
|
PolicyDecision,
|
||||||
|
WechatActionAuditRecord,
|
||||||
|
WechatActionContent,
|
||||||
|
WechatActionErrorCode,
|
||||||
|
WechatActionMemberEventReference,
|
||||||
|
WechatActionRequest,
|
||||||
|
WechatActionResult
|
||||||
|
} from '../../shared/wechat-action'
|
||||||
|
import { personalWechatCapabilityService } from './personal-wechat-capability-service'
|
||||||
|
import { personalWechatSendService } from './personal-wechat-send-service'
|
||||||
|
|
||||||
|
const MAX_AUDIT_RECORDS = 500
|
||||||
|
const MAX_CONTENT_PREVIEW_LENGTH = 240
|
||||||
|
const AUTOMATION_PURPOSE_ALLOWLIST = new Set(['scheduled_report', 'member_left_notification'])
|
||||||
|
|
||||||
|
export interface WechatActionGatewayDependencies {
|
||||||
|
getCapability?: () => Promise<PersonalWechatSendCapability>
|
||||||
|
send?: (request: PersonalWechatSendRequest) => Promise<PersonalWechatSendResult>
|
||||||
|
getMemberEvent?: (
|
||||||
|
sourceId: string
|
||||||
|
) =>
|
||||||
|
| WechatActionMemberEventReference
|
||||||
|
| Promise<WechatActionMemberEventReference | undefined>
|
||||||
|
| undefined
|
||||||
|
checkEntitlement?: (request: WechatActionRequest) => boolean | Promise<boolean>
|
||||||
|
getUserDataPath?: () => string
|
||||||
|
now?: () => Date
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LoadedAuditState {
|
||||||
|
path: string
|
||||||
|
records: WechatActionAuditRecord[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WechatActionPolicyContext {
|
||||||
|
memberEvent?: WechatActionMemberEventReference
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultDependencies = (): Required<
|
||||||
|
Pick<WechatActionGatewayDependencies, 'getCapability' | 'send' | 'getUserDataPath' | 'now'>
|
||||||
|
> => ({
|
||||||
|
getCapability: () => personalWechatCapabilityService.getPersonalWechatSendCapability(),
|
||||||
|
send: (request) => personalWechatSendService.send(request),
|
||||||
|
getUserDataPath: () => app.getPath('userData'),
|
||||||
|
now: () => new Date()
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一管理微信发送操作,在真正发送前完成必要检查;
|
||||||
|
* 具体发送由底层服务处理,使用者只需提供发送内容和对象。
|
||||||
|
*/
|
||||||
|
export class WechatActionGateway {
|
||||||
|
private readonly deps: Required<
|
||||||
|
Pick<WechatActionGatewayDependencies, 'getCapability' | 'send' | 'getUserDataPath' | 'now'>
|
||||||
|
> &
|
||||||
|
Omit<WechatActionGatewayDependencies, 'getCapability' | 'send' | 'getUserDataPath' | 'now'>
|
||||||
|
private readonly memberEvents = new Map<string, WechatActionMemberEventReference>()
|
||||||
|
private readonly inFlight = new Map<string, Promise<WechatActionResult>>()
|
||||||
|
private auditState: LoadedAuditState | null = null
|
||||||
|
|
||||||
|
constructor(deps: WechatActionGatewayDependencies = {}) {
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
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 eventContext = await this.resolveEventContext(request)
|
||||||
|
const policy = evaluateWechatActionPolicy(request, eventContext)
|
||||||
|
if (policy.decision !== 'allow') {
|
||||||
|
return this.finishBlocked(
|
||||||
|
actionId,
|
||||||
|
request,
|
||||||
|
idempotencyKey,
|
||||||
|
startedAt,
|
||||||
|
policy.reasonCode || 'POLICY_BLOCKED',
|
||||||
|
policy.reason || '该发送动作未通过策略检查'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let entitled = true
|
||||||
|
try {
|
||||||
|
entitled = this.deps.checkEntitlement
|
||||||
|
? await this.deps.checkEntitlement(request)
|
||||||
|
: await checkEntitlement(request)
|
||||||
|
} catch (error) {
|
||||||
|
entitled = false
|
||||||
|
return this.finishBlocked(
|
||||||
|
actionId,
|
||||||
|
request,
|
||||||
|
idempotencyKey,
|
||||||
|
startedAt,
|
||||||
|
'POLICY_BLOCKED',
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!entitled) {
|
||||||
|
return this.finishBlocked(
|
||||||
|
actionId,
|
||||||
|
request,
|
||||||
|
idempotencyKey,
|
||||||
|
startedAt,
|
||||||
|
'POLICY_BLOCKED',
|
||||||
|
'该发送动作未获得当前账号的使用权限'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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.deps.send(toPersonalWechatSendRequest(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 async resolveEventContext(
|
||||||
|
request: WechatActionRequest
|
||||||
|
): Promise<WechatActionPolicyContext> {
|
||||||
|
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<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.purpose === 'member_left_notification' && request.sourceId) {
|
||||||
|
return `member_left_notification:${request.sourceId}`
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
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,
|
||||||
|
context: WechatActionPolicyContext = {}
|
||||||
|
): 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}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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' }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 发送前会根据固定规则检查是否允许发送。 */
|
||||||
|
export function shouldUseAiPolicy(request: WechatActionRequest): boolean {
|
||||||
|
void request
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 当前默认允许发送,后续可在这里补充权限限制。 */
|
||||||
|
export function checkEntitlement(request: WechatActionRequest): boolean {
|
||||||
|
void request
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
return { ...base, type: 'voice', filePath: request.content.path }
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
@@ -80,6 +80,38 @@ const sendCapabilityLabel = (capability: PersonalWechatSendCapability | null): s
|
|||||||
const sendCapabilityTone = (capability: PersonalWechatSendCapability | null): string =>
|
const sendCapabilityTone = (capability: PersonalWechatSendCapability | null): string =>
|
||||||
capability?.ready && capability.capabilities.text ? 'ready' : 'unready'
|
capability?.ready && capability.capabilities.text ? 'ready' : 'unready'
|
||||||
|
|
||||||
|
function EventNotificationStatus({
|
||||||
|
event
|
||||||
|
}: {
|
||||||
|
event: GroupExitMonitorState['events'][number]
|
||||||
|
}): React.ReactElement {
|
||||||
|
const status = event.notificationStatus || event.notification?.status || 'not_requested'
|
||||||
|
const details =
|
||||||
|
status === 'sent'
|
||||||
|
? { label: '已通知当前群聊', tone: 'sent', description: '' }
|
||||||
|
: status === 'pending'
|
||||||
|
? { label: '正在通知当前群聊', tone: 'pending', description: '' }
|
||||||
|
: status === 'blocked'
|
||||||
|
? { label: '通知已拦截', tone: 'blocked', description: '该操作未通过发送策略检查。' }
|
||||||
|
: status === 'failed'
|
||||||
|
? {
|
||||||
|
label: '通知未发送',
|
||||||
|
tone: 'failed',
|
||||||
|
description:
|
||||||
|
event.notification?.errorCode === 'SEND_CAPABILITY_UNAVAILABLE' ||
|
||||||
|
event.notification?.errorCode === 'SEND_NOT_READY'
|
||||||
|
? '当前微信发送能力不可用,退群事件已正常记录。'
|
||||||
|
: '退群事件已正常记录,但通知发送失败。'
|
||||||
|
}
|
||||||
|
: { label: '仅记录', tone: 'recorded', description: '' }
|
||||||
|
return (
|
||||||
|
<div className={`exit-monitor-event-notification ${details.tone}`}>
|
||||||
|
<span>{details.label}</span>
|
||||||
|
{details.description ? <small>{details.description}</small> : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function SendCapabilityStatus({
|
function SendCapabilityStatus({
|
||||||
capability,
|
capability,
|
||||||
className = ''
|
className = ''
|
||||||
@@ -783,6 +815,7 @@ export function GroupExitMonitorWorkspace({
|
|||||||
群人数 {event.previousCount} 人 → {event.currentCount} 人
|
群人数 {event.previousCount} 人 → {event.currentCount} 人
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
<EventNotificationStatus event={event} />
|
||||||
<dl className="exit-monitor-event-details">
|
<dl className="exit-monitor-event-details">
|
||||||
<div>
|
<div>
|
||||||
<dt>微信名</dt>
|
<dt>微信名</dt>
|
||||||
|
|||||||
@@ -350,6 +350,40 @@
|
|||||||
color: var(--wxex-danger);
|
color: var(--wxex-danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.exit-monitor-event-notification {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 7px;
|
||||||
|
color: var(--wxex-text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exit-monitor-event-notification span {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exit-monitor-event-notification small {
|
||||||
|
color: var(--wxex-text-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exit-monitor-event-notification.sent {
|
||||||
|
color: var(--wxex-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.exit-monitor-event-notification.blocked,
|
||||||
|
.exit-monitor-event-notification.failed {
|
||||||
|
color: var(--wxex-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.exit-monitor-event-notification.pending {
|
||||||
|
color: var(--wxex-brand);
|
||||||
|
}
|
||||||
|
|
||||||
.exit-monitor-event-details {
|
.exit-monitor-event-details {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { WechatActionResult } from './wechat-action'
|
||||||
|
|
||||||
export interface GroupExitMonitorMember {
|
export interface GroupExitMonitorMember {
|
||||||
wxid: string
|
wxid: string
|
||||||
nickname?: string
|
nickname?: string
|
||||||
@@ -7,6 +9,23 @@ export interface GroupExitMonitorMember {
|
|||||||
avatar?: string
|
avatar?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type GroupExitNotificationStatus =
|
||||||
|
| 'not_requested'
|
||||||
|
| 'pending'
|
||||||
|
| 'sent'
|
||||||
|
| 'blocked'
|
||||||
|
| 'failed'
|
||||||
|
|
||||||
|
export interface GroupExitNotificationState {
|
||||||
|
status: GroupExitNotificationStatus
|
||||||
|
actionId?: string
|
||||||
|
decision?: WechatActionResult['decision']
|
||||||
|
errorCode?: string
|
||||||
|
reason?: string
|
||||||
|
startedAt?: string
|
||||||
|
finishedAt?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface GroupExitMonitorEvent {
|
export interface GroupExitMonitorEvent {
|
||||||
id: string
|
id: string
|
||||||
contactId: string
|
contactId: string
|
||||||
@@ -25,6 +44,9 @@ export interface GroupExitMonitorEvent {
|
|||||||
delta: number
|
delta: number
|
||||||
message: string
|
message: string
|
||||||
detectedAt: number
|
detectedAt: number
|
||||||
|
/** 可选通知的结果;退群检测与通知发送彼此独立。 */
|
||||||
|
notificationStatus?: GroupExitNotificationStatus
|
||||||
|
notification?: GroupExitNotificationState
|
||||||
}
|
}
|
||||||
|
|
||||||
const LEGACY_GROUP_EXIT_NOTIFICATION_TEMPLATE = [
|
const LEGACY_GROUP_EXIT_NOTIFICATION_TEMPLATE = [
|
||||||
@@ -142,6 +164,14 @@ export function renderGroupExitMonitorNotification(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 主进程中的辅助函数,供生成通知和预览时共用。 */
|
||||||
|
export function buildMemberLeftNotification(
|
||||||
|
event: GroupExitMonitorEvent,
|
||||||
|
template = GROUP_EXIT_NOTIFICATION_TEMPLATE
|
||||||
|
): string {
|
||||||
|
return renderGroupExitMonitorNotification(event, template)
|
||||||
|
}
|
||||||
|
|
||||||
export interface GroupExitMonitorState {
|
export interface GroupExitMonitorState {
|
||||||
events: GroupExitMonitorEvent[]
|
events: GroupExitMonitorEvent[]
|
||||||
running: boolean
|
running: boolean
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
export type WechatActionOrigin =
|
||||||
|
| 'member_monitor'
|
||||||
|
| 'scheduled_report'
|
||||||
|
| 'user_tts'
|
||||||
|
| 'unknown'
|
||||||
|
| (string & {})
|
||||||
|
|
||||||
|
export type WechatActionPurpose =
|
||||||
|
| 'member_left_notification'
|
||||||
|
| 'scheduled_report'
|
||||||
|
| 'tts_voice'
|
||||||
|
| (string & {})
|
||||||
|
|
||||||
|
export type WechatActionTriggerType = 'automation' | 'user'
|
||||||
|
|
||||||
|
export interface WechatActionRecipient {
|
||||||
|
type: 'group' | 'contact'
|
||||||
|
id: string
|
||||||
|
name?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WechatActionContent =
|
||||||
|
| { type: 'text'; text: string }
|
||||||
|
| { type: 'image'; path: string }
|
||||||
|
| { type: 'voice'; path: string }
|
||||||
|
|
||||||
|
export interface WechatActionRequest {
|
||||||
|
/** 可选的操作编号;未提供时会自动生成。 */
|
||||||
|
id?: string
|
||||||
|
/** 用于避免同一自动操作被重复执行的标识。 */
|
||||||
|
idempotencyKey?: string
|
||||||
|
origin: WechatActionOrigin
|
||||||
|
purpose: WechatActionPurpose
|
||||||
|
triggerType: WechatActionTriggerType
|
||||||
|
sourceId?: string
|
||||||
|
executionId?: string
|
||||||
|
recipient: WechatActionRecipient
|
||||||
|
content: WechatActionContent
|
||||||
|
metadata?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WechatActionStatus = 'sent' | 'blocked' | 'failed'
|
||||||
|
export type WechatActionDecision = 'allow' | 'block'
|
||||||
|
|
||||||
|
export type WechatActionErrorCode =
|
||||||
|
| 'INVALID_REQUEST'
|
||||||
|
| 'INVALID_RECIPIENT'
|
||||||
|
| 'ACTION_NOT_ALLOWED'
|
||||||
|
| 'RECIPIENT_SCOPE_VIOLATION'
|
||||||
|
| 'SEND_CAPABILITY_UNAVAILABLE'
|
||||||
|
| 'SEND_NOT_READY'
|
||||||
|
| 'SEND_FAILED'
|
||||||
|
| 'POLICY_BLOCKED'
|
||||||
|
| 'UNKNOWN'
|
||||||
|
| (string & {})
|
||||||
|
|
||||||
|
export interface WechatActionResult {
|
||||||
|
actionId: string
|
||||||
|
status: WechatActionStatus
|
||||||
|
decision: WechatActionDecision
|
||||||
|
errorCode?: WechatActionErrorCode
|
||||||
|
reason?: string
|
||||||
|
startedAt: string
|
||||||
|
finishedAt: string
|
||||||
|
sendResult?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PolicyDecision {
|
||||||
|
decision: 'allow' | 'block' | 'require_review'
|
||||||
|
source: 'deterministic' | 'ai'
|
||||||
|
reasonCode?: WechatActionErrorCode
|
||||||
|
reason?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WechatActionMemberEventReference {
|
||||||
|
id: string
|
||||||
|
roomId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WechatActionAuditRecord {
|
||||||
|
actionId: string
|
||||||
|
idempotencyKey?: string
|
||||||
|
origin: WechatActionOrigin
|
||||||
|
purpose: WechatActionPurpose
|
||||||
|
triggerType: WechatActionTriggerType
|
||||||
|
sourceId?: string
|
||||||
|
executionId?: string
|
||||||
|
recipientType: WechatActionRecipient['type']
|
||||||
|
recipientId: string
|
||||||
|
contentType: WechatActionContent['type']
|
||||||
|
contentPreview?: string
|
||||||
|
contentHash?: string
|
||||||
|
createdAt: string
|
||||||
|
startedAt: string
|
||||||
|
finishedAt: string
|
||||||
|
decision: WechatActionDecision
|
||||||
|
decisionReason?: string
|
||||||
|
sendStatus: WechatActionStatus
|
||||||
|
errorCode?: WechatActionErrorCode
|
||||||
|
}
|
||||||
@@ -159,4 +159,84 @@ describe('GroupExitMonitorService', () => {
|
|||||||
text: '退群: 另一个人/未设置/wxid_other'
|
text: '退群: 另一个人/未设置/wxid_other'
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('records an exit without creating a send action when notification is disabled', async () => {
|
||||||
|
installGroupDb()
|
||||||
|
mocks.chat.getGroupSnapshotAsync
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
roomId: 'room@chatroom',
|
||||||
|
members: [member, { wxid: 'wxid_other', wechatNickname: '另一个人' }]
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({ roomId: 'room@chatroom', members: [member] })
|
||||||
|
const service = new GroupExitMonitorService()
|
||||||
|
service.setMonitoredRoomIds(['room@chatroom'])
|
||||||
|
await service.start(true)
|
||||||
|
const state = await service.checkNow()
|
||||||
|
|
||||||
|
expect(state.events[0]).toMatchObject({
|
||||||
|
memberWxid: 'wxid_other',
|
||||||
|
notificationStatus: 'not_requested'
|
||||||
|
})
|
||||||
|
expect(mocks.sender.send).not.toHaveBeenCalled()
|
||||||
|
expect(mocks.capability.getPersonalWechatSendCapability).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the member event when the Gateway reports unavailable capability', async () => {
|
||||||
|
installGroupDb()
|
||||||
|
mocks.capability.getPersonalWechatSendCapability.mockResolvedValue({
|
||||||
|
ready: false,
|
||||||
|
supported: true,
|
||||||
|
status: 'needs_binding',
|
||||||
|
capabilities: { text: false, image: false, voice: false },
|
||||||
|
senderStatus: {},
|
||||||
|
message: '当前微信发送能力不可用'
|
||||||
|
})
|
||||||
|
mocks.chat.getGroupSnapshotAsync
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
roomId: 'room@chatroom',
|
||||||
|
members: [member, { wxid: 'wxid_other', wechatNickname: '另一个人' }]
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({ roomId: 'room@chatroom', members: [member] })
|
||||||
|
const service = new GroupExitMonitorService()
|
||||||
|
service.setMonitoredRoomIds(['room@chatroom'], ['room@chatroom'])
|
||||||
|
await service.start(true)
|
||||||
|
const state = await service.checkNow()
|
||||||
|
|
||||||
|
expect(state.events[0]).toMatchObject({
|
||||||
|
notificationStatus: 'failed',
|
||||||
|
notification: {
|
||||||
|
status: 'failed',
|
||||||
|
errorCode: 'SEND_CAPABILITY_UNAVAILABLE'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
expect(mocks.sender.send).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not send twice when the same snapshot diff is checked again', async () => {
|
||||||
|
installGroupDb()
|
||||||
|
mocks.capability.getPersonalWechatSendCapability.mockResolvedValue({
|
||||||
|
ready: true,
|
||||||
|
capabilities: { text: true, image: false, voice: false },
|
||||||
|
supported: true,
|
||||||
|
status: 'ready',
|
||||||
|
senderStatus: {},
|
||||||
|
message: 'ready'
|
||||||
|
})
|
||||||
|
mocks.sender.send.mockResolvedValue({ success: true })
|
||||||
|
mocks.chat.getGroupSnapshotAsync
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
roomId: 'room@chatroom',
|
||||||
|
members: [member, { wxid: 'wxid_other', wechatNickname: '另一个人' }]
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({ roomId: 'room@chatroom', members: [member] })
|
||||||
|
.mockResolvedValueOnce({ roomId: 'room@chatroom', members: [member] })
|
||||||
|
const service = new GroupExitMonitorService()
|
||||||
|
service.setMonitoredRoomIds(['room@chatroom'], ['room@chatroom'])
|
||||||
|
await service.start(true)
|
||||||
|
await service.checkNow()
|
||||||
|
await service.checkNow()
|
||||||
|
|
||||||
|
expect(mocks.sender.send).toHaveBeenCalledOnce()
|
||||||
|
expect(service.getState().events).toHaveLength(1)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -645,6 +645,71 @@ describe('scheduled report scheduling', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('routes scheduled report sends and retries through the Action Gateway adapter', async () => {
|
||||||
|
const storageDir = await mkdtemp(join(tmpdir(), 'tracememo-scheduled-report-gateway-'))
|
||||||
|
const send = vi.fn()
|
||||||
|
const sendAction = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
actionId: 'action-1',
|
||||||
|
status: 'failed' as const,
|
||||||
|
decision: 'allow' as const,
|
||||||
|
errorCode: 'SEND_FAILED' as const,
|
||||||
|
reason: '连接器暂时不可用',
|
||||||
|
startedAt: '2026-08-27T01:00:00.000Z',
|
||||||
|
finishedAt: '2026-08-27T01:00:01.000Z'
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
actionId: 'action-2',
|
||||||
|
status: 'sent' as const,
|
||||||
|
decision: 'allow' as const,
|
||||||
|
startedAt: '2026-08-27T01:01:00.000Z',
|
||||||
|
finishedAt: '2026-08-27T01:01:01.000Z'
|
||||||
|
})
|
||||||
|
const service = new ScheduledReportService({
|
||||||
|
storageDir,
|
||||||
|
...makeDependencies({ send, sendAction })
|
||||||
|
})
|
||||||
|
const created = await service.createTask({
|
||||||
|
name: 'Gateway 日报',
|
||||||
|
group: '研发群',
|
||||||
|
target: '研发群@chatroom',
|
||||||
|
scheduleTime: '09:00'
|
||||||
|
})
|
||||||
|
|
||||||
|
const first = await runScheduled(service, created.data!.id)
|
||||||
|
const recovered = await service.retryScheduledReportSend(first.id)
|
||||||
|
|
||||||
|
expect(first).toMatchObject({
|
||||||
|
status: 'partial_success',
|
||||||
|
pngPath: '/tmp/saved.png',
|
||||||
|
sendTarget: '研发群@chatroom'
|
||||||
|
})
|
||||||
|
expect(recovered.data).toMatchObject({ status: 'success', retryCount: 1 })
|
||||||
|
expect(send).not.toHaveBeenCalled()
|
||||||
|
expect(sendAction).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
expect.objectContaining({
|
||||||
|
target: '研发群@chatroom',
|
||||||
|
filePath: '/tmp/saved.png',
|
||||||
|
triggerType: 'scheduled',
|
||||||
|
executionId: first.id,
|
||||||
|
taskId: created.data!.id
|
||||||
|
})
|
||||||
|
)
|
||||||
|
expect(sendAction).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
expect.objectContaining({
|
||||||
|
target: '研发群@chatroom',
|
||||||
|
filePath: '/tmp/saved.png',
|
||||||
|
triggerType: 'scheduled',
|
||||||
|
executionId: first.id,
|
||||||
|
retryCount: 1,
|
||||||
|
taskId: created.data!.id
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
it('retries an existing PNG without generating the report again', async () => {
|
it('retries an existing PNG without generating the report again', async () => {
|
||||||
const storageDir = await mkdtemp(join(tmpdir(), 'tracememo-scheduled-report-retry-'))
|
const storageDir = await mkdtemp(join(tmpdir(), 'tracememo-scheduled-report-retry-'))
|
||||||
await enableNotifications(storageDir)
|
await enableNotifications(storageDir)
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
import { mkdtempSync, readJsonSync, rmSync } from 'fs-extra'
|
||||||
|
import { tmpdir } from 'os'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
capability: { getPersonalWechatSendCapability: vi.fn() },
|
||||||
|
sender: { send: vi.fn() }
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('electron', () => ({ app: { getPath: () => '/tmp/tracememo-gateway-default' } }))
|
||||||
|
vi.mock('../../src/main/services/personal-wechat-capability-service', () => ({
|
||||||
|
personalWechatCapabilityService: mocks.capability
|
||||||
|
}))
|
||||||
|
vi.mock('../../src/main/services/personal-wechat-send-service', () => ({
|
||||||
|
personalWechatSendService: mocks.sender
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { WechatActionGateway } from '../../src/main/services/wechat-action-gateway'
|
||||||
|
import type { PersonalWechatSendCapability } from '../../src/shared/personal-wechat'
|
||||||
|
import type { WechatActionResult } from '../../src/shared/wechat-action'
|
||||||
|
|
||||||
|
const readyCapability: PersonalWechatSendCapability = {
|
||||||
|
supported: true,
|
||||||
|
ready: true,
|
||||||
|
status: 'ready',
|
||||||
|
capabilities: { text: true, image: true, voice: true },
|
||||||
|
senderStatus: {} as never,
|
||||||
|
message: 'ready'
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('WechatActionGateway', () => {
|
||||||
|
const directories: string[] = []
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mocks.capability.getPersonalWechatSendCapability.mockReset().mockResolvedValue(readyCapability)
|
||||||
|
mocks.sender.send.mockReset().mockResolvedValue({ success: true, status: {} })
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
while (directories.length) rmSync(directories.pop()!, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
function createGateway(): WechatActionGateway {
|
||||||
|
const userData = mkdtempSync(join(tmpdir(), 'tracememo-wechat-action-'))
|
||||||
|
directories.push(userData)
|
||||||
|
return new WechatActionGateway({ getUserDataPath: () => userData })
|
||||||
|
}
|
||||||
|
|
||||||
|
function memberAction(
|
||||||
|
gateway: WechatActionGateway,
|
||||||
|
roomId = 'room@chatroom'
|
||||||
|
): Promise<WechatActionResult> {
|
||||||
|
gateway.registerMemberEvent({ id: 'event-1', roomId: 'room@chatroom' })
|
||||||
|
return gateway.execute({
|
||||||
|
origin: 'member_monitor',
|
||||||
|
purpose: 'member_left_notification',
|
||||||
|
triggerType: 'automation',
|
||||||
|
sourceId: 'event-1',
|
||||||
|
recipient: { type: 'group', id: roomId },
|
||||||
|
content: { type: 'text', text: '张三已退出群聊' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
it('allows a valid member action, sends once, and writes an audit record', async () => {
|
||||||
|
const userData = mkdtempSync(join(tmpdir(), 'tracememo-wechat-action-'))
|
||||||
|
directories.push(userData)
|
||||||
|
const gateway = new WechatActionGateway({ getUserDataPath: () => userData })
|
||||||
|
const result = await memberAction(gateway)
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ status: 'sent', decision: 'allow' })
|
||||||
|
expect(mocks.sender.send).toHaveBeenCalledOnce()
|
||||||
|
expect(mocks.sender.send).toHaveBeenCalledWith({
|
||||||
|
type: 'text',
|
||||||
|
to: 'room@chatroom',
|
||||||
|
isGroup: true,
|
||||||
|
text: '张三已退出群聊'
|
||||||
|
})
|
||||||
|
const audit = readJsonSync(join(userData, 'actions', 'wechat-actions.json'))
|
||||||
|
expect(audit).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
purpose: 'member_left_notification',
|
||||||
|
recipientId: 'room@chatroom',
|
||||||
|
sendStatus: 'sent',
|
||||||
|
decision: 'allow',
|
||||||
|
contentPreview: '张三已退出群聊'
|
||||||
|
})
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('deduplicates the same member event across repeated execution calls', async () => {
|
||||||
|
const gateway = createGateway()
|
||||||
|
const first = await memberAction(gateway)
|
||||||
|
const second = await memberAction(gateway)
|
||||||
|
|
||||||
|
expect(second.actionId).toBe(first.actionId)
|
||||||
|
expect(mocks.sender.send).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('blocks a recipient outside the source group', async () => {
|
||||||
|
const gateway = createGateway()
|
||||||
|
const result = await memberAction(gateway, 'other@chatroom')
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
status: 'blocked',
|
||||||
|
decision: 'block',
|
||||||
|
errorCode: 'RECIPIENT_SCOPE_VIOLATION'
|
||||||
|
})
|
||||||
|
expect(mocks.sender.send).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('blocks unknown automation purposes before checking capability', async () => {
|
||||||
|
const gateway = createGateway()
|
||||||
|
const result = await gateway.execute({
|
||||||
|
origin: 'unknown',
|
||||||
|
purpose: 'arbitrary_message',
|
||||||
|
triggerType: 'automation',
|
||||||
|
recipient: { type: 'group', id: 'room@chatroom' },
|
||||||
|
content: { type: 'text', text: '不应自动发送' }
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
status: 'blocked',
|
||||||
|
decision: 'block',
|
||||||
|
errorCode: 'ACTION_NOT_ALLOWED'
|
||||||
|
})
|
||||||
|
expect(mocks.capability.getPersonalWechatSendCapability).not.toHaveBeenCalled()
|
||||||
|
expect(mocks.sender.send).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns INVALID_REQUEST for malformed input without throwing from audit handling', async () => {
|
||||||
|
const gateway = createGateway()
|
||||||
|
const result = await gateway.execute({
|
||||||
|
origin: 'member_monitor',
|
||||||
|
purpose: 'member_left_notification',
|
||||||
|
triggerType: 'automation',
|
||||||
|
recipient: { type: 'group', id: 'room@chatroom' }
|
||||||
|
} as never)
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
status: 'blocked',
|
||||||
|
decision: 'block',
|
||||||
|
errorCode: 'INVALID_REQUEST'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns INVALID_RECIPIENT when the recipient id is missing', async () => {
|
||||||
|
const gateway = createGateway()
|
||||||
|
const result = await gateway.execute({
|
||||||
|
origin: 'member_monitor',
|
||||||
|
purpose: 'member_left_notification',
|
||||||
|
triggerType: 'automation',
|
||||||
|
sourceId: 'event-1',
|
||||||
|
recipient: { type: 'group', id: '' },
|
||||||
|
content: { type: 'text', text: '张三已退出群聊' }
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
status: 'blocked',
|
||||||
|
decision: 'block',
|
||||||
|
errorCode: 'INVALID_RECIPIENT'
|
||||||
|
})
|
||||||
|
expect(mocks.sender.send).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not accept an event lookup for a different source id', async () => {
|
||||||
|
const userData = mkdtempSync(join(tmpdir(), 'tracememo-wechat-action-'))
|
||||||
|
directories.push(userData)
|
||||||
|
const gateway = new WechatActionGateway({
|
||||||
|
getUserDataPath: () => userData,
|
||||||
|
getMemberEvent: () => ({ id: 'different-event', roomId: 'room@chatroom' })
|
||||||
|
})
|
||||||
|
const result = await gateway.execute({
|
||||||
|
origin: 'member_monitor',
|
||||||
|
purpose: 'member_left_notification',
|
||||||
|
triggerType: 'automation',
|
||||||
|
sourceId: 'event-1',
|
||||||
|
recipient: { type: 'group', id: 'room@chatroom' },
|
||||||
|
content: { type: 'text', text: '张三已退出群聊' }
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
status: 'blocked',
|
||||||
|
decision: 'block',
|
||||||
|
errorCode: 'INVALID_REQUEST'
|
||||||
|
})
|
||||||
|
expect(mocks.sender.send).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns a structured capability failure without sending', async () => {
|
||||||
|
const gateway = createGateway()
|
||||||
|
mocks.capability.getPersonalWechatSendCapability.mockResolvedValueOnce({
|
||||||
|
...readyCapability,
|
||||||
|
ready: false,
|
||||||
|
capabilities: { text: false, image: false, voice: false },
|
||||||
|
message: '当前微信发送能力不可用'
|
||||||
|
})
|
||||||
|
const result = await memberAction(gateway)
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
status: 'failed',
|
||||||
|
decision: 'allow',
|
||||||
|
errorCode: 'SEND_CAPABILITY_UNAVAILABLE'
|
||||||
|
})
|
||||||
|
expect(mocks.sender.send).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('converts a transport throw into SEND_FAILED', async () => {
|
||||||
|
const gateway = createGateway()
|
||||||
|
mocks.sender.send.mockRejectedValueOnce(new Error('connector timeout'))
|
||||||
|
const result = await memberAction(gateway)
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
status: 'failed',
|
||||||
|
decision: 'allow',
|
||||||
|
errorCode: 'SEND_FAILED',
|
||||||
|
reason: 'connector timeout'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user