diff --git a/scripts/prepare-wechat-chatter-runtime.cjs b/scripts/prepare-wechat-chatter-runtime.cjs index 4940e05..74d07de 100644 --- a/scripts/prepare-wechat-chatter-runtime.cjs +++ b/scripts/prepare-wechat-chatter-runtime.cjs @@ -123,6 +123,30 @@ function patchPerSendPayload(scriptPath) { console.log('[wechat-personal] 已应用逐条发送 payload 隔离补丁') } +function patchVoiceAudioBuffer(scriptPath) { + let source = fs.readFileSync(scriptPath, 'utf8') + if (source.includes('voiceAudioDataAddr = Memory.alloc(audioLen + 1);')) return + + const staticAllocation = 'voiceAudioDataAddr = Memory.alloc(5 * 1024 * 1024); // 预分配5MB' + if (!source.includes(staticAllocation)) { + throw new Error('无法定位 wechat_chatter 语音缓冲区') + } + source = source.replace( + staticAllocation, + 'voiceAudioDataAddr = Memory.alloc(1); // 上传前按语音长度重新分配' + ) + const audioLengthMarker = ' const audioLen = audioBytes.length;\n' + if (!source.includes(audioLengthMarker)) { + throw new Error('无法定位 wechat_chatter 语音上传逻辑') + } + source = source.replace( + audioLengthMarker, + `${audioLengthMarker} voiceAudioDataAddr = Memory.alloc(audioLen + 1);\n` + ) + fs.writeFileSync(scriptPath, source) + console.log('[wechat-personal] 已应用按语音长度分配上传缓冲区补丁') +} + function patchImageHookReadiness(scriptPath) { let source = fs.readFileSync(scriptPath, 'utf8') if ( @@ -182,7 +206,8 @@ function addModifiedWorkNotice(scriptPath) { * Upstream: https://github.com/yincongcyincong/wechat_chatter * Runtime version: v0.0.18 * License: GNU General Public License version 3 (GPL-3.0) - * Changes: WeChat module discovery, per-send payload isolation, and image Hook readiness logging. + * Changes: WeChat module discovery, per-send payload isolation, dynamic voice upload buffers, + * and image Hook readiness logging. * These modifications are not provided by the upstream author. */ @@ -194,6 +219,7 @@ function addModifiedWorkNotice(scriptPath) { patchWechatCoreModuleBase(script) patchPerSendPayload(script) +patchVoiceAudioBuffer(script) patchImageHookReadiness(script) addModifiedWorkNotice(script) fs.chmodSync(executable, 0o755) diff --git a/src/main/index.ts b/src/main/index.ts index cb0266b..0509964 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1777,13 +1777,19 @@ app.whenReady().then(async () => { ipcMain.handle('agent-hub:reconnect', () => agentHubService.reconnect()) ipcMain.handle('agent-hub:disconnect', () => agentHubService.disconnect()) ipcMain.handle('wechat-personal:getStatus', () => personalWechatSendService.getStatus()) + ipcMain.handle('wechat-personal:getKeepProcess', () => + personalWechatSendService.getKeepOneBotProcess() + ) + ipcMain.handle('wechat-personal:setKeepProcess', (_, keep: boolean) => + personalWechatSendService.setKeepOneBotProcess(Boolean(keep)) + ) ipcMain.handle('wechat-personal:getRuntimeStatus', () => personalWechatRuntimeManager.getStatus()) ipcMain.handle('wechat-personal:downloadRuntime', () => personalWechatRuntimeManager.download()) ipcMain.handle('wechat-personal:cancelRuntimeDownload', () => ({ success: personalWechatRuntimeManager.cancelDownload() })) ipcMain.handle('wechat-personal:removeRuntime', async () => { - await personalWechatSendService.terminate() + await personalWechatSendService.terminate(true) return personalWechatRuntimeManager.remove() }) ipcMain.handle('wechat-personal:openRuntimeDirectory', async () => { @@ -1797,6 +1803,9 @@ app.whenReady().then(async () => { ipcMain.handle('wechat-personal:send', (_, request: PersonalWechatSendRequest) => personalWechatSendService.send(request) ) + ipcMain.handle('wechat-personal:getVoiceDiagnostic', () => + personalWechatSendService.getLatestVoiceDiagnostic() + ) ipcMain.handle('wechat-personal:selectImage', async (event) => { const window = BrowserWindow.fromWebContents(event.sender) const result = await dialog.showOpenDialog(window!, { diff --git a/src/main/services/personal-wechat-runtime-manager.ts b/src/main/services/personal-wechat-runtime-manager.ts index 37e66ca..55a3759 100644 --- a/src/main/services/personal-wechat-runtime-manager.ts +++ b/src/main/services/personal-wechat-runtime-manager.ts @@ -57,6 +57,31 @@ function patchPerSendPayload(scriptPath: string): void { writeFileSync(scriptPath, source) } +function patchVoiceAudioBuffer(scriptPath: string, strict = true): void { + let source = readFileSync(scriptPath, 'utf8') + if (source.includes('voiceAudioDataAddr = Memory.alloc(audioLen + 1);')) return + + const staticAllocation = 'voiceAudioDataAddr = Memory.alloc(5 * 1024 * 1024); // 预分配5MB' + if (!source.includes(staticAllocation)) { + if (strict) throw new Error('下载的语音组件与当前应用不兼容') + return + } + source = source.replace( + staticAllocation, + 'voiceAudioDataAddr = Memory.alloc(1); // 上传前按语音长度重新分配' + ) + const audioLengthMarker = ' const audioLen = audioBytes.length;\n' + if (!source.includes(audioLengthMarker)) { + if (strict) throw new Error('下载的语音组件与当前应用不兼容') + return + } + source = source.replace( + audioLengthMarker, + `${audioLengthMarker} voiceAudioDataAddr = Memory.alloc(audioLen + 1);\n` + ) + writeFileSync(scriptPath, source) +} + function patchImageHookReadiness(scriptPath: string): void { let source = readFileSync(scriptPath, 'utf8') if ( @@ -114,7 +139,8 @@ function addModifiedWorkNotice(scriptPath: string): void { * Upstream: https://github.com/yincongcyincong/wechat_chatter * Runtime version: v0.0.18 * License: GNU General Public License version 3 (GPL-3.0) - * Changes: WeChat module discovery, per-send payload isolation, and image Hook readiness logging. + * Changes: WeChat module discovery, per-send payload isolation, dynamic voice upload buffers, + * and image Hook readiness logging. * These modifications are not provided by the upstream author. */ @@ -159,6 +185,12 @@ export class PersonalWechatRuntimeManager { const runtime = findPersonalWechatRuntime() if (runtime) { + try { + // Apply compatibility fixes to runtimes installed before this version. + patchVoiceAudioBuffer(join(runtime.workingDirectory, 'script.js'), false) + } catch { + // Status discovery should remain available even if an old runtime is read-only. + } return this.buildStatus('ready', ARCHIVE_SIZE, undefined, runtime.root) } @@ -261,6 +293,7 @@ export class PersonalWechatRuntimeManager { const script = join(stagedDirectory, 'onebot', 'script.js') patchWechatCoreModuleBase(script) patchPerSendPayload(script) + patchVoiceAudioBuffer(script) patchImageHookReadiness(script) addModifiedWorkNotice(script) await chmod(executable, 0o755) diff --git a/src/main/services/personal-wechat-send-service.ts b/src/main/services/personal-wechat-send-service.ts index fc7244e..160fcf0 100644 --- a/src/main/services/personal-wechat-send-service.ts +++ b/src/main/services/personal-wechat-send-service.ts @@ -1,6 +1,6 @@ import { app } from 'electron' import { execFile, spawn, type ChildProcess } from 'child_process' -import { createHash } from 'crypto' +import { createHash, randomUUID } from 'crypto' import { existsSync, readFileSync, readdirSync, statSync } from 'fs' import { createConnection } from 'net' import { homedir } from 'os' @@ -10,10 +10,19 @@ import { promisify } from 'util' import type { PersonalWechatSendRequest, PersonalWechatSendResult, - PersonalWechatSenderStatus + PersonalWechatSenderStatus, + PersonalWechatVoiceDiagnostic } from '../../shared/personal-wechat' import { isPackagedRuntime } from '../runtime-mode' +import { loadSettings, updateSettings } from './settings-store' import { SilkAudioDecoder } from '../voice-pipeline/audio-decoder' +import { + validateVoicePcm, + validateVoiceSilkMetadata, + VOICE_FRAME_BYTES, + type VoicePcmMetadata +} from '../voice-pipeline/voice-quality' +import { appLogger } from '../app-logger' const execFileAsync = promisify(execFile) const DEFAULT_HOST = '127.0.0.1:58080' @@ -22,6 +31,10 @@ const REQUEST_TIMEOUT_MS = 20_000 const STOP_TIMEOUT_MS = 3_000 const MAX_IMAGE_BYTES = 20 * 1024 * 1024 const MAX_VOICE_BYTES = 20 * 1024 * 1024 +const MAX_PCM_BYTES = 64 * 1024 * 1024 +const VOICE_ENCODER_NAME = 'go-silk' +const VOICE_ENCODER_VERSION = 'wechat_chatter-v0.0.18' +let latestVoiceDiagnostic: PersonalWechatVoiceDiagnostic | null = null const WECHAT_APP_PATH = '/Applications/WeChat.app' const WECHAT_FILES_ROOT = join( homedir(), @@ -239,6 +252,221 @@ function buildRuntimePythonPath(runtimeRoot: string): string { .join(delimiter) } +function bundledFfmpegExecutable(): string { + const bundledFfmpeg = String(ffmpegStaticPath || '') + .replace('app.asar', 'app.asar.unpacked') + .trim() + return bundledFfmpeg && existsSync(bundledFfmpeg) ? bundledFfmpeg : 'ffmpeg' +} + +async function convertAudioToPcm(audioData: Buffer): Promise { + return new Promise((resolve, reject) => { + const child = spawn( + bundledFfmpegExecutable(), + ['-v', 'error', '-i', 'pipe:0', '-f', 's16le', '-ar', '16000', '-ac', '1', 'pipe:1'], + { + env: { ...process.env, PATH: buildRuntimePath() }, + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true + } + ) + const chunks: Buffer[] = [] + let total = 0 + let stderr = '' + child.stdout.on('data', (chunk: Buffer) => { + total += chunk.length + if (total > MAX_PCM_BYTES) { + child.kill() + reject(new Error('转换后的语音 PCM 过大')) + return + } + chunks.push(chunk) + }) + child.stderr.on('data', (chunk: Buffer) => { + stderr += chunk.toString('utf8').slice(0, 2_000) + }) + child.once('error', (error) => reject(new Error(`ffmpeg 转换失败:${error.message}`))) + child.once('close', (code) => { + if (code !== 0) { + reject(new Error(`ffmpeg 转换失败${stderr ? `:${stderr.trim()}` : ''}`)) + return + } + resolve(Buffer.concat(chunks)) + }) + child.stdin.end(audioData) + }) +} + +async function runtimeVoiceLogSnapshot(): Promise<{ path: string; offset: number } | undefined> { + const runtime = findPersonalWechatRuntime() + const runningOneBot = await readOneBotProcessInfo() + // Prefer the runtime discovered by TraceMemo itself. Its path may contain + // spaces (for example, "Application Support"), so parsing `ps` output with + // a whitespace-delimited regex can produce a non-existent log path. + const executable = + runtime?.executable && + runningOneBot && + (runningOneBot.command === runtime.executable || + runningOneBot.command.startsWith(`${runtime.executable} `)) + ? runtime.executable + : runningOneBot?.command.match(/^(.*\/onebot(?:\/onebot)?)(?:\s|$)/)?.[1] + const processLogPath = executable ? join(dirname(executable), 'log', 'macos.log') : undefined + const logPath = processLogPath && existsSync(processLogPath) ? processLogPath : runtime?.logPath + if (!logPath || !existsSync(logPath)) return undefined + try { + return { path: logPath, offset: statSync(logPath).size } + } catch { + return undefined + } +} + +function readVoiceRuntimeEvidence(snapshot?: { path: string; offset: number }): { + uploadResult?: string + uploadDataLen?: number + durationMs?: number + sendResult?: string +} { + if (!snapshot || !existsSync(snapshot.path)) return {} + try { + const data = readFileSync(snapshot.path) + const lines = data + .subarray(Math.min(snapshot.offset, data.length)) + .toString('utf8') + .split(/\r?\n/) + const evidence: { + uploadResult?: string + uploadDataLen?: number + durationMs?: number + sendResult?: string + } = {} + for (const line of lines) { + if (!line.trim()) continue + try { + const entry = JSON.parse(line) as Record + const message = `${String(entry.msg || '')} ${String(entry.message || '')}` + if (message.includes('上传语音任务执行结果')) { + if (entry.result !== undefined) evidence.uploadResult = String(entry.result) + if (entry.silk_len !== undefined) evidence.uploadDataLen = Number(entry.silk_len) + if (entry.duration_ms !== undefined) evidence.durationMs = Number(entry.duration_ms) + } + if (message.includes('发送语音任务执行结果') && entry.result !== undefined) { + evidence.sendResult = String(entry.result) + } + } catch { + // Ignore a partially-written runtime log line. + } + } + return evidence + } catch { + return {} + } +} + +async function waitForVoiceRuntimeEvidence( + snapshot: { path: string; offset: number } | undefined, + timeoutMs = 6_000 +): Promise<{ + uploadResult?: string + uploadDataLen?: number + durationMs?: number + sendResult?: string +}> { + if (!snapshot) return {} + const startedAt = Date.now() + let evidence = readVoiceRuntimeEvidence(snapshot) + while (Date.now() - startedAt < timeoutMs) { + // The OneBot HTTP handler acknowledges the task before its worker writes + // the upload/send callbacks to disk. Poll the request's log suffix so a + // successful asynchronous send is not reported as a validation failure. + if ( + evidence.uploadResult !== undefined && + (evidence.uploadResult !== '0' || evidence.sendResult !== undefined) + ) { + return evidence + } + await new Promise((resolve) => setTimeout(resolve, 100)) + evidence = readVoiceRuntimeEvidence(snapshot) + } + return evidence +} + +const VOICE_DIAGNOSTIC_KEYS = new Set([ + 'input_bytes', + 'normalized_input_bytes', + 'pcm_size', + 'sample_rate', + 'channels', + 'input_duration_ms', + 'upload_result', + 'upload_data_len', + 'silk_duration_ms', + 'send_result', + 'voice_send_mode', + 'failure_phase', + 'error' +]) + +function redactVoiceDiagnosticDetails(details: Record): Record { + const allowedDetails = Object.fromEntries( + Object.entries(details).filter(([key]) => VOICE_DIAGNOSTIC_KEYS.has(key)) + ) + if (allowedDetails.error !== undefined) { + const rawError = String(allowedDetails.error).trim() + allowedDetails.error = rawError + .replace( + /(["']?(?:aesKey|cdnKey|token|cookie|authorization|access_token|secret|apiKey)["']?\s*[:=]\s*["']?)([^"',;\]}\s]+)(["']?)/gi, + '$1[redacted]$3' + ) + .replace(/Bearer\s+[^\s,;\]}"']+/gi, 'Bearer [redacted]') + .slice(0, 1_000) + } + return allowedDetails +} + +export function buildPersonalWechatVoiceDiagnostic( + requestId: string, + phase: PersonalWechatVoiceDiagnostic['phase'], + details: Record, + previous: PersonalWechatVoiceDiagnostic | null = null +): PersonalWechatVoiceDiagnostic { + const allowedDetails = redactVoiceDiagnosticDetails(details) + return { + ...(previous?.request_id === requestId ? previous : {}), + request_id: requestId, + voice_id: requestId, + phase, + encoder_name: VOICE_ENCODER_NAME, + encoder_version: VOICE_ENCODER_VERSION, + ...allowedDetails + } +} + +function logVoiceAttempt( + requestId: string, + phase: PersonalWechatVoiceDiagnostic['phase'], + details: Record = {} +): void { + latestVoiceDiagnostic = buildPersonalWechatVoiceDiagnostic( + requestId, + phase, + details, + latestVoiceDiagnostic + ) + const allowedDetails = redactVoiceDiagnosticDetails(details) + appLogger.write({ + level: phase === 'failed' ? 'error' : 'info', + scope: 'personal-wechat-voice', + message: `voice_${phase}`, + details: { + request_id: requestId, + voice_id: requestId, + encoder_name: VOICE_ENCODER_NAME, + encoder_version: VOICE_ENCODER_VERSION, + ...allowedDetails + } + }) +} + function createWavBuffer(pcm: Buffer, sampleRate: number, channels: number): Buffer { const header = Buffer.alloc(44) header.write('RIFF', 0) @@ -415,6 +643,18 @@ export class PersonalWechatSendService { private child: ChildProcess | null = null private startPromise: Promise | null = null private lastError = '' + private voiceSendTail: Promise = Promise.resolve() + private keepOneBotProcess = Boolean(loadSettings().keepPersonalWechatProcess) + + getKeepOneBotProcess(): boolean { + return this.keepOneBotProcess + } + + setKeepOneBotProcess(keep: boolean): boolean { + this.keepOneBotProcess = keep + updateSettings({ keepPersonalWechatProcess: keep }) + return this.keepOneBotProcess + } async getStatus(): Promise { const preflight = await this.preflight() @@ -449,7 +689,7 @@ export class PersonalWechatSendService { canSend: false, canSendText: false, canSendImage: false, - message: 'OneBot 仍绑定旧微信进程,请点击“尝试重新绑定”' + message: 'OneBot 仍绑定旧微信进程,请点击“绑定微信”' } } if (hook.readiness === 'failed') { @@ -459,7 +699,7 @@ export class PersonalWechatSendService { canSend: false, canSendText: false, canSendImage: false, - message: '微信发送 Hook 初始化失败,请尝试重新绑定', + message: '微信发送 Hook 初始化失败,请点击“绑定微信”', ...(hook.error ? { error: hook.error } : {}) } } @@ -477,7 +717,7 @@ export class PersonalWechatSendService { canSendVoice, message: hook.imageHookReady && preflight.status.imagePath && !imagePathBound - ? 'OneBot 尚未绑定微信图片目录,请点击“尝试重新绑定”' + ? 'OneBot 尚未绑定微信图片目录,请点击“绑定微信”' : canSendText || canSendImage || canSendVoice ? '个人微信已绑定,可使用已初始化的消息类型' : '个人微信已绑定,发送前请先在微信中手动初始化对应消息类型', @@ -485,13 +725,42 @@ export class PersonalWechatSendService { } } + getLatestVoiceDiagnostic(): PersonalWechatVoiceDiagnostic | null { + return latestVoiceDiagnostic ? { ...latestVoiceDiagnostic } : null + } + async send(request: PersonalWechatSendRequest): Promise { + const requestId = randomUUID() + if (request.type !== 'voice') return this.sendInternal(request, requestId) + + // OneBot's voice upload/callback bridge still uses process-wide Frida + // fields. Serialize voice requests here so duration, Silk length and CDN + // callback data cannot cross between two concurrent sends. + const previous = this.voiceSendTail + let release!: () => void + this.voiceSendTail = new Promise((resolve) => { + release = resolve + }) + await previous + try { + return await this.sendInternal(request, requestId) + } finally { + release() + } + } + + private async sendInternal( + request: PersonalWechatSendRequest, + requestId: string + ): Promise { const to = String(request?.to || '').trim() if (!to) { const status = await this.getStatus() return { success: false, status, error: '接收者不能为空' } } let fileBase64: string | undefined + let voicePcm: VoicePcmMetadata | undefined + let runtimeSnapshot: { path: string; offset: number } | undefined if (request.type === 'text') { const text = String(request.text || '').trim() if (!text) { @@ -523,9 +792,45 @@ export class PersonalWechatSendService { error: `${request.type === 'voice' ? '语音' : '图片'}必须小于 20 MB` } } - fileBase64 = ( - request.type === 'voice' ? await prepareVoiceFile(filePath) : readFileSync(filePath) - ).toString('base64') + let fileData: Buffer + try { + fileData = + request.type === 'voice' ? await prepareVoiceFile(filePath) : readFileSync(filePath) + const useLegacyVoicePath = request.type === 'voice' && request.voiceSendMode === 'legacy' + if (request.type === 'voice' && !useLegacyVoicePath) { + const sourceInputBytes = fileData.length + const pcm = await convertAudioToPcm(fileData) + const alignedPcmBytes = Math.floor(pcm.length / VOICE_FRAME_BYTES) * VOICE_FRAME_BYTES + const alignedPcm = pcm.subarray(0, alignedPcmBytes) + voicePcm = validateVoicePcm(alignedPcm) + // The bundled Go encoder consumes 20ms frames. Sending an aligned + // WAV makes the duration in the eventual protobuf match its output. + fileData = createWavBuffer(alignedPcm, voicePcm.sampleRate, voicePcm.channels) + logVoiceAttempt(requestId, 'prepared', { + input_bytes: sourceInputBytes, + normalized_input_bytes: fileData.length, + pcm_size: voicePcm.pcmSize, + sample_rate: voicePcm.sampleRate, + channels: voicePcm.channels, + input_duration_ms: voicePcm.durationMs, + voice_send_mode: 'normalized' + }) + } else if (request.type === 'voice') { + logVoiceAttempt(requestId, 'prepared', { + input_bytes: fileData.length, + normalized_input_bytes: fileData.length, + voice_send_mode: 'legacy' + }) + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + if (request.type === 'voice') { + logVoiceAttempt(requestId, 'failed', { failure_phase: 'pcm_validation', error: message }) + } + const status = await this.getStatus() + return { success: false, status, error: message } + } + fileBase64 = fileData.toString('base64') request = { ...request, to, filePath } } @@ -547,6 +852,7 @@ export class PersonalWechatSendService { } const oneBot = buildPersonalWechatOneBotRequest(request, fileBase64) + if (request.type === 'voice') runtimeSnapshot = await runtimeVoiceLogSnapshot() try { const response = await requestWithTimeout( `http://${DEFAULT_HOST}${oneBot.endpoint}`, @@ -561,9 +867,83 @@ export class PersonalWechatSendService { if (!response.ok) throw new Error(responseText || `HTTP ${response.status}`) const parsed = responseText ? (JSON.parse(responseText) as { status?: string }) : {} if (parsed.status && parsed.status !== 'ok') throw new Error(responseText) + if (request.type === 'voice') { + const evidence = await waitForVoiceRuntimeEvidence(runtimeSnapshot) + try { + if (evidence.uploadResult !== '0') throw new Error('未确认微信语音上传结果') + if (evidence.sendResult !== '1') throw new Error('未确认微信语音发送结果') + if (evidence.uploadDataLen === undefined || evidence.durationMs === undefined) { + throw new Error('未获取到微信 Silk 音频元数据') + } + validateVoiceSilkMetadata(evidence.uploadDataLen, evidence.durationMs) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + logVoiceAttempt(requestId, 'failed', { + failure_phase: 'silk_validation', + upload_result: evidence.uploadResult, + upload_data_len: evidence.uploadDataLen, + silk_duration_ms: evidence.durationMs, + send_result: evidence.sendResult, + error: message + }) + const failedStatus = await this.getStatus() + return { + success: false, + status: { ...failedStatus, state: 'error', error: message }, + error: message + } + } + logVoiceAttempt(requestId, 'completed', { + pcm_size: voicePcm?.pcmSize, + input_duration_ms: voicePcm?.durationMs, + upload_result: evidence.uploadResult, + upload_data_len: evidence.uploadDataLen, + silk_duration_ms: evidence.durationMs, + send_result: evidence.sendResult + }) + } return { success: true, status: await this.getStatus() } } catch (error) { this.lastError = error instanceof Error ? error.message : String(error) + if (request.type === 'voice') { + const evidence = await waitForVoiceRuntimeEvidence(runtimeSnapshot, 1_000) + // OneBot may finish the asynchronous upload/send after its HTTP + // request has timed out. If the runtime log proves that this voice was + // uploaded and sent successfully, do not report a false failure. + const runtimeSendSucceeded = + evidence.uploadResult === '0' && + evidence.sendResult === '1' && + evidence.uploadDataLen !== undefined && + evidence.durationMs !== undefined + if (runtimeSendSucceeded) { + try { + validateVoiceSilkMetadata(evidence.uploadDataLen!, evidence.durationMs!) + this.lastError = '' + logVoiceAttempt(requestId, 'completed', { + pcm_size: voicePcm?.pcmSize, + input_duration_ms: voicePcm?.durationMs, + upload_result: evidence.uploadResult, + upload_data_len: evidence.uploadDataLen, + silk_duration_ms: evidence.durationMs, + send_result: evidence.sendResult + }) + return { success: true, status: await this.getStatus() } + } catch { + // Keep the transport error below when the runtime metadata is + // present but fails the same Silk validation as the normal path. + } + } + logVoiceAttempt(requestId, 'failed', { + failure_phase: 'http_send', + pcm_size: voicePcm?.pcmSize, + input_duration_ms: voicePcm?.durationMs, + upload_result: evidence.uploadResult, + upload_data_len: evidence.uploadDataLen, + silk_duration_ms: evidence.durationMs, + send_result: evidence.sendResult, + error: this.lastError + }) + } const failedStatus = await this.getStatus() return { success: false, @@ -614,7 +994,13 @@ export class PersonalWechatSendService { return status } - async terminate(): Promise { + async terminate(force = false): Promise { + if (this.keepOneBotProcess && !force) { + this.child = null + this.startPromise = null + this.lastError = '' + return + } const trackedPid = this.child?.pid const oneBot = await readOneBotProcessInfo() if (oneBot) await terminateOneBot(oneBot) @@ -817,7 +1203,7 @@ export class PersonalWechatSendService { configPath, ...(imagePath ? { imagePath } : {}), state: this.child && this.child.exitCode === null ? 'starting' : 'stopped', - message: this.lastError || '尚未绑定当前微信,可点击“尝试重新绑定”' + message: this.lastError || '尚未绑定当前微信,可点击“绑定微信”' } } } diff --git a/src/main/services/settings-store.ts b/src/main/services/settings-store.ts index 316e7e4..d67ce41 100644 --- a/src/main/services/settings-store.ts +++ b/src/main/services/settings-store.ts @@ -39,6 +39,8 @@ export interface AppSettings { showStartupProgress: boolean ttsSelectedVoiceId: string ttsModel: TextToSpeechModel + /** Keep a running personal-WeChat OneBot process across app restarts. */ + keepPersonalWechatProcess?: boolean } function getDefaultDbRoot(): string { @@ -120,7 +122,8 @@ const DEFAULT_SETTINGS: AppSettings = { compactMode: false, showStartupProgress: true, ttsSelectedVoiceId: '', - ttsModel: 's2.1-pro-free' + ttsModel: 's2.1-pro-free', + keepPersonalWechatProcess: false } const SETTINGS_FILE = path.join( diff --git a/src/main/voice-pipeline/voice-quality.ts b/src/main/voice-pipeline/voice-quality.ts new file mode 100644 index 0000000..2c78264 --- /dev/null +++ b/src/main/voice-pipeline/voice-quality.ts @@ -0,0 +1,66 @@ +export const VOICE_SAMPLE_RATE = 16_000 +export const VOICE_CHANNELS = 1 +export const VOICE_SAMPLE_BYTES = 2 +export const VOICE_FRAME_MS = 20 +export const VOICE_MIN_PCM_BYTES = + (VOICE_SAMPLE_RATE * VOICE_CHANNELS * VOICE_SAMPLE_BYTES * VOICE_FRAME_MS) / 1000 +export const VOICE_FRAME_BYTES = VOICE_MIN_PCM_BYTES + +export interface VoicePcmMetadata { + pcmSize: number + sampleRate: number + channels: number + durationMs: number +} + +export interface VoiceSilkMetadata { + silkSize: number + durationMs: number +} + +/** + * Validate the exact PCM contract consumed by the bundled OneBot encoder. + * The Go Silk encoder emits a header-only payload for sub-frame input, which + * is accepted by the old path but produces an unplayable WeChat voice. + */ +export function validateVoicePcm( + pcm: Uint8Array, + sampleRate = VOICE_SAMPLE_RATE, + channels = VOICE_CHANNELS +): VoicePcmMetadata { + if (sampleRate !== VOICE_SAMPLE_RATE || channels !== VOICE_CHANNELS) { + throw new Error('语音 PCM 必须是 16kHz 单声道') + } + if (pcm.byteLength === 0) throw new Error('语音 PCM 为空') + if (pcm.byteLength % VOICE_SAMPLE_BYTES !== 0) throw new Error('语音 PCM 长度无效') + if (pcm.byteLength < VOICE_MIN_PCM_BYTES) { + throw new Error('语音时长过短,至少需要 20 毫秒') + } + const durationMs = Math.floor( + (pcm.byteLength * 1000) / (sampleRate * channels * VOICE_SAMPLE_BYTES) + ) + if (durationMs <= 0) throw new Error('语音时长无效') + return { pcmSize: pcm.byteLength, sampleRate, channels, durationMs } +} + +export function validateVoiceSilk( + silk: Uint8Array, + durationMs: number, + silkHeader = '\x02#!SILK_V3' +): VoiceSilkMetadata { + const header = new TextEncoder().encode(silkHeader) + if (silk.byteLength <= header.byteLength) throw new Error('Silk 音频为空或只有文件头') + for (let index = 0; index < header.length; index += 1) { + if (silk[index] !== header[index]) throw new Error('Silk 音频头无效') + } + if (!Number.isFinite(durationMs) || durationMs <= 0) throw new Error('Silk 时长无效') + return { silkSize: silk.byteLength, durationMs: Math.floor(durationMs) } +} + +export function validateVoiceSilkMetadata(silkSize: number, durationMs: number): VoiceSilkMetadata { + if (!Number.isInteger(silkSize) || silkSize <= 10) { + throw new Error('Silk 音频为空或只有文件头') + } + if (!Number.isFinite(durationMs) || durationMs <= 0) throw new Error('Silk 时长无效') + return { silkSize, durationMs: Math.floor(durationMs) } +} diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 403b410..d4715f6 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -58,7 +58,8 @@ import type { PersonalWechatVoiceSelectionResult, PersonalWechatSendRequest, PersonalWechatSendResult, - PersonalWechatSenderStatus + PersonalWechatSenderStatus, + PersonalWechatVoiceDiagnostic } from '../shared/personal-wechat' import type { PersonalWechatRuntimeDownloadResult, @@ -581,6 +582,8 @@ declare global { limit?: number ) => Promise<{ success: boolean; insights: ImageInsight[] }> getPersonalWechatSenderStatus: () => Promise + getPersonalWechatKeepOneBotProcess: () => Promise + setPersonalWechatKeepOneBotProcess: (keep: boolean) => Promise getPersonalWechatRuntimeStatus: () => Promise downloadPersonalWechatRuntime: () => Promise cancelPersonalWechatRuntimeDownload: () => Promise<{ success: boolean }> @@ -595,6 +598,7 @@ declare global { sendPersonalWechatMessage: ( request: PersonalWechatSendRequest ) => Promise + getPersonalWechatVoiceDiagnostic: () => Promise getAgentHubStatus: () => Promise getAgentHubLogs: () => Promise clearAgentHubLogs: () => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index 1294b7f..747f8b0 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -30,7 +30,8 @@ import type { PersonalWechatVoiceSelectionResult, PersonalWechatSendRequest, PersonalWechatSendResult, - PersonalWechatSenderStatus + PersonalWechatSenderStatus, + PersonalWechatVoiceDiagnostic } from '../shared/personal-wechat' import type { PersonalWechatRuntimeDownloadResult, @@ -352,6 +353,10 @@ const api = { ipcRenderer.invoke('image:listInsights', sessionId, limit), getPersonalWechatSenderStatus: (): Promise => ipcRenderer.invoke('wechat-personal:getStatus'), + getPersonalWechatKeepOneBotProcess: (): Promise => + ipcRenderer.invoke('wechat-personal:getKeepProcess'), + setPersonalWechatKeepOneBotProcess: (keep: boolean): Promise => + ipcRenderer.invoke('wechat-personal:setKeepProcess', keep), getPersonalWechatRuntimeStatus: (): Promise => ipcRenderer.invoke('wechat-personal:getRuntimeStatus'), downloadPersonalWechatRuntime: (): Promise => @@ -381,6 +386,8 @@ const api = { sendPersonalWechatMessage: ( request: PersonalWechatSendRequest ): Promise => ipcRenderer.invoke('wechat-personal:send', request), + getPersonalWechatVoiceDiagnostic: (): Promise => + ipcRenderer.invoke('wechat-personal:getVoiceDiagnostic'), getAgentHubStatus: () => ipcRenderer.invoke('agent-hub:getStatus'), getAgentHubLogs: () => ipcRenderer.invoke('agent-hub:getLogs'), clearAgentHubLogs: () => ipcRenderer.invoke('agent-hub:clearLogs'), diff --git a/src/renderer/src/components/chat/PersonalWechatChatComposer.tsx b/src/renderer/src/components/chat/PersonalWechatChatComposer.tsx index 7449460..3e6e1dd 100644 --- a/src/renderer/src/components/chat/PersonalWechatChatComposer.tsx +++ b/src/renderer/src/components/chat/PersonalWechatChatComposer.tsx @@ -206,7 +206,7 @@ export function PersonalWechatChatComposer({ const sendGenerated = async (): Promise => { if (!generatedVoiceRef.current) return - const result = await send( + await send( { type: 'voice', to: targetId, @@ -215,7 +215,20 @@ export function PersonalWechatChatComposer({ }, voiceText.trim() || '语音消息' ) - if (result) clearGeneratedVoice() + } + + const sendGeneratedAlternative = async (): Promise => { + if (!generatedVoiceRef.current) return + await send( + { + type: 'voice', + to: targetId, + filePath: generatedVoiceRef.current.filePath, + isGroup: isGroupChat, + voiceSendMode: 'legacy' + }, + voiceText.trim() || '语音消息' + ) } const canSend = Boolean( @@ -341,7 +354,7 @@ export function PersonalWechatChatComposer({ -
+
+
) : ( diff --git a/src/renderer/src/components/chat/PersonalWechatSendDialog.tsx b/src/renderer/src/components/chat/PersonalWechatSendDialog.tsx index a95ccf1..8d53629 100644 --- a/src/renderer/src/components/chat/PersonalWechatSendDialog.tsx +++ b/src/renderer/src/components/chat/PersonalWechatSendDialog.tsx @@ -2,19 +2,29 @@ import { useCallback, useEffect, useRef, useState } from 'react' import type { Contact } from '../../../../shared/types' import type { PersonalWechatSendRequest, - PersonalWechatSenderStatus + PersonalWechatSenderStatus, + PersonalWechatVoiceDiagnostic } from '../../../../shared/personal-wechat' import type { PersonalWechatRuntimeProgressEvent, PersonalWechatRuntimeStatus } from '../../../../shared/personal-wechat-runtime' -import { Button, Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../ui' +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + Switch +} from '../ui' import { PersonalWechatChatComposer, type ChatMessage, type PersonalWechatComposerMode } from './PersonalWechatChatComposer' import { PersonalWechatSetupGuide } from './PersonalWechatSetupGuide' +import { PersonalWechatVoiceDiagnosticDialog } from './PersonalWechatVoiceDiagnosticDialog' type SelectedLocalFile = { path: string; name: string } @@ -71,8 +81,15 @@ export function PersonalWechatSendDialog({ const [runtimeBusy, setRuntimeBusy] = useState(false) const [sendBusy, setSendBusy] = useState(false) const [detectionAttempted, setDetectionAttempted] = useState(false) + // Status can be reconstructed from a previous OneBot process/log. These + // session gates ensure the user explicitly binds and detects after opening + // the flow instead of inheriting stale readiness. + const [sessionBound, setSessionBound] = useState(false) const [messages, setMessages] = useState([]) const [sendError, setSendError] = useState(null) + const [voiceDiagnostic, setVoiceDiagnostic] = useState(null) + const [voiceDiagnosticOpen, setVoiceDiagnosticOpen] = useState(false) + const [keepOneBotProcess, setKeepOneBotProcess] = useState(false) const [composerStarted, setComposerStarted] = useState(Boolean(initialImage)) const requestIdRef = useRef(0) const restoreFocusRef = useRef(null) @@ -81,7 +98,10 @@ export function PersonalWechatSendDialog({ const targetId = contact.m_nsUsrName const isBusy = binding || runtimeBusy || sendBusy const setupReady = Boolean( - senderStatus?.canSendText && (senderStatus?.canSendImage || senderStatus?.canSendVoice) + sessionBound && + detectionAttempted && + senderStatus?.canSendText && + (senderStatus?.canSendImage || senderStatus?.canSendVoice) ) const refreshStatus = useCallback(async (): Promise => { @@ -97,6 +117,15 @@ export function PersonalWechatSendDialog({ setRuntimeStatus(nextRuntime) setRuntimeProgress(nextRuntime?.state === 'downloading' ? nextRuntime : null) setSenderStatus(nextSender) + setSessionBound( + nextSender.state === 'online' || + Boolean( + nextSender.wechatPid && + nextSender.boundWechatPid === nextSender.wechatPid && + nextSender.attachReady && + nextSender.baseAddressReady + ) + ) } catch (error) { if (requestId === requestIdRef.current) setSenderStatus(fallbackStatus(error)) } finally { @@ -114,6 +143,34 @@ export function PersonalWechatSendDialog({ return unsubscribe }, [refreshStatus]) + useEffect(() => { + let active = true + const readKeepProcess = window.api.getPersonalWechatKeepOneBotProcess + if (!readKeepProcess) return undefined + void readKeepProcess().then((keep) => { + if (active && typeof keep === 'boolean') setKeepOneBotProcess(keep) + }) + return () => { + active = false + } + }, []) + + const handleKeepOneBotProcessChange = async (keep: boolean): Promise => { + const saveKeepProcess = window.api.setPersonalWechatKeepOneBotProcess + if (!saveKeepProcess) { + setSendError('请重启 TraceMemo 后再使用“保留 OneBot 进程”') + return + } + setKeepOneBotProcess(keep) + try { + const saved = await saveKeepProcess(keep) + if (typeof saved === 'boolean') setKeepOneBotProcess(saved) + } catch (error) { + setKeepOneBotProcess(!keep) + setSendError(error instanceof Error ? error.message : String(error)) + } + } + const handleDownloadRuntime = async (): Promise => { if (runtimeBusy) return setRuntimeBusy(true) @@ -138,6 +195,15 @@ export function PersonalWechatSendDialog({ try { const nextStatus = await window.api.rebindPersonalWechatSender() setSenderStatus(nextStatus) + setSessionBound( + nextStatus.state === 'online' || + Boolean( + nextStatus.wechatPid && + nextStatus.boundWechatPid === nextStatus.wechatPid && + nextStatus.attachReady && + nextStatus.baseAddressReady + ) + ) if (nextStatus.state !== 'online' && nextStatus.message) setSendError(nextStatus.message) } catch (error) { setSendError(error instanceof Error ? error.message : String(error)) @@ -185,103 +251,140 @@ export function PersonalWechatSendDialog({ } } + const handleOpenVoiceDiagnostic = async (): Promise => { + const diagnostic = await window.api.getPersonalWechatVoiceDiagnostic() + setVoiceDiagnostic(diagnostic) + setVoiceDiagnosticOpen(true) + } + return ( - !open && handleClose()}> - { - restoreFocusRef.current = - document.activeElement instanceof HTMLElement ? document.activeElement : null - }} - onEscapeKeyDown={(event) => isBusy && event.preventDefault()} - onPointerDownOutside={(event) => isBusy && event.preventDefault()} - > - -
- {displayName.slice(0, 1)} -
-
- {displayName} - - {isGroupChat ? '群聊' : '联系人'} · {setupReady ? '微信已连接' : '配置微信消息发送'} - -
- -
+ <> + !open && handleClose()}> + { + restoreFocusRef.current = + document.activeElement instanceof HTMLElement ? document.activeElement : null + }} + onEscapeKeyDown={(event) => isBusy && event.preventDefault()} + onPointerDownOutside={(event) => isBusy && event.preventDefault()} + > + +
+ {displayName.slice(0, 1)} +
+
+ {displayName} + + {isGroupChat ? '群聊' : '联系人'} · {setupReady ? '微信已连接' : '配置微信消息发送'} + +
+ +
-
+
+ {setupReady && composerStarted && ( +
+ {messages.length === 0 ? ( +
还没有发送消息。
+ ) : ( + messages.map((message) => ( +
+ + {message.type === 'text' + ? '文字' + : message.type === 'image' + ? '图片' + : '语音'} + + {message.text || message.fileName} +
+ )) + )} +
+ )} + + {(!setupReady || !composerStarted) && senderStatus && ( + void handleDownloadRuntime()} + onBind={() => void handleBind()} + detectionAttempted={detectionAttempted} + onDetect={() => void handleDetect()} + onStartSending={() => setComposerStarted(true)} + onOpenTextToSpeechSettings={handleOpenSettings} + /> + )} + + {setupReady && composerStarted && ( + setMessages((current) => [...current, message])} + busy={sendBusy} + /> + )} + {sendError && ( +
+ {sendError} +
+ )} +
{setupReady && composerStarted && ( -
- {messages.length === 0 ? ( -
还没有发送消息。
- ) : ( - messages.map((message) => ( -
- - {message.type === 'text' - ? '文字' - : message.type === 'image' - ? '图片' - : '语音'} - - {message.text || message.fileName} -
- )) - )} +
+
+ 保留 OneBot 进程 + void handleKeepOneBotProcessChange(checked)} + aria-label="保留 OneBot 进程" + /> +
+
)} - - {(!setupReady || !composerStarted) && senderStatus && ( - void handleDownloadRuntime()} - onBind={() => void handleBind()} - detectionAttempted={detectionAttempted} - onDetect={() => void handleDetect()} - onStartSending={() => setComposerStarted(true)} - onOpenTextToSpeechSettings={handleOpenSettings} - /> - )} - - {setupReady && composerStarted && ( - setMessages((current) => [...current, message])} - busy={sendBusy} - /> - )} - {sendError && ( -
- {sendError} + {(!setupReady || !composerStarted) && ( +
+
+ 保留 OneBot 进程 + void handleKeepOneBotProcessChange(checked)} + aria-label="保留 OneBot 进程" + /> +
+
)} -
- {(!setupReady || !composerStarted) && ( -
- -
- )} - -
+
+
+ + ) } diff --git a/src/renderer/src/components/chat/PersonalWechatSetupGuide.tsx b/src/renderer/src/components/chat/PersonalWechatSetupGuide.tsx index 9f4fc46..fa81a67 100644 --- a/src/renderer/src/components/chat/PersonalWechatSetupGuide.tsx +++ b/src/renderer/src/components/chat/PersonalWechatSetupGuide.tsx @@ -15,6 +15,7 @@ interface PersonalWechatSetupGuideProps { binding: boolean detecting: boolean detectionAttempted: boolean + sessionBound: boolean onDownloadRuntime: () => void onBind: () => void onDetect: () => void @@ -61,6 +62,7 @@ export function PersonalWechatSetupGuide({ binding, detecting, detectionAttempted, + sessionBound, onDownloadRuntime, onBind, onDetect, @@ -70,12 +72,14 @@ export function PersonalWechatSetupGuide({ const [showSupportedVersions, setShowSupportedVersions] = useState(false) const runtimeReady = runtimeStatus?.state === 'ready' || senderStatus?.runtimeReady === true const runtimeDownloading = runtimeBusy || runtimeStatus?.state === 'downloading' - const connected = senderStatus ? isWechatBound(senderStatus) : false + const connected = sessionBound && (senderStatus ? isWechatBound(senderStatus) : false) const canSendText = Boolean(senderStatus?.canSendText) const canSendImage = Boolean(senderStatus?.canSendImage) const canSendVoice = Boolean(senderStatus?.canSendVoice) const mediaReady = canSendImage || canSendVoice - const verificationComplete = canSendText && mediaReady + const detectedTextReady = detectionAttempted && canSendText + const detectedMediaReady = detectionAttempted && mediaReady + const verificationComplete = connected && detectedTextReady && detectedMediaReady const allReady = verificationComplete const progress = runtimeProgress || runtimeStatus const progressPercent = Math.max(0, Math.min(100, Math.round((progress?.progress || 0) * 100))) @@ -160,7 +164,8 @@ export function PersonalWechatSetupGuide({

绑定微信可能导致当前微信异常闪退,这是正常现象。若微信退出,请重新启动微信后,再回到这里重新检测/绑定。
- 微信总是自动更新?请在微信左下角打开“设置 → 通用”,取消勾选“有更新时自动升级微信”,否则版本变化后可能无法绑定。 + 微信总是自动更新?请在微信左下角打开“设置 → + 通用”,取消勾选“有更新时自动升级微信”,否则版本变化后可能无法绑定。

)} + + + + + ) +} diff --git a/src/shared/personal-wechat.ts b/src/shared/personal-wechat.ts index 53418da..d7cbd1f 100644 --- a/src/shared/personal-wechat.ts +++ b/src/shared/personal-wechat.ts @@ -62,6 +62,8 @@ export interface PersonalWechatSendImageRequest extends PersonalWechatSendBaseRe export interface PersonalWechatSendVoiceRequest extends PersonalWechatSendBaseRequest { type: 'voice' filePath: string + /** Internal comparison switch for the temporary voice regression test. */ + voiceSendMode?: 'normalized' | 'legacy' } export type PersonalWechatSendRequest = @@ -75,6 +77,28 @@ export interface PersonalWechatSendResult { error?: string } +/** Safe, user-copyable metadata for the most recent voice send attempt. */ +export interface PersonalWechatVoiceDiagnostic { + request_id: string + voice_id: string + phase: 'prepared' | 'completed' | 'failed' + encoder_name: string + encoder_version: string + input_bytes?: number + normalized_input_bytes?: number + pcm_size?: number + sample_rate?: number + channels?: number + input_duration_ms?: number + upload_result?: string + upload_data_len?: number + silk_duration_ms?: number + send_result?: string + voice_send_mode?: 'normalized' | 'legacy' + failure_phase?: string + error?: string +} + export interface PersonalWechatImageSelectionResult { canceled: boolean path?: string diff --git a/tests/component/personal-wechat-send-dialog.test.tsx b/tests/component/personal-wechat-send-dialog.test.tsx index 0401c1e..0c99196 100644 --- a/tests/component/personal-wechat-send-dialog.test.tsx +++ b/tests/component/personal-wechat-send-dialog.test.tsx @@ -16,6 +16,8 @@ const getTextToSpeechSettings = vi.fn() const listTextToSpeechVoices = vi.fn() const synthesizeTextToSpeech = vi.fn() const removeGeneratedTextToSpeechAudio = vi.fn() +const getPersonalWechatVoiceDiagnostic = vi.fn() +const copyText = vi.fn() const contact = { m_nsUsrName: 'fixture-room@chatroom', @@ -71,6 +73,11 @@ function renderDialog( } async function startComposer(): Promise { + const bind = await screen.findByRole('button', { name: '绑定微信' }) + fireEvent.click(bind) + await screen.findByText('✓ 微信已绑定') + const detect = await screen.findByRole('button', { name: '重新检测' }) + fireEvent.click(detect) const start = await screen.findByRole('button', { name: '开始发送' }) fireEvent.click(start) } @@ -125,6 +132,8 @@ describe('PersonalWechatSendDialog', () => { audioDataUrl: 'data:audio/mpeg;base64,fixture' }) removeGeneratedTextToSpeechAudio.mockReset().mockResolvedValue({ success: true }) + getPersonalWechatVoiceDiagnostic.mockReset().mockResolvedValue(null) + copyText.mockReset().mockResolvedValue({ success: true }) Object.defineProperty(window, 'api', { configurable: true, value: { @@ -139,7 +148,9 @@ describe('PersonalWechatSendDialog', () => { getTextToSpeechSettings, listTextToSpeechVoices, synthesizeTextToSpeech, - removeGeneratedTextToSpeechAudio + removeGeneratedTextToSpeechAudio, + getPersonalWechatVoiceDiagnostic, + copyText } }) }) @@ -165,15 +176,13 @@ describe('PersonalWechatSendDialog', () => { expect(screen.getByText('验证消息能力')).toBeInTheDocument() expect(screen.getByText('能力检测')).toBeInTheDocument() expect(screen.getByText('图片和语音消息')).toBeInTheDocument() + expect(screen.getByRole('note')).toHaveTextContent( + '绑定微信可能导致当前微信异常闪退,这是正常现象。若微信退出,请重新启动微信后,再回到这里重新检测/绑定。' + ) fireEvent.click(screen.getByRole('button', { name: '查看支持的微信版本' })) const versionsDialog = screen.getByRole('dialog', { name: '支持的微信版本' }) expect(versionsDialog).toBeInTheDocument() expect(versionsDialog).toHaveTextContent('4.1.11.53') - expect( - screen.getByText( - '绑定微信可能导致当前微信异常闪退,这是正常现象。若微信退出,请重新启动微信后,再回到这里重新检测/绑定。' - ) - ).toBeInTheDocument() expect(screen.queryByLabelText('消息列表')).not.toBeInTheDocument() expect(screen.queryByText('TraceMemo 消息发送')).not.toBeInTheDocument() expect(screen.queryByText('PID 4668')).not.toBeVisible() @@ -232,6 +241,27 @@ describe('PersonalWechatSendDialog', () => { ) }) + it('sends the same generated voice through the comparison path', async () => { + renderDialog() + await startComposer() + fireEvent.click(screen.getByRole('radio', { name: '语音' })) + fireEvent.change(screen.getByRole('textbox', { name: '语音文字' }), { + target: { value: '入口2语音测试' } + }) + fireEvent.click(screen.getByRole('button', { name: '生成语音' })) + await waitFor(() => expect(screen.getByText('语音已生成')).toBeInTheDocument()) + fireEvent.click(screen.getByRole('button', { name: '发送2' })) + await waitFor(() => + expect(sendMessage).toHaveBeenCalledWith({ + type: 'voice', + to: 'fixture-room@chatroom', + filePath: '/tmp/generated.mp3', + isGroup: true, + voiceSendMode: 'legacy' + }) + ) + }) + it('uses the existing runtime, binding and detection IPC actions', async () => { getRuntimeStatus .mockResolvedValueOnce({ ...readyRuntime, state: 'missing', progress: 0 }) @@ -290,6 +320,9 @@ describe('PersonalWechatSendDialog', () => { }) renderDialog() expect(await screen.findByText('图片和语音消息')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: '绑定微信' })) + await screen.findByText('✓ 微信已绑定') + fireEvent.click(await screen.findByRole('button', { name: '重新检测' })) expect(screen.getByText('微信消息发送已配置完成')).toBeInTheDocument() expect(screen.getByRole('button', { name: '开始发送' })).toBeEnabled() expect(screen.queryByText('图片消息')).not.toBeInTheDocument() @@ -307,6 +340,8 @@ describe('PersonalWechatSendDialog', () => { message: '等待消息初始化' }) renderDialog() + fireEvent.click(await screen.findByRole('button', { name: '绑定微信' })) + await screen.findByText('✓ 微信已绑定') const detect = await screen.findByRole('button', { name: '重新检测' }) expect(detect).toBeEnabled() fireEvent.click(detect) @@ -329,4 +364,41 @@ describe('PersonalWechatSendDialog', () => { expect(screen.getByRole('button', { name: '正在发送…' })).toBeDisabled() expect(screen.getByRole('radio', { name: '图片' })).toBeDisabled() }) + + it('shows and copies the latest redacted voice diagnostic JSON', async () => { + getPersonalWechatVoiceDiagnostic.mockResolvedValue({ + request_id: 'request-1', + voice_id: 'request-1', + phase: 'completed', + encoder_name: 'go-silk', + encoder_version: 'wechat_chatter-v0.0.18', + input_bytes: 35107, + normalized_input_bytes: 69804, + pcm_size: 69760, + sample_rate: 16000, + channels: 1, + input_duration_ms: 2180, + upload_result: '0', + upload_data_len: 4380, + silk_duration_ms: 2180, + send_result: '1' + }) + renderDialog() + await startComposer() + fireEvent.click(screen.getByRole('button', { name: '语音发送诊断' })) + const diagnosticDialog = await screen.findByRole('dialog', { name: '语音发送诊断' }) + expect(diagnosticDialog).toHaveTextContent('"encoder_name": "go-silk"') + expect(diagnosticDialog).not.toHaveTextContent('aesKey') + fireEvent.click(screen.getByRole('button', { name: '复制诊断 JSON' })) + await waitFor(() => expect(copyText).toHaveBeenCalledWith(expect.stringContaining('request-1'))) + expect(screen.getByRole('button', { name: '已复制' })).toBeInTheDocument() + }) + + it('shows an empty state when no voice diagnostic exists', async () => { + renderDialog() + await startComposer() + fireEvent.click(screen.getByRole('button', { name: '语音发送诊断' })) + expect(await screen.findByText('暂无诊断信息')).toBeInTheDocument() + expect(screen.getByRole('button', { name: '复制诊断 JSON' })).toBeDisabled() + }) }) diff --git a/tests/integration/preload-contract.test.ts b/tests/integration/preload-contract.test.ts index b52d5eb..e8af1b5 100644 --- a/tests/integration/preload-contract.test.ts +++ b/tests/integration/preload-contract.test.ts @@ -108,6 +108,8 @@ describe('preload IPC contract', () => { } await api.sendPersonalWechatMessage(sendRequest) expect(invoke).toHaveBeenLastCalledWith('wechat-personal:send', sendRequest) + await api.getPersonalWechatVoiceDiagnostic() + expect(invoke).toHaveBeenLastCalledWith('wechat-personal:getVoiceDiagnostic') await api.selectPersonalWechatImage() expect(invoke).toHaveBeenLastCalledWith('wechat-personal:selectImage') }) diff --git a/tests/unit/personal-wechat-send-service.test.ts b/tests/unit/personal-wechat-send-service.test.ts index 407c886..a6ef669 100644 --- a/tests/unit/personal-wechat-send-service.test.ts +++ b/tests/unit/personal-wechat-send-service.test.ts @@ -11,6 +11,7 @@ vi.mock('electron', () => ({ })) import { + buildPersonalWechatVoiceDiagnostic, buildPersonalWechatOneBotRequest, findWechatImagePath, findPersonalWechatRuntime, @@ -32,6 +33,37 @@ afterEach(() => { }) describe('personal WeChat OneBot request', () => { + it('keeps voice diagnostics to the safe metadata allowlist', () => { + const diagnostic = buildPersonalWechatVoiceDiagnostic('request-1', 'completed', { + input_bytes: 10, + upload_result: '0', + error: undefined, + aesKey: 'secret', + cdnKey: 'secret', + token: 'secret', + runtime_log: 'private' + }) + expect(diagnostic).toMatchObject({ request_id: 'request-1', upload_result: '0' }) + expect(diagnostic).not.toHaveProperty('aesKey') + expect(diagnostic).not.toHaveProperty('cdnKey') + expect(diagnostic).not.toHaveProperty('token') + expect(diagnostic).not.toHaveProperty('runtime_log') + }) + + it('redacts secrets embedded in diagnostic errors', () => { + const diagnostic = buildPersonalWechatVoiceDiagnostic('request-2', 'failed', { + error: + 'upload failed aesKey=secret-value Bearer bearer-secret token:token-value {"cdnKey":"json-secret"}' + }) + expect(diagnostic.error).toContain('aesKey=[redacted]') + expect(diagnostic.error).toContain('Bearer [redacted]') + expect(diagnostic.error).toContain('token:[redacted]') + expect(diagnostic.error).not.toContain('secret-value') + expect(diagnostic.error).not.toContain('bearer-secret') + expect(diagnostic.error).not.toContain('token-value') + expect(diagnostic.error).not.toContain('json-secret') + }) + it('builds a private text message request', () => { expect( buildPersonalWechatOneBotRequest({ diff --git a/tests/unit/security-and-errors.test.ts b/tests/unit/security-and-errors.test.ts index 5e82dc1..08e9386 100644 --- a/tests/unit/security-and-errors.test.ts +++ b/tests/unit/security-and-errors.test.ts @@ -87,6 +87,14 @@ describe('personal WeChat runtime security invariants', () => { for (const source of [runtimeManagerSource, preparationScriptSource]) { expect(source).not.toContain('pilk==') expect(source).not.toMatch(/['"]pip['"]/) + expect(source).toContain('voiceAudioDataAddr = Memory.alloc(audioLen + 1);') + expect(source).toContain('上传前按语音长度重新分配') } + + const senderSource = readFileSync( + resolve('src/main/services/personal-wechat-send-service.ts'), + 'utf8' + ) + expect(senderSource).toContain('buildRuntimePythonPath(preflight.runtime.root)') }) }) diff --git a/tests/unit/voice-quality.test.ts b/tests/unit/voice-quality.test.ts new file mode 100644 index 0000000..bb84e58 --- /dev/null +++ b/tests/unit/voice-quality.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' +import { + VOICE_MIN_PCM_BYTES, + validateVoicePcm, + validateVoiceSilk, + validateVoiceSilkMetadata +} from '../../src/main/voice-pipeline/voice-quality' + +describe('voice quality gates', () => { + it('accepts a complete 20ms PCM frame', () => { + expect(validateVoicePcm(new Uint8Array(VOICE_MIN_PCM_BYTES))).toMatchObject({ + pcmSize: VOICE_MIN_PCM_BYTES, + sampleRate: 16_000, + channels: 1, + durationMs: 20 + }) + }) + + it('rejects empty, odd-sized and sub-frame PCM', () => { + expect(() => validateVoicePcm(new Uint8Array())).toThrow('PCM 为空') + expect(() => validateVoicePcm(new Uint8Array(3))).toThrow('长度无效') + expect(() => validateVoicePcm(new Uint8Array(VOICE_MIN_PCM_BYTES - 2))).toThrow('过短') + }) + + it('rejects a header-only Silk payload', () => { + expect(() => validateVoiceSilkMetadata(10, 20)).toThrow('只有文件头') + expect(() => validateVoiceSilk(new TextEncoder().encode('\x02#!SILK_V3'), 20)).toThrow( + '只有文件头' + ) + }) + + it('accepts valid Silk metadata', () => { + expect(validateVoiceSilkMetadata(128, 240)).toEqual({ silkSize: 128, durationMs: 240 }) + const silk = new Uint8Array(128) + silk.set(new TextEncoder().encode('\x02#!SILK_V3')) + expect(validateVoiceSilk(silk, 240)).toEqual({ silkSize: 128, durationMs: 240 }) + }) +})