mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-03 18:33:14 +08:00
1612 lines
53 KiB
JavaScript
1612 lines
53 KiB
JavaScript
/* eslint-disable @typescript-eslint/no-require-imports, @typescript-eslint/explicit-function-return-type */
|
||
const { app, BrowserWindow, ipcMain } = require('electron')
|
||
const fs = require('fs')
|
||
const path = require('path')
|
||
|
||
const root = path.resolve(__dirname, '../../..')
|
||
const fixture = structuredClone(require(path.join(root, 'tests/fixtures/chat-data.json')))
|
||
const userData = process.env.WXE_E2E_USER_DATA
|
||
if (!userData) throw new Error('WXE_E2E_USER_DATA is required')
|
||
const backgroundE2E = process.env.WXE_E2E_HEADLESS === '1'
|
||
app.setPath('userData', userData)
|
||
app.setPath('logs', path.join(userData, 'logs'))
|
||
app.commandLine.appendSwitch('disable-gpu')
|
||
|
||
const VALID_KEY = 'a'.repeat(64)
|
||
const imageData = `data:image/png;base64,${fs.readFileSync(path.join(root, 'resources/icon.png')).toString('base64')}`
|
||
const voiceData = 'UklGRiQAAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQAAAAA='
|
||
const configuredNow = Number(process.env.WXE_E2E_NOW_MS)
|
||
const updateSimulation = process.env.WXE_E2E_UPDATE_SIMULATION === '1'
|
||
const personalWechatSupported = process.platform === 'darwin' || process.platform === 'win32'
|
||
const unsignedMacUpdate = process.env.WXE_E2E_UNSIGNED_MAC_UPDATE === '1'
|
||
const fixtureNowMs =
|
||
Number.isFinite(configuredNow) && configuredNow > 0 ? configuredNow : Date.now()
|
||
|
||
const formatFixtureDateTime = (timestampSeconds) => {
|
||
const date = new Date(timestampSeconds * 1000)
|
||
const pad = (value) => String(value).padStart(2, '0')
|
||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
|
||
}
|
||
|
||
const allFixtureMessages = Object.values(fixture.messages).flat()
|
||
const latestFixtureTime = Math.max(...allFixtureMessages.map((message) => message.createTime || 0))
|
||
const fixtureTimeOffset = Math.floor(fixtureNowMs / 1000) - 3600 - latestFixtureTime
|
||
for (const message of allFixtureMessages) {
|
||
message.createTime = (message.createTime || latestFixtureTime) + fixtureTimeOffset
|
||
message.datetime = formatFixtureDateTime(message.createTime)
|
||
}
|
||
|
||
const emptyTimings = () => ({
|
||
queryUnderstandingMs: 0,
|
||
contactResolutionMs: 0,
|
||
knowledgeSearchMs: 0,
|
||
workerIpcMs: 0,
|
||
workerBootMs: 0,
|
||
dispatchMs: 0,
|
||
workerSqlMs: 0,
|
||
responseSerializeMs: 0,
|
||
responseTransferMs: 0,
|
||
workerQueueMs: 0,
|
||
workerExecutionMs: 0,
|
||
globalCountMs: 0,
|
||
voiceCoverageMs: 0,
|
||
wcdbQueueMs: 0,
|
||
wcdbExecutionMs: 0,
|
||
senderEnrichmentMs: 0,
|
||
ipcMs: 0,
|
||
serializationMs: 0,
|
||
otherMs: 0,
|
||
ftsMs: 0,
|
||
chunkExpandMs: 0,
|
||
messageLoadMs: 0,
|
||
rankingMs: 0,
|
||
candidateRankingMs: 0,
|
||
evidenceBuildMs: 0,
|
||
aggregationMs: 0,
|
||
contextPreparationMs: 0,
|
||
agentDecisionMs: 0,
|
||
agentToolMs: 0,
|
||
aiGenerationMs: 0,
|
||
totalMs: 1
|
||
})
|
||
|
||
const aiSearchResult = (request) => {
|
||
const failure = process.env.WXE_E2E_AI_FAILURE
|
||
const evidence = [
|
||
{
|
||
id: 'E1',
|
||
chunkId: 'fixture-chunk',
|
||
conversationId: 'group-regular-md5',
|
||
conversationName: '产品测试群',
|
||
conversationType: 'group',
|
||
startTime: fixture.messages['group-regular-md5'][0].createTime * 1000,
|
||
endTime: fixture.messages['group-regular-md5'][0].createTime * 1000,
|
||
messageId: 'msg-text',
|
||
senderId: 'wxid_fixture_member',
|
||
sender: '测试成员',
|
||
timestamp: fixture.messages['group-regular-md5'][0].createTime * 1000,
|
||
messageIds: ['msg-text'],
|
||
sourceKind: 'text',
|
||
text: '这是一条脱敏测试消息',
|
||
score: 1
|
||
}
|
||
]
|
||
return {
|
||
requestId: request.requestId,
|
||
status: failure ? 'ai_failed' : 'completed',
|
||
plan: {
|
||
intent: 'general',
|
||
keywords: ['测试'],
|
||
variants: [],
|
||
source: 'local',
|
||
scopeLabel: '全局搜索',
|
||
rangeLabel: '近 30 天',
|
||
timeRange: {
|
||
startTime: Math.floor(Date.now() / 1000) - 30 * 86400,
|
||
endTime: Math.floor(Date.now() / 1000),
|
||
label: '近 30 天',
|
||
reason: 'E2E fixture',
|
||
source: 'ui'
|
||
},
|
||
contactNames: []
|
||
},
|
||
knowledge: {
|
||
source: 'knowledge',
|
||
state: 'ready',
|
||
indexedMessageCount: allFixtureMessages.length,
|
||
indexedChunkCount: 1,
|
||
totalMessages: allFixtureMessages.length,
|
||
voiceCoverage: {
|
||
voiceMessageCount: 1,
|
||
transcribedVoiceCount: 1,
|
||
failedVoiceCount: 0,
|
||
voiceCoverageComplete: true
|
||
}
|
||
},
|
||
candidateEvidenceCount: 1,
|
||
retrieval: {
|
||
intent: 'general',
|
||
timeRange: {
|
||
startTime: Math.floor(Date.now() / 1000) - 30 * 86400,
|
||
endTime: Math.floor(Date.now() / 1000),
|
||
label: '近 30 天',
|
||
reason: 'E2E fixture',
|
||
source: 'ui'
|
||
},
|
||
retrievalMode: 'global_fts',
|
||
candidateCount: 1,
|
||
uniqueCandidateCount: 1,
|
||
sourceMessageCount: allFixtureMessages.length,
|
||
sourceCoverage: 'complete',
|
||
isComplete: true,
|
||
fallbackUsed: false,
|
||
suspicious: false
|
||
},
|
||
evidence,
|
||
contextEvidenceCount: 1,
|
||
aggregation: {
|
||
messageCount: 1,
|
||
peopleCount: 1,
|
||
conversationCount: 1,
|
||
people: [],
|
||
conversations: []
|
||
},
|
||
agent: { mode: 'fallback', toolCalls: 0, trace: [], fallbackReason: 'E2E fixture' },
|
||
citationValidation: { status: 'valid', invalidCitationIds: [] },
|
||
timings: emptyTimings(),
|
||
answer: failure ? undefined : '固定假回答:测试数据中的核心流程正常。',
|
||
ai: {
|
||
providerName: '本地假服务',
|
||
modelName: '固定响应模型',
|
||
inputTokens: 10,
|
||
inputTokensEstimated: false
|
||
},
|
||
error: failure ? `本地假服务错误 ${failure}` : undefined,
|
||
errorStage: failure ? 'ai_generating' : undefined,
|
||
elapsedMs: 1
|
||
}
|
||
}
|
||
const reportJson = JSON.stringify({
|
||
overview: '固定脱敏日报',
|
||
hero: {
|
||
headline: '产品测试群日报',
|
||
summary: '测试消息已完成自动整理。',
|
||
keyTakeaway: '核心流程可用',
|
||
pendingNote: '',
|
||
statusLine: '今日形成 1 个结论'
|
||
},
|
||
topics: [
|
||
{
|
||
title: '自动化测试',
|
||
timeRange: '10:00-10:02',
|
||
heat: '中',
|
||
participants: ['测试成员'],
|
||
summary: '讨论了脱敏自动化测试。',
|
||
conclusions: [{ text: '核心流程可用', sourceMessageIds: ['msg-text'] }],
|
||
keywords: ['测试'],
|
||
sourceMessageIds: ['msg-text']
|
||
}
|
||
],
|
||
resources: [],
|
||
importantMessages: [],
|
||
quotes: [],
|
||
qa: [],
|
||
todos: [],
|
||
unresolved: [],
|
||
storylines: [],
|
||
reversals: [],
|
||
participantChains: [],
|
||
keywords: ['测试']
|
||
})
|
||
|
||
let connected = process.env.WXE_E2E_MODE !== 'disconnected'
|
||
let savedKey = connected ? VALID_KEY : ''
|
||
let settings = {
|
||
dbRoot: 'fixture-account',
|
||
apiEnabled: false,
|
||
apiHost: '127.0.0.1',
|
||
apiPort: 5031,
|
||
imageKeyRoot: 'fixture-account',
|
||
ffmpegPath: '',
|
||
recallProtectionEnabled: false,
|
||
debugEnabled: false,
|
||
autoLogin: connected,
|
||
autoLoginPreferenceSet: true,
|
||
appearanceTheme: process.env.WXE_E2E_APPEARANCE_THEME === 'dark' ? 'dark' : 'light',
|
||
compactMode: false,
|
||
showStartupProgress: false,
|
||
imageXorKey: '0x40',
|
||
imageAesKey: '0123456789abcdef'
|
||
}
|
||
|
||
const extraContacts = Number(process.env.WXE_E2E_LARGE_CONTACTS || 0)
|
||
const contacts = [...fixture.contacts]
|
||
for (let index = 0; index < extraContacts; index += 1) {
|
||
contacts.push({
|
||
m_nsUsrName: `fixture_${index}`,
|
||
m_nsNickName: `性能样本 ${index}`,
|
||
md5: `fixture-contact-${index}`,
|
||
type: index % 5 === 0 ? 'group' : 'user'
|
||
})
|
||
}
|
||
if (process.env.WXE_E2E_REALTIME_REORDER === '1') {
|
||
contacts.push(
|
||
{
|
||
m_nsUsrName: 'realtime_group_b@chatroom',
|
||
m_nsNickName: '自动刷新 B',
|
||
md5: 'realtime-group-b-md5',
|
||
type: 'group'
|
||
},
|
||
{
|
||
m_nsUsrName: 'realtime_group_a@chatroom',
|
||
m_nsNickName: '自动刷新 A',
|
||
md5: 'realtime-group-a-md5',
|
||
type: 'group'
|
||
},
|
||
{
|
||
m_nsUsrName: 'realtime_direct_d',
|
||
m_nsNickName: '自动刷新 D',
|
||
md5: 'realtime-direct-d-md5',
|
||
type: 'user'
|
||
},
|
||
{
|
||
m_nsUsrName: 'realtime_direct_c',
|
||
m_nsNickName: '自动刷新 C',
|
||
md5: 'realtime-direct-c-md5',
|
||
type: 'user'
|
||
}
|
||
)
|
||
}
|
||
|
||
const handlers = new Map()
|
||
const handle = (channel, fn) => {
|
||
handlers.set(channel, fn)
|
||
ipcMain.handle(channel, async (event, ...args) => fn(...args))
|
||
}
|
||
let contactReadCount = 0
|
||
|
||
const groupExitEvent = {
|
||
id: 'fixture-exit-event',
|
||
contactId: 'group-regular-md5',
|
||
roomId: 'group_regular@chatroom',
|
||
groupName: '产品测试群',
|
||
memberWxid: 'wxid_fixture_member',
|
||
memberName: '测试成员',
|
||
wechatName: '测试成员微信名',
|
||
groupRemark: '测试成员群备注',
|
||
contactRemark: '测试成员通讯录备注',
|
||
previousCount: 240,
|
||
currentCount: 239,
|
||
delta: -1,
|
||
message: '测试成员退出了产品测试群',
|
||
detectedAt: fixtureNowMs - 60 * 1000
|
||
}
|
||
let groupExitMonitorState = {
|
||
enabled: connected,
|
||
events: [groupExitEvent],
|
||
running: connected,
|
||
nativeMonitorActive: connected,
|
||
monitoredGroupCount: 2,
|
||
monitorSelectionConfigured: true,
|
||
monitoredRoomIds: ['group_regular@chatroom', 'group_folded@chatroom'],
|
||
lastCheckedAt: fixtureNowMs - 30 * 1000,
|
||
lastReadAt: 0,
|
||
unreadCount: 1
|
||
}
|
||
const cloneGroupExitMonitorState = () => ({
|
||
...groupExitMonitorState,
|
||
events: groupExitMonitorState.events.map((event) => ({ ...event })),
|
||
unreadCount: groupExitMonitorState.events.filter(
|
||
(event) => event.detectedAt > groupExitMonitorState.lastReadAt
|
||
).length
|
||
})
|
||
handle('group-exit-monitor:getState', () => cloneGroupExitMonitorState())
|
||
// 只保存**监控范围**:通知配置已迁到自动化规则。
|
||
handle('group-exit-monitor:setGroups', (roomIds) => {
|
||
const selected = Array.isArray(roomIds)
|
||
? roomIds.filter((roomId) => typeof roomId === 'string' && roomId.endsWith('@chatroom'))
|
||
: []
|
||
groupExitMonitorState = {
|
||
...groupExitMonitorState,
|
||
monitorSelectionConfigured: true,
|
||
monitoredRoomIds: [...new Set(selected)],
|
||
monitoredGroupCount: selected.length,
|
||
lastCheckedAt: fixtureNowMs
|
||
}
|
||
return cloneGroupExitMonitorState()
|
||
})
|
||
handle('group-exit-monitor:checkNow', () => {
|
||
groupExitMonitorState = {
|
||
...groupExitMonitorState,
|
||
lastCheckedAt: fixtureNowMs
|
||
}
|
||
return cloneGroupExitMonitorState()
|
||
})
|
||
handle('group-exit-monitor:clearEvents', () => {
|
||
groupExitMonitorState = {
|
||
...groupExitMonitorState,
|
||
events: [],
|
||
lastReadAt: fixtureNowMs
|
||
}
|
||
return cloneGroupExitMonitorState()
|
||
})
|
||
handle('group-exit-monitor:markRead', (readAt) => {
|
||
const requestedAt = Number(readAt)
|
||
groupExitMonitorState = {
|
||
...groupExitMonitorState,
|
||
lastReadAt: Math.max(
|
||
groupExitMonitorState.lastReadAt,
|
||
Number.isFinite(requestedAt) && requestedAt > 0 ? requestedAt : fixtureNowMs
|
||
)
|
||
}
|
||
return cloneGroupExitMonitorState()
|
||
})
|
||
|
||
// ---- Automation v1(@我生成日报)----
|
||
//
|
||
// 这组桩只服务 UI / 视觉评审:让自动化首页拿到一份确定性的规则与群列表,
|
||
// 而不是整页挂着「自动化接口尚未就绪」。
|
||
// 它**不**参与任何真实发送,也**不**代表自动化功能已迁移。
|
||
const automationGroupOptions = () =>
|
||
contacts
|
||
.filter(
|
||
(contact) =>
|
||
contact.type === 'group' || String(contact.m_nsUsrName || '').endsWith('@chatroom')
|
||
)
|
||
.map((contact) => ({
|
||
id: contact.m_nsUsrName,
|
||
name: contact.m_nsNickName || contact.m_nsUsrName
|
||
}))
|
||
|
||
const builtinDailyReportRule = () => ({
|
||
id: 'builtin-mention-me-daily-report',
|
||
name: '@我生成日报',
|
||
enabled: true,
|
||
ruleType: 'daily_report',
|
||
trigger: 'message',
|
||
scope: 'group',
|
||
conditions: {
|
||
requireMentionMe: true,
|
||
keyword: '日报',
|
||
keywordMatchMode: 'contains',
|
||
conversationIds: [],
|
||
ignoreSelf: true
|
||
},
|
||
actions: [
|
||
{ type: 'replyText', enabled: true, text: '收到,正在生成今日日报' },
|
||
{ type: 'generateReport', enabled: true },
|
||
{ type: 'sendReportImage', enabled: true }
|
||
],
|
||
cooldownSeconds: 60,
|
||
replyDelaySeconds: 2,
|
||
createdAt: fixtureNowMs,
|
||
updatedAt: fixtureNowMs
|
||
})
|
||
|
||
/*
|
||
* ⚠️ 这里的默认值必须与 `src/shared/automation.ts` 的
|
||
* `createDefaultLeaveNotificationRule()` **逐字一致**。
|
||
*
|
||
* 它扮演"一个全新安装的 main 进程":一旦漂移,E2E 与截图验证的就不是真实默认行为。
|
||
* 由 `tests/integration/automation-default-contract.test.ts` 锁住。
|
||
*/
|
||
let leaveNotificationRule = {
|
||
id: 'builtin-leave-notification',
|
||
name: '退群通知',
|
||
enabled: true,
|
||
ruleType: 'leave_notification',
|
||
trigger: 'message',
|
||
scope: 'group',
|
||
conditions: {
|
||
requireMentionMe: false,
|
||
keyword: '',
|
||
keywordMatchMode: 'contains',
|
||
conversationIds: [],
|
||
ignoreSelf: true
|
||
},
|
||
actions: [],
|
||
cooldownSeconds: 0,
|
||
replyDelaySeconds: 2,
|
||
leaveNotification: {
|
||
// 默认目标 = 当前群聊(旧退群监控通知的原始行为)。
|
||
target: { type: 'source_chat' },
|
||
template:
|
||
'[退群监测]\n\n群聊: {groupName}\n\n用户: {user}\n\n群备注: {groupRemark}\n\n微信号: {wxid}\n\n人数: {previousCount} -> {currentCount}\n\n退群时间: {time}'
|
||
},
|
||
createdAt: fixtureNowMs,
|
||
updatedAt: fixtureNowMs
|
||
}
|
||
|
||
/** 「指定好友」候选:个人联系人,排除群 / 公众号 / 文件传输助手 / 自己。 */
|
||
const sendableContactOptions = () =>
|
||
contacts
|
||
.filter(
|
||
(contact) =>
|
||
contact.type !== 'group' &&
|
||
!String(contact.m_nsUsrName || '').endsWith('@chatroom') &&
|
||
!String(contact.m_nsUsrName || '').startsWith('gh_') &&
|
||
contact.m_nsUsrName !== 'filehelper'
|
||
)
|
||
.map((contact) => ({
|
||
id: contact.m_nsUsrName,
|
||
name: contact.m_nsNickName || contact.m_nsUsrName
|
||
}))
|
||
|
||
handle('automation:getStatus', () => ({
|
||
listening: connected,
|
||
listeningDegraded: false,
|
||
todayExecutions: connected ? 3 : 0,
|
||
todaySuccesses: connected ? 3 : 0,
|
||
sendCapability: {
|
||
supported: personalWechatSupported,
|
||
ready: connected,
|
||
canSendText: connected,
|
||
canSendImage: connected,
|
||
message: connected ? '个人微信发送能力已就绪' : '尚未绑定个人微信发送能力'
|
||
}
|
||
}))
|
||
/*
|
||
* 定时日报是 `ruleType === 'scheduled_report'` 的**真规则**:列表由
|
||
* `automation:listRules` 过滤得到,创建 / 更新走 `automation:createRule` /
|
||
* `automation:updateRule`。fixture 必须实现这组通道,否则保存后列表读不回来。
|
||
*/
|
||
const scheduledReportRules = []
|
||
let automationRuleSequence = 0
|
||
// 「微信异常通知」是全局开关,新旧两套通道共用同一份状态。
|
||
let scheduledReportNotificationEnabled = false
|
||
handle('automation:listRules', () => [
|
||
builtinDailyReportRule(),
|
||
structuredClone(leaveNotificationRule),
|
||
...scheduledReportRules.map((rule) => structuredClone(rule))
|
||
])
|
||
handle('automation:createRule', (draft) => {
|
||
automationRuleSequence += 1
|
||
const rule = {
|
||
...draft,
|
||
id: `fixture-scheduled-rule-${automationRuleSequence}`,
|
||
createdAt: fixtureNowMs,
|
||
updatedAt: fixtureNowMs
|
||
}
|
||
scheduledReportRules.push(rule)
|
||
return structuredClone(rule)
|
||
})
|
||
// `updateRule` / `setRuleEnabled` 收的是**单个对象**(`{ id, draft }` / `{ id, enabled }`),
|
||
// 与 `createRule(draft)` / `deleteRule(id)` 的位置参数不同 —— preload 就是这么拼的。
|
||
handle('automation:updateRule', (input) => {
|
||
const { id, draft } = input || {}
|
||
const index = scheduledReportRules.findIndex((rule) => rule.id === id)
|
||
if (index < 0) return null
|
||
scheduledReportRules[index] = {
|
||
...scheduledReportRules[index],
|
||
...(draft || {}),
|
||
id,
|
||
updatedAt: fixtureNowMs
|
||
}
|
||
return structuredClone(scheduledReportRules[index])
|
||
})
|
||
handle('automation:deleteRule', (id) => {
|
||
const index = scheduledReportRules.findIndex((rule) => rule.id === id)
|
||
if (index < 0) return false
|
||
scheduledReportRules.splice(index, 1)
|
||
return true
|
||
})
|
||
handle('automation:setRuleEnabled', (input) => {
|
||
const { id, enabled } = input || {}
|
||
const index = scheduledReportRules.findIndex((rule) => rule.id === id)
|
||
if (index < 0) return null
|
||
scheduledReportRules[index] = {
|
||
...scheduledReportRules[index],
|
||
enabled: enabled === true,
|
||
updatedAt: fixtureNowMs
|
||
}
|
||
return structuredClone(scheduledReportRules[index])
|
||
})
|
||
|
||
/*
|
||
* 微信异常通知(定时日报的全局能力,不是单条规则的配置)。
|
||
*
|
||
* 能力判定与 `ScheduledReportService#checkNotificationCapability` 一致:**Agent Hub 离线
|
||
* 就是不可用**,且要带上那句可操作的原因。fixture 里 Hub 是 offline,所以这里如实回
|
||
* 不可用 —— 不为了让开关点得动而假装就绪。
|
||
*
|
||
* 这四个通道生产环境都有;不注册会被渲染层的降级兜底吞掉,页面看起来"正常",
|
||
* 但测的就不是真实文案了。
|
||
*/
|
||
handle('automation:getScheduledReportNotificationSettings', () => ({
|
||
enabled: scheduledReportNotificationEnabled
|
||
}))
|
||
handle('automation:getScheduledReportNotificationCapability', () =>
|
||
agentHubStatus().hub === 'online'
|
||
? { ready: true, recipient: 'wxid_fixture_self' }
|
||
: {
|
||
ready: false,
|
||
reason: 'agent_hub_offline',
|
||
error: '需要先连接 Agent Hub 微信机器人,才能接收异常通知。'
|
||
}
|
||
)
|
||
handle('automation:setScheduledReportNotificationEnabled', (enabled) => {
|
||
if (enabled && agentHubStatus().hub !== 'online') {
|
||
scheduledReportNotificationEnabled = false
|
||
return {
|
||
success: false,
|
||
data: { enabled: false },
|
||
reason: 'agent_hub_offline',
|
||
error: '需要先连接 Agent Hub 微信机器人,才能接收异常通知。'
|
||
}
|
||
}
|
||
scheduledReportNotificationEnabled = Boolean(enabled)
|
||
return { success: true, data: { enabled: scheduledReportNotificationEnabled } }
|
||
})
|
||
handle('automation:testScheduledReportErrorNotification', (ruleId) =>
|
||
scheduledReportRules.some((rule) => rule.id === ruleId)
|
||
? {
|
||
success: true,
|
||
data: { ruleId, notificationId: 'fixture-notification', sentAt: fixtureNowMs }
|
||
}
|
||
: { success: false, error: '未找到这条定时日报规则' }
|
||
)
|
||
handle('automation:listGroups', () => automationGroupOptions())
|
||
handle('automation:listExecutions', () => [])
|
||
handle('automation:clearExecutions', () => true)
|
||
handle('automation:listSendableContacts', () => sendableContactOptions())
|
||
handle('automation:saveLeaveNotificationRule', (draft) => {
|
||
leaveNotificationRule = {
|
||
...leaveNotificationRule,
|
||
...(draft && typeof draft === 'object' ? draft : {}),
|
||
id: 'builtin-leave-notification',
|
||
ruleType: 'leave_notification',
|
||
updatedAt: fixtureNowMs
|
||
}
|
||
if (leaveNotificationRule.leaveNotification) {
|
||
const { targetNeedsReview, ...rest } = leaveNotificationRule.leaveNotification
|
||
void targetNeedsReview
|
||
leaveNotificationRule.leaveNotification = rest
|
||
}
|
||
return structuredClone(leaveNotificationRule)
|
||
})
|
||
|
||
/*
|
||
* 定时日报任务。
|
||
*
|
||
* 默认**为空**(既有 E2E 依赖这个初始状态)。
|
||
* `WXE_E2E_SCHEDULED_FIXTURE=1` 启动时注入三条任务,
|
||
* 让「自动化 → 定时日报」的列表与卡片走**真实读取路径**,而不是在渲染层塞假数据。
|
||
*/
|
||
const scheduledReportTasks =
|
||
process.env.WXE_E2E_SCHEDULED_FIXTURE === '1'
|
||
? [
|
||
{
|
||
id: 'fixture-scheduled-1',
|
||
name: 'TraceMemo 每日晚报',
|
||
group: 'TraceMemo 交流群',
|
||
scheduleTime: '18:21',
|
||
reportRange: 'today',
|
||
target: 'TraceMemo 管理群',
|
||
enabled: true,
|
||
createdAt: new Date(fixtureNowMs - 86_400_000).toISOString(),
|
||
updatedAt: new Date(fixtureNowMs - 86_400_000).toISOString(),
|
||
lastRunAt: new Date(fixtureNowMs - 86_400_000).toISOString(),
|
||
nextRunAt: new Date(fixtureNowMs + 3_600_000).toISOString()
|
||
},
|
||
{
|
||
id: 'fixture-scheduled-2',
|
||
name: '技术交流群日报',
|
||
group: '技术交流群',
|
||
scheduleTime: '09:00',
|
||
reportRange: 'yesterday',
|
||
target: '技术交流群',
|
||
enabled: true,
|
||
createdAt: new Date(fixtureNowMs - 172_800_000).toISOString(),
|
||
updatedAt: new Date(fixtureNowMs - 172_800_000).toISOString(),
|
||
nextRunAt: new Date(fixtureNowMs + 64_800_000).toISOString()
|
||
},
|
||
{
|
||
id: 'fixture-scheduled-3',
|
||
name: 'AI 编程讨论群日报',
|
||
group: 'AI 编程讨论群',
|
||
scheduleTime: '21:30',
|
||
reportRange: 'recent24h',
|
||
target: 'TechMemo 归档群',
|
||
enabled: false,
|
||
createdAt: new Date(fixtureNowMs - 259_200_000).toISOString(),
|
||
updatedAt: new Date(fixtureNowMs - 259_200_000).toISOString(),
|
||
nextRunAt: new Date(fixtureNowMs + 86_400_000).toISOString()
|
||
}
|
||
]
|
||
: []
|
||
const scheduledReportExecutions = []
|
||
const generatedReports = []
|
||
const scheduledReportNextRun = (scheduleTime) => {
|
||
const [hours, minutes] = String(scheduleTime || '09:00')
|
||
.split(':')
|
||
.map(Number)
|
||
const next = new Date(fixtureNowMs)
|
||
next.setHours(Number.isFinite(hours) ? hours : 9, Number.isFinite(minutes) ? minutes : 0, 0, 0)
|
||
if (next.getTime() <= fixtureNowMs) next.setDate(next.getDate() + 1)
|
||
return next.toISOString()
|
||
}
|
||
|
||
const startupCache = () => ({
|
||
self: fixture.self,
|
||
contacts,
|
||
updatedAt: Date.now()
|
||
})
|
||
|
||
handle('settings:get', () => ({ settings, settingsPath: path.join(userData, 'settings.json') }))
|
||
handle('settings:set', (patch) => {
|
||
settings = { ...settings, ...patch }
|
||
return { settings, settingsPath: path.join(userData, 'settings.json') }
|
||
})
|
||
handle('tts:getSettings', () => ({
|
||
success: true,
|
||
settings: {
|
||
provider: 'fish-audio',
|
||
hasApiKey: false,
|
||
hasStoredApiKey: false,
|
||
hasEnvironmentApiKey: false,
|
||
keySource: 'missing',
|
||
encryptionAvailable: true,
|
||
selectedVoiceId: '',
|
||
outputFormat: 'mp3',
|
||
model: 's2.1-pro-free',
|
||
phase: 'ready'
|
||
},
|
||
voices: []
|
||
}))
|
||
handle('wechat-personal:getStatus', () => ({
|
||
state: 'online',
|
||
platform: process.platform,
|
||
arch: process.arch,
|
||
sipDisabled: true,
|
||
wechatRunning: true,
|
||
wechatPid: 4668,
|
||
boundWechatPid: 4668,
|
||
endpoint: '127.0.0.1:4290',
|
||
endpointReady: true,
|
||
wechatVersion: '4.1.11.53',
|
||
runtimeReady: true,
|
||
attachReady: true,
|
||
baseAddress: '0x114ef8000',
|
||
baseAddressReady: true,
|
||
textHookInstalled: true,
|
||
textHookReady: true,
|
||
imageHookInstalled: true,
|
||
imageHookReady: true,
|
||
messageListenerReady: true,
|
||
canSend: true,
|
||
canSendText: true,
|
||
canSendImage: true,
|
||
canSendVoice: true,
|
||
message: '个人微信已绑定'
|
||
}))
|
||
handle('wechat-personal:getSendCapability', () => ({
|
||
supported: personalWechatSupported,
|
||
ready: personalWechatSupported,
|
||
status: personalWechatSupported ? 'ready' : 'unsupported',
|
||
capabilities: {
|
||
text: personalWechatSupported,
|
||
image: personalWechatSupported,
|
||
voice: personalWechatSupported
|
||
},
|
||
senderStatus: {
|
||
state: personalWechatSupported ? 'online' : 'unsupported_platform',
|
||
platform: process.platform,
|
||
arch: process.arch,
|
||
sipDisabled: true,
|
||
wechatRunning: true,
|
||
boundWechatPid: 4668,
|
||
endpoint: '127.0.0.1:4290',
|
||
endpointReady: true,
|
||
runtimeReady: true,
|
||
attachReady: true,
|
||
baseAddressReady: true,
|
||
textHookInstalled: true,
|
||
textHookReady: true,
|
||
imageHookInstalled: true,
|
||
imageHookReady: true,
|
||
messageListenerReady: true,
|
||
canSend: personalWechatSupported,
|
||
canSendText: personalWechatSupported,
|
||
canSendImage: personalWechatSupported,
|
||
canSendVoice: personalWechatSupported,
|
||
message: '个人微信已绑定'
|
||
},
|
||
message: personalWechatSupported
|
||
? '个人微信已准备好发送日报'
|
||
: '微信消息发送目前仅支持 macOS 和 Windows'
|
||
}))
|
||
handle('scheduled-report:list', () => [...scheduledReportTasks])
|
||
handle('scheduled-report:listExecutions', (taskId) =>
|
||
taskId
|
||
? scheduledReportExecutions.filter((execution) => execution.taskId === taskId)
|
||
: [...scheduledReportExecutions]
|
||
)
|
||
handle('scheduled-report:getNotificationSettings', () => ({
|
||
enabled: scheduledReportNotificationEnabled
|
||
}))
|
||
handle('scheduled-report:setNotificationEnabled', (enabled) => {
|
||
if (enabled) {
|
||
return {
|
||
success: false,
|
||
data: { enabled: false },
|
||
reason: 'agent_hub_offline',
|
||
error: '需要先连接 Agent Hub 微信机器人,才能接收异常通知。'
|
||
}
|
||
}
|
||
scheduledReportNotificationEnabled = false
|
||
return { success: true, data: { enabled: false } }
|
||
})
|
||
handle('scheduled-report:create', (request) => {
|
||
const now = new Date(fixtureNowMs).toISOString()
|
||
const task = {
|
||
id: `fixture-scheduled-report-${scheduledReportTasks.length + 1}`,
|
||
name: request.name,
|
||
group: request.group,
|
||
scheduleTime: request.scheduleTime,
|
||
reportRange: request.reportRange || 'yesterday',
|
||
messageTypes: request.messageTypes,
|
||
templateId: request.templateId,
|
||
memberNameMode: request.memberNameMode,
|
||
timeoutSeconds: request.timeoutSeconds,
|
||
target: request.target || request.group,
|
||
enabled: request.enabled !== false,
|
||
createdAt: now,
|
||
updatedAt: now,
|
||
nextRunAt: scheduledReportNextRun(request.scheduleTime)
|
||
}
|
||
scheduledReportTasks.push(task)
|
||
return { success: true, data: task }
|
||
})
|
||
handle('scheduled-report:update', (taskId, request) => {
|
||
const index = scheduledReportTasks.findIndex((task) => task.id === taskId)
|
||
if (index < 0) return { success: false, error: '任务不存在' }
|
||
const current = scheduledReportTasks[index]
|
||
const next = { ...current, ...request, updatedAt: new Date(fixtureNowMs).toISOString() }
|
||
if (request.scheduleTime) next.nextRunAt = scheduledReportNextRun(request.scheduleTime)
|
||
scheduledReportTasks[index] = next
|
||
return { success: true, data: next }
|
||
})
|
||
handle('scheduled-report:delete', (taskId) => {
|
||
const index = scheduledReportTasks.findIndex((task) => task.id === taskId)
|
||
if (index < 0) return { success: false, error: '任务不存在' }
|
||
scheduledReportTasks.splice(index, 1)
|
||
return { success: true, data: { deletedId: taskId } }
|
||
})
|
||
handle('scheduled-report:setEnabled', (taskId, enabled) => {
|
||
const task = scheduledReportTasks.find((item) => item.id === taskId)
|
||
if (!task) return { success: false, error: '任务不存在' }
|
||
task.enabled = Boolean(enabled)
|
||
task.updatedAt = new Date(fixtureNowMs).toISOString()
|
||
task.nextRunAt = task.enabled ? scheduledReportNextRun(task.scheduleTime) : task.nextRunAt
|
||
return { success: true, data: task }
|
||
})
|
||
handle('scheduled-report:runNow', (taskId) => {
|
||
const task = scheduledReportTasks.find((item) => item.id === taskId)
|
||
if (!task) return { success: false, error: '任务不存在' }
|
||
const pngPath = path.join(
|
||
userData,
|
||
`fixture-scheduled-report-${scheduledReportExecutions.length + 1}.png`
|
||
)
|
||
fs.writeFileSync(pngPath, Buffer.from(imageData.split(',')[1], 'base64'))
|
||
const execution = {
|
||
id: `fixture-execution-${scheduledReportExecutions.length + 1}`,
|
||
taskId,
|
||
triggerType: 'manual',
|
||
startedAt: new Date(fixtureNowMs).toISOString(),
|
||
finishedAt: new Date(fixtureNowMs).toISOString(),
|
||
status: 'success',
|
||
currentStage: 'send',
|
||
scheduledSlot: new Date(fixtureNowMs).toISOString().slice(0, 10),
|
||
retryCount: 0,
|
||
reportId: `fixture-report-record-${scheduledReportExecutions.length + 1}`,
|
||
pngPath,
|
||
sendTarget: task.target,
|
||
sendStatus: 'success',
|
||
notificationStatus: 'not_needed',
|
||
message: '日报生成成功,微信发送成功'
|
||
}
|
||
scheduledReportExecutions.push(execution)
|
||
task.lastRunAt = execution.finishedAt
|
||
return { success: true, data: execution }
|
||
})
|
||
handle('scheduled-report:retrySend', (executionId) => {
|
||
const execution = scheduledReportExecutions.find((item) => item.id === executionId)
|
||
if (!execution) return { success: false, error: '执行记录不存在' }
|
||
execution.status = 'success'
|
||
execution.currentStage = 'send'
|
||
execution.finishedAt = new Date(fixtureNowMs).toISOString()
|
||
execution.retryCount = Number(execution.retryCount || 0) + 1
|
||
execution.sendStatus = 'success'
|
||
execution.message = '日报生成成功,微信发送成功'
|
||
return { success: true, data: execution }
|
||
})
|
||
handle('scheduled-report:testErrorNotification', (taskId) => {
|
||
const task = scheduledReportTasks.find((item) => item.id === taskId)
|
||
if (!task) return { success: false, error: '任务不存在' }
|
||
const now = new Date(fixtureNowMs).toISOString()
|
||
const execution = {
|
||
id: `fixture-debug-execution-${scheduledReportExecutions.length + 1}`,
|
||
taskId,
|
||
triggerType: 'scheduled',
|
||
startedAt: now,
|
||
finishedAt: now,
|
||
status: 'failed',
|
||
currentStage: 'notify',
|
||
failedStage: 'report',
|
||
errorCode: 'DEBUG_TEST_NOTIFICATION',
|
||
technicalMessage: '这是调试用的模拟错误信息;本次没有调用 AI。',
|
||
userTitle: '定时日报错误通知测试',
|
||
userMessage: '这是一条调试用的模拟错误通知,用于验证 Agent Hub 推送链路。',
|
||
suggestedAction: '确认微信中是否收到这条测试通知。',
|
||
retryable: false,
|
||
retryCount: 0,
|
||
sendStatus: 'unavailable',
|
||
notificationStatus: 'sent'
|
||
}
|
||
scheduledReportExecutions.unshift(execution)
|
||
return { success: true, data: execution }
|
||
})
|
||
handle('wechat-share:getConfig', () => ({
|
||
success: true,
|
||
configured: true,
|
||
serviceUrl: 'https://share.example.test'
|
||
}))
|
||
handle('key:getSavedDbKey', () => ({
|
||
success: true,
|
||
key: savedKey || undefined,
|
||
saved: Boolean(savedKey),
|
||
encryptionAvailable: true
|
||
}))
|
||
handle('key:saveDbKey', (_accountRoot, key) => {
|
||
savedKey = String(key || '')
|
||
return { success: true, key: savedKey, saved: true, encryptionAvailable: true }
|
||
})
|
||
handle('key:clearSavedDbKey', () => {
|
||
savedKey = ''
|
||
return { success: true }
|
||
})
|
||
handle('key:getEnvironment', () => ({
|
||
platform: process.platform,
|
||
osVersion: process.platform === 'win32' ? 'Windows fixture' : 'macOS fixture',
|
||
appVersion: 'v2.2.0',
|
||
wechatVersion: '4.1.9.57',
|
||
dataStructureVersion: settings.dbRoot === 'fixture-account' ? '微信 4.x(WCDB)' : '未检测到',
|
||
dataDirectoryDetected: settings.dbRoot === 'fixture-account',
|
||
diagnosticSummary: 'TraceMemo: v2.2.0\n数据目录: 已检测到',
|
||
autoDetectSupported: true,
|
||
wechatRunning: true,
|
||
accountIdentified: connected,
|
||
dbConnected: connected,
|
||
encryptionAvailable: true
|
||
}))
|
||
handle('key:readClipboardDbKey', () => ({ success: true, value: VALID_KEY }))
|
||
handle('key:pasteAndSaveDbKey', () => ({ success: true, key: VALID_KEY }))
|
||
handle('key:autoGetDbKey', () => ({ success: true, key: VALID_KEY, saved: false }))
|
||
handle('key:autoGetImageKey', () => ({
|
||
success: true,
|
||
xorKey: 64,
|
||
aesKey: '0123456789abcdef',
|
||
verified: true
|
||
}))
|
||
|
||
handle('db:init', (key, accountRoot) => {
|
||
if (settings.dbRoot === 'Z:\\missing-wechat-data') {
|
||
connected = false
|
||
return {
|
||
success: false,
|
||
code: 'ROOT_UNAVAILABLE',
|
||
error: '微信数据目录不存在,请重新选择目录',
|
||
monitoring: false
|
||
}
|
||
}
|
||
if (key !== VALID_KEY) {
|
||
connected = false
|
||
return { success: false, error: '数据库密钥无效', monitoring: false }
|
||
}
|
||
connected = true
|
||
settings.dbRoot = accountRoot || settings.dbRoot
|
||
return { success: true, monitoring: true }
|
||
})
|
||
handle('db:testConnection', (key) =>
|
||
key === VALID_KEY
|
||
? { success: true, wxid: fixture.self.wxid, accountRoot: fixture.self.accountRoot }
|
||
: { success: false, code: 'DATABASE_OPEN_FAILED', error: '数据库密钥无效' }
|
||
)
|
||
handle('db:disconnect', () => {
|
||
connected = false
|
||
return { success: true }
|
||
})
|
||
handle('db:getStartupCache', () =>
|
||
process.env.WXE_E2E_CORRUPT_CACHE === '1' ? null : startupCache()
|
||
)
|
||
handle('db:getBootstrapCache', () =>
|
||
process.env.WXE_E2E_CORRUPT_CACHE === '1' ? null : startupCache()
|
||
)
|
||
handle('db:getContacts', (filter) => {
|
||
contactReadCount += 1
|
||
const query = String(filter || '').toLowerCase()
|
||
return query
|
||
? contacts.filter((contact) => contact.m_nsNickName.toLowerCase().includes(query))
|
||
: contacts
|
||
})
|
||
handle('test:getContactReadCount', () => contactReadCount)
|
||
handle('db:getContactAvatars', (usernames) =>
|
||
Object.fromEntries(
|
||
contacts
|
||
.filter((contact) => usernames.includes(contact.m_nsUsrName) && contact.avatar)
|
||
.map((contact) => [contact.m_nsUsrName, contact.avatar])
|
||
)
|
||
)
|
||
handle('wechat-action-log:list', () => [
|
||
{
|
||
id: 'fixture-action-log',
|
||
category: 'wechat_send',
|
||
source: 'scheduled_report',
|
||
purpose: 'scheduled_report',
|
||
triggerType: 'automation',
|
||
timestamp: new Date(fixtureNowMs - 60_000).toISOString(),
|
||
recipientType: 'group',
|
||
recipientId: 'group_regular@chatroom',
|
||
recipientName: '产品测试群',
|
||
contentType: 'image',
|
||
contentPreview: 'fixture-report.png',
|
||
status: 'sent',
|
||
executionId: 'fixture-execution',
|
||
idempotencyKey: 'scheduled_report:fixture-execution'
|
||
}
|
||
])
|
||
handle('settings:getSelf', () => ({ ready: true, info: fixture.self }))
|
||
handle('db:getCachedMessages', (md5) => fixture.messages[md5] || [])
|
||
handle('db:getCachedMessagePage', (md5) => ({
|
||
hit: true,
|
||
messages: fixture.messages[md5] || [],
|
||
groupSnapshot: null
|
||
}))
|
||
handle('db:getMessages', (md5, startTime, endTime, options) => {
|
||
let messages = fixture.messages[md5] || []
|
||
if (startTime) messages = messages.filter((message) => (message.createTime || 0) >= startTime)
|
||
if (endTime) messages = messages.filter((message) => (message.createTime || 0) <= endTime)
|
||
if (options && options.limit) messages = messages.slice(-options.limit)
|
||
return messages
|
||
})
|
||
handle('test:messageChange', (payload = {}) => {
|
||
const md5 = String(payload.md5 || '')
|
||
const message = payload.message
|
||
if (md5 && message && Array.isArray(fixture.messages[md5])) {
|
||
fixture.messages[md5].push(message)
|
||
}
|
||
const reorderContactMd5 = String(payload.reorderContactMd5 || '')
|
||
if (reorderContactMd5) {
|
||
const index = contacts.findIndex((contact) => contact.md5 === reorderContactMd5)
|
||
if (index >= 0) {
|
||
const [contact] = contacts.splice(index, 1)
|
||
contacts.unshift(contact)
|
||
}
|
||
}
|
||
const event = payload.event || { db: 'message_0.db', table: 'message', action: 'update' }
|
||
const json = typeof event === 'string' ? event : JSON.stringify(event)
|
||
for (const window of BrowserWindow.getAllWindows()) {
|
||
if (!window.isDestroyed()) window.webContents.send('wcdb-change', { type: 'update', json })
|
||
}
|
||
return { success: true }
|
||
})
|
||
handle('db:getGroupSnapshot', (md5) =>
|
||
md5.startsWith('group-')
|
||
? {
|
||
roomId: md5,
|
||
memberCount: 1,
|
||
members: [
|
||
{
|
||
wxid: 'wxid_fixture_member',
|
||
nickname: '测试成员',
|
||
groupNickname: '测试成员',
|
||
wechatNickname: '测试成员',
|
||
remark: '',
|
||
avatar: ''
|
||
}
|
||
]
|
||
}
|
||
: null
|
||
)
|
||
handle('db:getImage', (md5, datName, sessionId, options) =>
|
||
md5 === 'unsupported'
|
||
? { success: false, error: '不支持的 DAT 版本' }
|
||
: {
|
||
success: true,
|
||
data: imageData,
|
||
isThumb: !options?.force,
|
||
filePath: path.join(userData, options?.force ? 'original.png' : 'thumbnail.png')
|
||
}
|
||
)
|
||
handle('db:getVoiceData', () => ({ success: true, data: voiceData }))
|
||
const voiceModelStatus = (state = 'missing') => ({
|
||
modelId: 'sensevoice-small-int8',
|
||
version: '2024-07-17',
|
||
state,
|
||
downloadedBytes: state === 'ready' ? 239549735 : 0,
|
||
totalBytes: 239549735,
|
||
progress: state === 'ready' ? 1 : 0,
|
||
platform: process.platform,
|
||
architecture: process.arch,
|
||
supported: process.platform === 'win32' || process.platform === 'darwin'
|
||
})
|
||
handle('voice:getModelStatus', () => voiceModelStatus())
|
||
handle('voice:downloadModel', () => ({ success: true, status: voiceModelStatus('ready') }))
|
||
handle('voice:cancelModelDownload', () => ({ success: true }))
|
||
handle('voice:removeModel', () => voiceModelStatus())
|
||
handle('voice:openModelDirectory', () => ({ success: true }))
|
||
handle('voice:getBatchProgress', () => ({
|
||
accountIdentity: 'fixture-account',
|
||
state: 'idle',
|
||
total: 0,
|
||
processed: 0,
|
||
cached: 0,
|
||
succeeded: 0,
|
||
failed: 0,
|
||
elapsedMs: 0,
|
||
estimatedRemainingMs: null
|
||
}))
|
||
handle('voice:getBatchConversationSummaries', (request) =>
|
||
request.conversationIds.map((conversationId, index) => ({
|
||
conversationId,
|
||
voiceMessageCount: index + 3
|
||
}))
|
||
)
|
||
handle('voice:getBatchPreflight', (request) => ({
|
||
accountIdentity: 'fixture-account',
|
||
conversationCount: request.conversationIds.length,
|
||
voiceMessageCount: request.conversationIds.length * 3,
|
||
cachedCount: 0,
|
||
pendingCount: request.conversationIds.length * 3,
|
||
failedCount: 0,
|
||
estimatedDurationMs: null,
|
||
modelReady: false
|
||
}))
|
||
handle('voice:recognize', () => ({ success: true, transcript: '固定脱敏转写文本', language: 'zh' }))
|
||
handle('voice:cancelRecognition', () => ({ success: true }))
|
||
handle('db:getSticker', (url) =>
|
||
String(url || '').includes('403')
|
||
? {
|
||
success: false,
|
||
error: '表情链接已失效或需要微信授权',
|
||
failureCode: 'access_denied',
|
||
httpStatus: 403
|
||
}
|
||
: { success: true, data: imageData }
|
||
)
|
||
handle('db:parseMessage', (content, messageType) =>
|
||
messageType === 1
|
||
? { type: 'text', content: String(content) }
|
||
: { type: 'unknown', raw: String(content), messageType }
|
||
)
|
||
|
||
handle('ai:getRuntimeConfig', () => ({
|
||
providerId: 'fixture-provider',
|
||
providerName: '本地假服务',
|
||
model: 'fixture-model',
|
||
modelName: '固定响应模型',
|
||
configured: true,
|
||
status: 'connected',
|
||
timeoutMs: 5000
|
||
}))
|
||
handle('ai:getVisionRuntimeConfig', () => ({
|
||
providerId: 'fixture-vision-provider',
|
||
providerName: '本地图片假服务',
|
||
model: 'fixture-vision-model',
|
||
modelName: '固定图片识别模型',
|
||
configured: true,
|
||
status: 'connected',
|
||
timeoutMs: 5000,
|
||
source: 'vision-capability'
|
||
}))
|
||
handle('ai:listProviders', () => ({
|
||
success: true,
|
||
providers: [
|
||
{
|
||
id: 'fixture-provider',
|
||
name: '本地假服务',
|
||
type: 'openai-compatible',
|
||
baseUrl: 'http://127.0.0.1:1/v1',
|
||
auth: { type: 'none' },
|
||
models: [
|
||
{
|
||
id: 'fixture-model',
|
||
name: '固定响应模型',
|
||
capabilities: { chat: true, vision: false, ocr: false, longContext: true }
|
||
},
|
||
{
|
||
id: 'fixture-vision-model',
|
||
name: '固定图片识别模型',
|
||
capabilities: { chat: true, vision: true, ocr: true, longContext: true }
|
||
}
|
||
],
|
||
defaultModel: 'fixture-model',
|
||
advanced: { timeoutMs: 5000, extraHeaders: {} },
|
||
hasApiKey: true,
|
||
isDefault: true,
|
||
status: 'connected'
|
||
}
|
||
],
|
||
defaultProviderId: 'fixture-provider'
|
||
}))
|
||
handle('ai:migrateLegacy', () => ({ success: true, providers: [] }))
|
||
handle('ai:chat', (messages) => {
|
||
const failure = process.env.WXE_E2E_AI_FAILURE
|
||
if (failure) return { success: false, error: `本地假服务错误 ${failure}` }
|
||
const system = String(messages?.[0]?.content || '')
|
||
if (system.includes('本地聊天检索规划器')) {
|
||
return {
|
||
success: true,
|
||
data: '{"intent":"general","keywords":["测试"],"variants":[]}'
|
||
}
|
||
}
|
||
if (system.includes('微信群聊日报编辑') || system.includes('JSON 格式修复器')) {
|
||
return { success: true, data: reportJson, usage: { input: 10, output: 20, total: 30 } }
|
||
}
|
||
return { success: true, data: '固定假回答:测试数据中的核心流程正常。' }
|
||
})
|
||
handle('knowledge:getStatus', () => ({
|
||
accountId: fixture.self.wxid,
|
||
state: 'ready',
|
||
indexedMessageCount: allFixtureMessages.length,
|
||
indexedChunkCount: 1,
|
||
sourceMessageCount: allFixtureMessages.length,
|
||
processedMessages: allFixtureMessages.length,
|
||
totalMessages: allFixtureMessages.length,
|
||
estimatedRemainingMs: 0,
|
||
databaseBytes: 1024,
|
||
walBytes: 0,
|
||
shmBytes: 0
|
||
}))
|
||
handle('knowledge:startIndex', () => ({ success: true }))
|
||
handle('knowledge:search', () => ({
|
||
source: 'knowledge',
|
||
state: 'ready',
|
||
evidence: [],
|
||
indexedMessageCount: allFixtureMessages.length,
|
||
indexedChunkCount: 1,
|
||
totalMessages: allFixtureMessages.length,
|
||
timings: {
|
||
workerIpcMs: 0,
|
||
workerBootMs: 0,
|
||
dispatchMs: 0,
|
||
workerSqlMs: 0,
|
||
responseTransferMs: 0,
|
||
responseSerializeMs: 0,
|
||
ftsMs: 0,
|
||
messageLoadMs: 0,
|
||
chunkExpandMs: 0,
|
||
rankingMs: 0,
|
||
totalMs: 0
|
||
}
|
||
}))
|
||
handle('ai-search:getProviderStatus', () => ({ configured: true, requiresConsent: false }))
|
||
handle('ai-search:authorizeExternalProvider', () => ({ success: true }))
|
||
handle('ai-search:run', (request) => aiSearchResult(request))
|
||
handle('ai-search:cancel', () => ({ cancelled: true }))
|
||
|
||
handle('report:export', () => {
|
||
const htmlPath = path.join(userData, 'fixture-report.html')
|
||
const pngPath = path.join(userData, 'fixture-report.png')
|
||
fs.writeFileSync(htmlPath, '<!doctype html><h1>固定脱敏日报</h1>', 'utf8')
|
||
fs.writeFileSync(pngPath, Buffer.from(imageData.split(',')[1], 'base64'))
|
||
return { success: true, imageDataUrl: imageData, htmlPath, pngPath }
|
||
})
|
||
handle('report:exportSnapshot', () => {
|
||
const htmlPath = path.join(userData, 'fixture-report-snapshot.html')
|
||
const pngPath = path.join(userData, 'fixture-report-snapshot.png')
|
||
fs.writeFileSync(htmlPath, '<!doctype html><h1>固定脱敏模板快照日报</h1>', 'utf8')
|
||
fs.writeFileSync(pngPath, Buffer.from(imageData.split(',')[1], 'base64'))
|
||
return { success: true, imageDataUrl: imageData, htmlPath, pngPath }
|
||
})
|
||
handle('report:prepareTemplateSwitch', () => ({
|
||
success: true,
|
||
snapshot: {
|
||
groupName: '固定脱敏群',
|
||
reportDate: '2026-08-12',
|
||
values: { REPORT_TITLE: '固定脱敏群日报' }
|
||
}
|
||
}))
|
||
handle('report:listGenerated', () => ({ success: true, reports: [...generatedReports] }))
|
||
handle('report:saveGenerated', (request) => {
|
||
const record = {
|
||
id: 'fixture-report-record',
|
||
...request,
|
||
htmlStatus: request.htmlPath ? 'ready' : 'missing',
|
||
pngStatus: request.pngPath ? 'ready' : 'missing'
|
||
}
|
||
const index = generatedReports.findIndex((report) => report.id === record.id)
|
||
if (index >= 0) generatedReports[index] = record
|
||
else generatedReports.unshift(record)
|
||
return { success: true, record }
|
||
})
|
||
handle('report:updateGeneratedTemplate', (request) => ({
|
||
success: true,
|
||
record: { id: request.reportId, templateId: request.templateId }
|
||
}))
|
||
handle('report:deleteGenerated', () => ({ success: true }))
|
||
handle('report:reveal', () => ({ success: true }))
|
||
handle('copy-image', () => ({ success: true }))
|
||
// 社区模板市场:默认关闭(返回空目录),测试用例可通过 WXE_E2E_TEMPLATE_MARKET=fixture 打开合成目录。
|
||
const templateMarketEnabled = process.env.WXE_E2E_TEMPLATE_MARKET === 'fixture'
|
||
const templateMarketEntry = {
|
||
id: 'community.github.example.neonboard',
|
||
version: '1.0.2',
|
||
interfaceVersion: '1',
|
||
name: '霓光指挥日报',
|
||
description: '霓光主题的桌面指挥日报,突出实时讨论、重点消息与行动项。',
|
||
author: 'TraceMemo 社区',
|
||
platform: 'desktop',
|
||
tags: ['desktop'],
|
||
license: 'MIT',
|
||
minAppVersion: null,
|
||
download: 'https://example.com/packages/community.github.example.neonboard/1.0.2/template.zip',
|
||
sizeBytes: 2048,
|
||
sha256: 'b'.repeat(64),
|
||
status: 'published'
|
||
}
|
||
const templateMarketInstalled = []
|
||
handle('report-template:list', () =>
|
||
templateMarketInstalled.map((entry) => ({
|
||
id: entry.id,
|
||
version: entry.version,
|
||
interfaceVersion: entry.interfaceVersion,
|
||
source: 'installed',
|
||
name: entry.name,
|
||
author: entry.author,
|
||
entryPath: path.join(userData, 'report-templates', entry.id, 'template.html'),
|
||
capture: { width: 1280, maxWidth: 1280, maxHeight: 4000 },
|
||
license: { spdx: entry.license },
|
||
installedAt: '2026-09-15T00:00:00.000Z'
|
||
}))
|
||
)
|
||
handle('report-template-market:list', () =>
|
||
templateMarketEnabled
|
||
? {
|
||
success: true,
|
||
catalog: {
|
||
schemaVersion: '1',
|
||
generatedAt: '2026-09-15T00:00:00.000Z',
|
||
status: 'published',
|
||
templates: [templateMarketEntry]
|
||
}
|
||
}
|
||
: { success: true, catalog: { schemaVersion: '1', status: 'published', templates: [] } }
|
||
)
|
||
handle('report-template-market:install', (id, version) => {
|
||
if (
|
||
!templateMarketEnabled ||
|
||
String(id) !== templateMarketEntry.id ||
|
||
String(version) !== templateMarketEntry.version
|
||
) {
|
||
return { success: false, error: '模板不存在', code: 'unknown_template' }
|
||
}
|
||
if (!templateMarketInstalled.some((entry) => entry.id === id && entry.version === version)) {
|
||
templateMarketInstalled.push(templateMarketEntry)
|
||
}
|
||
return { success: true, catalogEntry: templateMarketEntry }
|
||
})
|
||
handle('report-template:uninstall', (id, version) => {
|
||
const index = templateMarketInstalled.findIndex(
|
||
(entry) => entry.id === String(id) && entry.version === String(version)
|
||
)
|
||
if (index >= 0) templateMarketInstalled.splice(index, 1)
|
||
return { success: true }
|
||
})
|
||
handle('api:copyText', () => ({ success: true }))
|
||
handle('app-log:write', (entry) => {
|
||
const safe = JSON.stringify(entry)
|
||
.replace(/\b(?:0x)?[a-f0-9]{64}\b/gi, '***')
|
||
.replace(/\bsk-[a-z0-9_-]{8,}\b/gi, '***')
|
||
fs.mkdirSync(path.join(userData, 'logs'), { recursive: true })
|
||
fs.appendFileSync(path.join(userData, 'logs', 'e2e.log'), `${safe}\n`, 'utf8')
|
||
})
|
||
handle('app-log:getPath', () => path.join(userData, 'logs', 'e2e.log'))
|
||
handle('app-log:reveal', () => undefined)
|
||
const cacheSummary = () => ({
|
||
items: [
|
||
{
|
||
id: 'bootstrap',
|
||
label: '启动缓存',
|
||
description: '联系人和会话的本地启动快照',
|
||
sizeBytes: 4096,
|
||
fileCount: 2
|
||
},
|
||
{
|
||
id: 'electron',
|
||
label: '界面缓存',
|
||
description: 'Electron 页面资源和网络缓存',
|
||
sizeBytes: 2 * 1024 * 1024,
|
||
fileCount: 12
|
||
},
|
||
{
|
||
id: 'knowledge',
|
||
label: '本地知识库索引',
|
||
description: '问问微信使用的本地检索索引',
|
||
sizeBytes: 12 * 1024 * 1024,
|
||
fileCount: 8
|
||
}
|
||
],
|
||
totalBytes: 14 * 1024 * 1024 + 4096,
|
||
updatedAt: fixtureNowMs
|
||
})
|
||
handle('cache:getSummary', cacheSummary)
|
||
handle('cache:clear', cacheSummary)
|
||
handle('api:getStatus', () => ({ running: false, host: settings.apiHost, port: settings.apiPort }))
|
||
handle('api:tokenStatus', () => ({
|
||
success: true,
|
||
available: true,
|
||
hasToken: true,
|
||
maskedToken: '••••••••••••••••'
|
||
}))
|
||
handle('api:revealToken', () => ({
|
||
available: true,
|
||
hasToken: true,
|
||
maskedToken: '••••••••••••••••',
|
||
token: 'fixture-api-token'
|
||
}))
|
||
handle('api:copyToken', () => ({
|
||
success: true,
|
||
available: true,
|
||
hasToken: true,
|
||
maskedToken: '••••••••••••••••'
|
||
}))
|
||
handle('api:rotateToken', () => ({
|
||
success: true,
|
||
available: true,
|
||
hasToken: true,
|
||
maskedToken: '••••••••••••••••'
|
||
}))
|
||
handle('api:copyCurl', () => ({ success: true }))
|
||
handle('api:skillStatus', () => ({
|
||
available: true,
|
||
version: 'v1.2',
|
||
filePath: '/fixture/tracememo-reader/SKILL.md',
|
||
directoryPath: '/fixture/tracememo-reader',
|
||
source: 'development',
|
||
githubUrl: 'https://example.test/tracememo-reader'
|
||
}))
|
||
handle('api:readSkill', () => ({
|
||
success: true,
|
||
content:
|
||
'# TraceMemo Reader\n\n## 能力\n- 读取本地聊天记录\n- 导出群聊日报\n\n仅在用户授权后访问。'
|
||
}))
|
||
handle('api:start', () => ({ running: true, host: settings.apiHost, port: settings.apiPort }))
|
||
handle('api:stop', () => ({ running: false, host: settings.apiHost, port: settings.apiPort }))
|
||
handle('api:toggle', (enabled) => ({
|
||
running: enabled,
|
||
host: settings.apiHost,
|
||
port: settings.apiPort
|
||
}))
|
||
const agentHubStatus = () => ({
|
||
hub: 'offline',
|
||
connector: 'disconnected',
|
||
updatedAt: fixtureNowMs,
|
||
dataApi: 'online',
|
||
databaseReady: true
|
||
})
|
||
handle('agent-hub:getStatus', agentHubStatus)
|
||
handle('agent-hub:getLogs', () => [])
|
||
handle('agent-hub:clearLogs', () => ({ success: true }))
|
||
handle('agent-hub:startLogin', () => ({ status: agentHubStatus() }))
|
||
handle('agent-hub:cancelLogin', () => ({ status: agentHubStatus() }))
|
||
handle('agent-hub:disconnect', () => ({ status: agentHubStatus() }))
|
||
|
||
/*
|
||
* Agent Hub 对话记录(本机 conversations.json 的只读回看)。
|
||
*
|
||
* fixture 里 Hub 是 offline、也没有任何收发记录,所以恒为空 —— 与
|
||
* `agent-hub:getStatus` 的 offline 状态保持一致。
|
||
*
|
||
* 通道**必须**注册:会话面板挂载即调用,缺 handler 会被渲染层记成 pageerror。
|
||
*/
|
||
handle('agent-hub:getConversations', () => [])
|
||
handle('agent-hub:getConversation', () => null)
|
||
handle('agent-hub:clearConversations', () => ({ success: true }))
|
||
handle('image:getConfig', () => ({
|
||
success: true,
|
||
configured: true,
|
||
saved: true,
|
||
encryptionAvailable: true,
|
||
source: 'secure-storage',
|
||
resourceRoot: settings.imageKeyRoot,
|
||
xorKey: settings.imageXorKey,
|
||
aesKey: settings.imageAesKey
|
||
}))
|
||
handle('image:saveConfig', (request) => ({
|
||
success: true,
|
||
configured: true,
|
||
saved: true,
|
||
encryptionAvailable: true,
|
||
source: 'secure-storage',
|
||
...request
|
||
}))
|
||
handle('image:testConfig', () => ({
|
||
success: true,
|
||
fileFound: true,
|
||
decrypted: true,
|
||
readable: true,
|
||
diagnosticLog: 'TraceMemo 图片解析测试日志(已脱敏)\n测试结果:成功(SUCCESS)'
|
||
}))
|
||
handle('image:clearConfig', () => ({ success: true }))
|
||
handle('image:getDecoderStatus', () => ({
|
||
installed: true,
|
||
available: true,
|
||
source: 'system',
|
||
selected: false
|
||
}))
|
||
handle('image:getStatus', () => ({
|
||
configured: true,
|
||
saved: true,
|
||
encryptionAvailable: true,
|
||
source: 'secure-storage',
|
||
resourceRoot: settings.imageKeyRoot,
|
||
platform: process.platform,
|
||
autoDetectSupported: true,
|
||
wechatRunning: true,
|
||
accountIdentified: true,
|
||
cacheState: 'normal',
|
||
decoder: { installed: true, available: true, source: 'system', selected: false },
|
||
resources: Object.fromEntries(
|
||
['imageIndex', 'imageDirectory', 'thumbnail', 'original', 'sticker', 'video'].map((name) => [
|
||
name,
|
||
{ state: 'available', detail: 'fixture' }
|
||
])
|
||
)
|
||
}))
|
||
handle('settings:selectDbRoot', () => ({ canceled: false, path: 'fixture-account' }))
|
||
handle('accounts:discover', (inputPath) =>
|
||
inputPath === 'Z:\\missing-wechat-data'
|
||
? { success: false, accounts: [], error: '微信数据目录不存在,请重新选择目录' }
|
||
: {
|
||
success: true,
|
||
inputKind: 'account',
|
||
preselectedAccountId: 'fixture-account-id',
|
||
accounts: [
|
||
{
|
||
id: 'fixture-account-id',
|
||
accountRoot: inputPath || 'fixture-account',
|
||
directoryName: 'fixture-account',
|
||
wxid: fixture.self.wxid,
|
||
nickname: fixture.self.nickname,
|
||
avatar: fixture.self.avatar,
|
||
hasSavedDbKey: Boolean(savedKey),
|
||
loginStatus: connected ? 'current' : 'unknown',
|
||
selectedByInput: true
|
||
}
|
||
]
|
||
}
|
||
)
|
||
let appUpdateState = updateSimulation
|
||
? {
|
||
status: 'available',
|
||
currentVersion: '1.9.0',
|
||
delivery: 'automatic',
|
||
version: '2.0.0',
|
||
source: 'startup',
|
||
isSimulation: true
|
||
}
|
||
: unsignedMacUpdate
|
||
? {
|
||
status: 'available',
|
||
currentVersion: '2.2.2',
|
||
delivery: 'release-page',
|
||
version: '2.2.3',
|
||
source: 'startup'
|
||
}
|
||
: { status: 'idle', currentVersion: '2.2.0', delivery: 'automatic' }
|
||
let openedUpdateDownloadUrl = ''
|
||
handle('app-update:getState', () => appUpdateState)
|
||
handle('app-update:openDownloadPage', () => {
|
||
openedUpdateDownloadUrl = 'https://github.com/Wxw-Gu/TraceMemo/releases/latest'
|
||
return { success: true }
|
||
})
|
||
handle('app-update:getOpenedDownloadUrl', () => openedUpdateDownloadUrl)
|
||
handle('app-update:download', async () => {
|
||
if (!updateSimulation) return { success: true, state: appUpdateState }
|
||
const total = 60 * 1024 * 1024
|
||
const steps = [0, 5, 12, 21, 33, 46, 58, 69, 78, 86, 93, 97, 100]
|
||
let previousTransferred = 0
|
||
appUpdateState = {
|
||
...appUpdateState,
|
||
status: 'downloading',
|
||
percent: 0,
|
||
transferred: 0,
|
||
total,
|
||
bytesPerSecond: 0
|
||
}
|
||
for (const percent of steps.slice(1)) {
|
||
await new Promise((resolve) => setTimeout(resolve, 150))
|
||
const transferred = Math.round((total * percent) / 100)
|
||
const bytesPerSecond = Math.round((transferred - previousTransferred) / 0.15)
|
||
previousTransferred = transferred
|
||
appUpdateState = {
|
||
...appUpdateState,
|
||
status: 'downloading',
|
||
percent,
|
||
transferred,
|
||
total,
|
||
bytesPerSecond
|
||
}
|
||
for (const window of BrowserWindow.getAllWindows()) {
|
||
if (!window.isDestroyed()) window.webContents.send('app-update:state', appUpdateState)
|
||
}
|
||
}
|
||
appUpdateState = {
|
||
...appUpdateState,
|
||
status: 'downloaded',
|
||
percent: 100,
|
||
transferred: total,
|
||
total,
|
||
bytesPerSecond: undefined
|
||
}
|
||
for (const window of BrowserWindow.getAllWindows()) {
|
||
if (!window.isDestroyed()) window.webContents.send('app-update:state', appUpdateState)
|
||
}
|
||
return { success: true, state: appUpdateState }
|
||
})
|
||
handle('app-update:install', async () =>
|
||
updateSimulation
|
||
? {
|
||
success: true,
|
||
simulated: true,
|
||
message: '开发模拟模式:更新安装动作已模拟,未实际退出应用。'
|
||
}
|
||
: { success: true }
|
||
)
|
||
|
||
for (const channel of [
|
||
'export:start',
|
||
'export:cancel',
|
||
'export:reveal',
|
||
'settings:openAccountRoot',
|
||
'db:reopenWithRoot',
|
||
'api:skillStatus',
|
||
'api:readSkill',
|
||
'api:revealSkill',
|
||
'api:openSkillGithub',
|
||
'api:testLocalRequest',
|
||
'image:selectDecoder',
|
||
'image:openDecoderDownload',
|
||
'app-update:check',
|
||
'agent-hub:clearLogs',
|
||
'agent-hub:startLogin',
|
||
'agent-hub:cancelLogin',
|
||
'agent-hub:reconnect',
|
||
'agent-hub:disconnect',
|
||
'agent-hub:selectTestImage',
|
||
'image:listCandidates',
|
||
'image:analyze',
|
||
'image:getInsight',
|
||
'image:listInsights',
|
||
'db:search',
|
||
'db:getVideo'
|
||
]) {
|
||
if (!handlers.has(channel))
|
||
handle(channel, () => ({ success: true, candidates: [], insights: [] }))
|
||
}
|
||
|
||
app.whenReady().then(() => {
|
||
const window = new BrowserWindow({
|
||
/*
|
||
* E2E 窗口**刻意固定** 1400×800,不再跟随 `createWindow()`
|
||
* (后者已改为按屏幕工作区 60% 计算初始尺寸):visual 基线是按这个尺寸
|
||
* 评审入库的,若跟随真实逻辑,截图尺寸会随 runner 屏幕浮动、基线永远对不上。
|
||
*/
|
||
width: 1400,
|
||
height: 800,
|
||
show: false,
|
||
backgroundColor: '#ffffff',
|
||
webPreferences: {
|
||
preload: path.join(root, 'out/preload/index.js'),
|
||
contextIsolation: true,
|
||
nodeIntegration: false,
|
||
sandbox: false
|
||
}
|
||
})
|
||
if (!backgroundE2E) window.once('ready-to-show', () => window.show())
|
||
window.loadFile(path.join(root, 'out/renderer/index.html'))
|
||
})
|
||
|
||
app.on('window-all-closed', () => app.quit())
|