mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-03 18:33:14 +08:00
fix: 修复转换微信语音发送逻辑
This commit is contained in:
@@ -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)
|
||||
|
||||
+10
-1
@@ -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!, {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<Buffer> {
|
||||
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<string, unknown>
|
||||
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<string, unknown>): Record<string, unknown> {
|
||||
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<string, unknown>,
|
||||
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<string, unknown> = {}
|
||||
): 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<PersonalWechatSenderStatus> | null = null
|
||||
private lastError = ''
|
||||
private voiceSendTail: Promise<void> = 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<PersonalWechatSenderStatus> {
|
||||
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<PersonalWechatSendResult> {
|
||||
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<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
await previous
|
||||
try {
|
||||
return await this.sendInternal(request, requestId)
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
}
|
||||
|
||||
private async sendInternal(
|
||||
request: PersonalWechatSendRequest,
|
||||
requestId: string
|
||||
): Promise<PersonalWechatSendResult> {
|
||||
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<void> {
|
||||
async terminate(force = false): Promise<void> {
|
||||
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 || '尚未绑定当前微信,可点击“绑定微信”'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
Vendored
+5
-1
@@ -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<PersonalWechatSenderStatus>
|
||||
getPersonalWechatKeepOneBotProcess: () => Promise<boolean>
|
||||
setPersonalWechatKeepOneBotProcess: (keep: boolean) => Promise<boolean>
|
||||
getPersonalWechatRuntimeStatus: () => Promise<PersonalWechatRuntimeStatus>
|
||||
downloadPersonalWechatRuntime: () => Promise<PersonalWechatRuntimeDownloadResult>
|
||||
cancelPersonalWechatRuntimeDownload: () => Promise<{ success: boolean }>
|
||||
@@ -595,6 +598,7 @@ declare global {
|
||||
sendPersonalWechatMessage: (
|
||||
request: PersonalWechatSendRequest
|
||||
) => Promise<PersonalWechatSendResult>
|
||||
getPersonalWechatVoiceDiagnostic: () => Promise<PersonalWechatVoiceDiagnostic | null>
|
||||
getAgentHubStatus: () => Promise<AgentHubStatus>
|
||||
getAgentHubLogs: () => Promise<AgentHubLogEntry[]>
|
||||
clearAgentHubLogs: () => Promise<void>
|
||||
|
||||
@@ -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<PersonalWechatSenderStatus> =>
|
||||
ipcRenderer.invoke('wechat-personal:getStatus'),
|
||||
getPersonalWechatKeepOneBotProcess: (): Promise<boolean> =>
|
||||
ipcRenderer.invoke('wechat-personal:getKeepProcess'),
|
||||
setPersonalWechatKeepOneBotProcess: (keep: boolean): Promise<boolean> =>
|
||||
ipcRenderer.invoke('wechat-personal:setKeepProcess', keep),
|
||||
getPersonalWechatRuntimeStatus: (): Promise<PersonalWechatRuntimeStatus> =>
|
||||
ipcRenderer.invoke('wechat-personal:getRuntimeStatus'),
|
||||
downloadPersonalWechatRuntime: (): Promise<PersonalWechatRuntimeDownloadResult> =>
|
||||
@@ -381,6 +386,8 @@ const api = {
|
||||
sendPersonalWechatMessage: (
|
||||
request: PersonalWechatSendRequest
|
||||
): Promise<PersonalWechatSendResult> => ipcRenderer.invoke('wechat-personal:send', request),
|
||||
getPersonalWechatVoiceDiagnostic: (): Promise<PersonalWechatVoiceDiagnostic | null> =>
|
||||
ipcRenderer.invoke('wechat-personal:getVoiceDiagnostic'),
|
||||
getAgentHubStatus: () => ipcRenderer.invoke('agent-hub:getStatus'),
|
||||
getAgentHubLogs: () => ipcRenderer.invoke('agent-hub:getLogs'),
|
||||
clearAgentHubLogs: () => ipcRenderer.invoke('agent-hub:clearLogs'),
|
||||
|
||||
@@ -206,7 +206,7 @@ export function PersonalWechatChatComposer({
|
||||
|
||||
const sendGenerated = async (): Promise<void> => {
|
||||
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<void> => {
|
||||
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({
|
||||
<span style={{ width: `${Math.min(100, previewProgress)}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="personal-wechat-generated-result-actions">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -362,6 +375,15 @@ export function PersonalWechatChatComposer({
|
||||
<Button size="sm" onClick={() => void sendGenerated()} disabled={!canSend}>
|
||||
{busy ? '发送中…' : '发送'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void sendGeneratedAlternative()}
|
||||
disabled={!canSend}
|
||||
title="使用另一条语音发送路径"
|
||||
>
|
||||
{busy ? '发送中…' : '空白语音?重新处理'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -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<ChatMessage[]>([])
|
||||
const [sendError, setSendError] = useState<string | null>(null)
|
||||
const [voiceDiagnostic, setVoiceDiagnostic] = useState<PersonalWechatVoiceDiagnostic | null>(null)
|
||||
const [voiceDiagnosticOpen, setVoiceDiagnosticOpen] = useState(false)
|
||||
const [keepOneBotProcess, setKeepOneBotProcess] = useState(false)
|
||||
const [composerStarted, setComposerStarted] = useState(Boolean(initialImage))
|
||||
const requestIdRef = useRef(0)
|
||||
const restoreFocusRef = useRef<HTMLElement | null>(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<void> => {
|
||||
@@ -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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
const diagnostic = await window.api.getPersonalWechatVoiceDiagnostic()
|
||||
setVoiceDiagnostic(diagnostic)
|
||||
setVoiceDiagnosticOpen(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && handleClose()}>
|
||||
<DialogContent
|
||||
className="personal-wechat-send-dialog max-h-[calc(100vh-2rem)] max-w-[720px] gap-0 overflow-y-auto p-0"
|
||||
onOpenAutoFocus={() => {
|
||||
restoreFocusRef.current =
|
||||
document.activeElement instanceof HTMLElement ? document.activeElement : null
|
||||
}}
|
||||
onEscapeKeyDown={(event) => isBusy && event.preventDefault()}
|
||||
onPointerDownOutside={(event) => isBusy && event.preventDefault()}
|
||||
>
|
||||
<DialogHeader className="personal-wechat-chat-header">
|
||||
<div className="personal-wechat-chat-avatar" aria-hidden>
|
||||
{displayName.slice(0, 1)}
|
||||
</div>
|
||||
<div className="personal-wechat-chat-heading">
|
||||
<DialogTitle>{displayName}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isGroupChat ? '群聊' : '联系人'} · {setupReady ? '微信已连接' : '配置微信消息发送'}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
<span
|
||||
className={`personal-wechat-connection-dot ${setupReady ? 'is-online' : ''}`}
|
||||
aria-label={setupReady ? '微信已连接' : '微信尚未配置'}
|
||||
/>
|
||||
</DialogHeader>
|
||||
<>
|
||||
<Dialog open onOpenChange={(open) => !open && handleClose()}>
|
||||
<DialogContent
|
||||
className="personal-wechat-send-dialog max-h-[calc(100vh-2rem)] max-w-[720px] gap-0 overflow-y-auto p-0"
|
||||
onOpenAutoFocus={() => {
|
||||
restoreFocusRef.current =
|
||||
document.activeElement instanceof HTMLElement ? document.activeElement : null
|
||||
}}
|
||||
onEscapeKeyDown={(event) => isBusy && event.preventDefault()}
|
||||
onPointerDownOutside={(event) => isBusy && event.preventDefault()}
|
||||
>
|
||||
<DialogHeader className="personal-wechat-chat-header">
|
||||
<div className="personal-wechat-chat-avatar" aria-hidden>
|
||||
{displayName.slice(0, 1)}
|
||||
</div>
|
||||
<div className="personal-wechat-chat-heading">
|
||||
<DialogTitle>{displayName}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isGroupChat ? '群聊' : '联系人'} · {setupReady ? '微信已连接' : '配置微信消息发送'}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
<span
|
||||
className={`personal-wechat-connection-dot ${setupReady ? 'is-online' : ''}`}
|
||||
aria-label={setupReady ? '微信已连接' : '微信尚未配置'}
|
||||
/>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="personal-wechat-chat-body">
|
||||
<div className="personal-wechat-chat-body">
|
||||
{setupReady && composerStarted && (
|
||||
<div className="personal-wechat-message-list" aria-label="消息列表">
|
||||
{messages.length === 0 ? (
|
||||
<div className="personal-wechat-empty-message">还没有发送消息。</div>
|
||||
) : (
|
||||
messages.map((message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
className={`personal-wechat-message-bubble ${message.outgoing ? 'is-outgoing' : ''}`}
|
||||
>
|
||||
<span className="personal-wechat-message-kind">
|
||||
{message.type === 'text'
|
||||
? '文字'
|
||||
: message.type === 'image'
|
||||
? '图片'
|
||||
: '语音'}
|
||||
</span>
|
||||
<span>{message.text || message.fileName}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(!setupReady || !composerStarted) && senderStatus && (
|
||||
<PersonalWechatSetupGuide
|
||||
runtimeStatus={runtimeStatus}
|
||||
senderStatus={senderStatus}
|
||||
runtimeProgress={runtimeProgress}
|
||||
runtimeBusy={runtimeBusy}
|
||||
binding={binding}
|
||||
detecting={detecting}
|
||||
sessionBound={sessionBound}
|
||||
onDownloadRuntime={() => void handleDownloadRuntime()}
|
||||
onBind={() => void handleBind()}
|
||||
detectionAttempted={detectionAttempted}
|
||||
onDetect={() => void handleDetect()}
|
||||
onStartSending={() => setComposerStarted(true)}
|
||||
onOpenTextToSpeechSettings={handleOpenSettings}
|
||||
/>
|
||||
)}
|
||||
|
||||
{setupReady && composerStarted && (
|
||||
<PersonalWechatChatComposer
|
||||
status={senderStatus!}
|
||||
targetId={targetId}
|
||||
isGroupChat={isGroupChat}
|
||||
initialMode={initialMode}
|
||||
initialImage={initialImage}
|
||||
onOpenTextToSpeechSettings={handleOpenSettings}
|
||||
onCancel={handleClose}
|
||||
onSend={handleSend}
|
||||
onMessage={(message) => setMessages((current) => [...current, message])}
|
||||
busy={sendBusy}
|
||||
/>
|
||||
)}
|
||||
{sendError && (
|
||||
<div className="personal-wechat-global-error" role="alert">
|
||||
{sendError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{setupReady && composerStarted && (
|
||||
<div className="personal-wechat-message-list" aria-label="消息列表">
|
||||
{messages.length === 0 ? (
|
||||
<div className="personal-wechat-empty-message">还没有发送消息。</div>
|
||||
) : (
|
||||
messages.map((message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
className={`personal-wechat-message-bubble ${message.outgoing ? 'is-outgoing' : ''}`}
|
||||
>
|
||||
<span className="personal-wechat-message-kind">
|
||||
{message.type === 'text'
|
||||
? '文字'
|
||||
: message.type === 'image'
|
||||
? '图片'
|
||||
: '语音'}
|
||||
</span>
|
||||
<span>{message.text || message.fileName}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<div className="personal-wechat-chat-footer flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>保留 OneBot 进程</span>
|
||||
<Switch
|
||||
checked={keepOneBotProcess}
|
||||
onCheckedChange={(checked) => void handleKeepOneBotProcessChange(checked)}
|
||||
aria-label="保留 OneBot 进程"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="link" size="sm" onClick={() => void handleOpenVoiceDiagnostic()}>
|
||||
语音发送诊断
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(!setupReady || !composerStarted) && senderStatus && (
|
||||
<PersonalWechatSetupGuide
|
||||
runtimeStatus={runtimeStatus}
|
||||
senderStatus={senderStatus}
|
||||
runtimeProgress={runtimeProgress}
|
||||
runtimeBusy={runtimeBusy}
|
||||
binding={binding}
|
||||
detecting={detecting}
|
||||
onDownloadRuntime={() => void handleDownloadRuntime()}
|
||||
onBind={() => void handleBind()}
|
||||
detectionAttempted={detectionAttempted}
|
||||
onDetect={() => void handleDetect()}
|
||||
onStartSending={() => setComposerStarted(true)}
|
||||
onOpenTextToSpeechSettings={handleOpenSettings}
|
||||
/>
|
||||
)}
|
||||
|
||||
{setupReady && composerStarted && (
|
||||
<PersonalWechatChatComposer
|
||||
status={senderStatus!}
|
||||
targetId={targetId}
|
||||
isGroupChat={isGroupChat}
|
||||
initialMode={initialMode}
|
||||
initialImage={initialImage}
|
||||
onOpenTextToSpeechSettings={handleOpenSettings}
|
||||
onCancel={handleClose}
|
||||
onSend={handleSend}
|
||||
onMessage={(message) => setMessages((current) => [...current, message])}
|
||||
busy={sendBusy}
|
||||
/>
|
||||
)}
|
||||
{sendError && (
|
||||
<div className="personal-wechat-global-error" role="alert">
|
||||
{sendError}
|
||||
{(!setupReady || !composerStarted) && (
|
||||
<div className="personal-wechat-chat-footer flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>保留 OneBot 进程</span>
|
||||
<Switch
|
||||
checked={keepOneBotProcess}
|
||||
onCheckedChange={(checked) => void handleKeepOneBotProcessChange(checked)}
|
||||
aria-label="保留 OneBot 进程"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" onClick={handleClose} disabled={isBusy}>
|
||||
关闭
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{(!setupReady || !composerStarted) && (
|
||||
<div className="personal-wechat-chat-footer">
|
||||
<Button variant="outline" onClick={handleClose} disabled={isBusy}>
|
||||
关闭
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<PersonalWechatVoiceDiagnosticDialog
|
||||
open={voiceDiagnosticOpen}
|
||||
diagnostic={voiceDiagnostic}
|
||||
onOpenChange={setVoiceDiagnosticOpen}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
<p className="personal-wechat-step-warning" role="note">
|
||||
绑定微信可能导致当前微信异常闪退,这是正常现象。若微信退出,请重新启动微信后,再回到这里重新检测/绑定。
|
||||
<br />
|
||||
微信总是自动更新?请在微信左下角打开“设置 → 通用”,取消勾选“有更新时自动升级微信”,否则版本变化后可能无法绑定。
|
||||
微信总是自动更新?请在微信左下角打开“设置 →
|
||||
通用”,取消勾选“有更新时自动升级微信”,否则版本变化后可能无法绑定。
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
@@ -188,7 +193,7 @@ export function PersonalWechatSetupGuide({
|
||||
<p className="personal-wechat-step-hint">
|
||||
TraceMemo 需要通过你主动发送的消息初始化微信消息能力。完成后点击“重新检测”。
|
||||
</p>
|
||||
{!verificationComplete && (
|
||||
{connected && !verificationComplete && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -214,8 +219,8 @@ export function PersonalWechatSetupGuide({
|
||||
<strong>能力检测</strong>
|
||||
<div className="personal-wechat-capabilities" aria-label="微信消息能力">
|
||||
{[
|
||||
['文字消息', canSendText],
|
||||
['图片和语音消息', mediaReady]
|
||||
['文字消息', detectedTextReady],
|
||||
['图片和语音消息', detectedMediaReady]
|
||||
].map(([label, ready]) => (
|
||||
<span key={String(label)} className={ready ? 'is-ready' : ''}>
|
||||
<b aria-hidden>{ready ? '✓' : '−'}</b>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import type { PersonalWechatVoiceDiagnostic } from '../../../../shared/personal-wechat'
|
||||
import { Button, Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '../ui'
|
||||
|
||||
interface PersonalWechatVoiceDiagnosticDialogProps {
|
||||
open: boolean
|
||||
diagnostic: PersonalWechatVoiceDiagnostic | null
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function PersonalWechatVoiceDiagnosticDialog({
|
||||
open,
|
||||
diagnostic,
|
||||
onOpenChange
|
||||
}: PersonalWechatVoiceDiagnosticDialogProps): React.ReactElement {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const json = useMemo(() => (diagnostic ? JSON.stringify(diagnostic, null, 2) : ''), [diagnostic])
|
||||
|
||||
const copyDiagnostic = async (): Promise<void> => {
|
||||
if (!json) return
|
||||
const result = await window.api.copyText(json)
|
||||
setCopied(result.success)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (nextOpen) setCopied(false)
|
||||
onOpenChange(nextOpen)
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-[560px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>语音发送诊断</DialogTitle>
|
||||
</DialogHeader>
|
||||
{json ? (
|
||||
<pre className="max-h-[min(50vh,420px)] overflow-auto rounded-md border border-border-subtle bg-muted/40 p-3 text-xs leading-5 text-foreground">
|
||||
{json}
|
||||
</pre>
|
||||
) : (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">暂无诊断信息</p>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
关闭
|
||||
</Button>
|
||||
<Button onClick={() => void copyDiagnostic()} disabled={!json}>
|
||||
{copied ? '已复制' : '复制诊断 JSON'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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<void> {
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user