mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-04 03:01:42 +08:00
fix: 修复转换微信语音发送逻辑
This commit is contained in:
@@ -123,6 +123,30 @@ function patchPerSendPayload(scriptPath) {
|
|||||||
console.log('[wechat-personal] 已应用逐条发送 payload 隔离补丁')
|
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) {
|
function patchImageHookReadiness(scriptPath) {
|
||||||
let source = fs.readFileSync(scriptPath, 'utf8')
|
let source = fs.readFileSync(scriptPath, 'utf8')
|
||||||
if (
|
if (
|
||||||
@@ -182,7 +206,8 @@ function addModifiedWorkNotice(scriptPath) {
|
|||||||
* Upstream: https://github.com/yincongcyincong/wechat_chatter
|
* Upstream: https://github.com/yincongcyincong/wechat_chatter
|
||||||
* Runtime version: v0.0.18
|
* Runtime version: v0.0.18
|
||||||
* License: GNU General Public License version 3 (GPL-3.0)
|
* 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.
|
* These modifications are not provided by the upstream author.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -194,6 +219,7 @@ function addModifiedWorkNotice(scriptPath) {
|
|||||||
|
|
||||||
patchWechatCoreModuleBase(script)
|
patchWechatCoreModuleBase(script)
|
||||||
patchPerSendPayload(script)
|
patchPerSendPayload(script)
|
||||||
|
patchVoiceAudioBuffer(script)
|
||||||
patchImageHookReadiness(script)
|
patchImageHookReadiness(script)
|
||||||
addModifiedWorkNotice(script)
|
addModifiedWorkNotice(script)
|
||||||
fs.chmodSync(executable, 0o755)
|
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:reconnect', () => agentHubService.reconnect())
|
||||||
ipcMain.handle('agent-hub:disconnect', () => agentHubService.disconnect())
|
ipcMain.handle('agent-hub:disconnect', () => agentHubService.disconnect())
|
||||||
ipcMain.handle('wechat-personal:getStatus', () => personalWechatSendService.getStatus())
|
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:getRuntimeStatus', () => personalWechatRuntimeManager.getStatus())
|
||||||
ipcMain.handle('wechat-personal:downloadRuntime', () => personalWechatRuntimeManager.download())
|
ipcMain.handle('wechat-personal:downloadRuntime', () => personalWechatRuntimeManager.download())
|
||||||
ipcMain.handle('wechat-personal:cancelRuntimeDownload', () => ({
|
ipcMain.handle('wechat-personal:cancelRuntimeDownload', () => ({
|
||||||
success: personalWechatRuntimeManager.cancelDownload()
|
success: personalWechatRuntimeManager.cancelDownload()
|
||||||
}))
|
}))
|
||||||
ipcMain.handle('wechat-personal:removeRuntime', async () => {
|
ipcMain.handle('wechat-personal:removeRuntime', async () => {
|
||||||
await personalWechatSendService.terminate()
|
await personalWechatSendService.terminate(true)
|
||||||
return personalWechatRuntimeManager.remove()
|
return personalWechatRuntimeManager.remove()
|
||||||
})
|
})
|
||||||
ipcMain.handle('wechat-personal:openRuntimeDirectory', async () => {
|
ipcMain.handle('wechat-personal:openRuntimeDirectory', async () => {
|
||||||
@@ -1797,6 +1803,9 @@ app.whenReady().then(async () => {
|
|||||||
ipcMain.handle('wechat-personal:send', (_, request: PersonalWechatSendRequest) =>
|
ipcMain.handle('wechat-personal:send', (_, request: PersonalWechatSendRequest) =>
|
||||||
personalWechatSendService.send(request)
|
personalWechatSendService.send(request)
|
||||||
)
|
)
|
||||||
|
ipcMain.handle('wechat-personal:getVoiceDiagnostic', () =>
|
||||||
|
personalWechatSendService.getLatestVoiceDiagnostic()
|
||||||
|
)
|
||||||
ipcMain.handle('wechat-personal:selectImage', async (event) => {
|
ipcMain.handle('wechat-personal:selectImage', async (event) => {
|
||||||
const window = BrowserWindow.fromWebContents(event.sender)
|
const window = BrowserWindow.fromWebContents(event.sender)
|
||||||
const result = await dialog.showOpenDialog(window!, {
|
const result = await dialog.showOpenDialog(window!, {
|
||||||
|
|||||||
@@ -57,6 +57,31 @@ function patchPerSendPayload(scriptPath: string): void {
|
|||||||
writeFileSync(scriptPath, source)
|
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 {
|
function patchImageHookReadiness(scriptPath: string): void {
|
||||||
let source = readFileSync(scriptPath, 'utf8')
|
let source = readFileSync(scriptPath, 'utf8')
|
||||||
if (
|
if (
|
||||||
@@ -114,7 +139,8 @@ function addModifiedWorkNotice(scriptPath: string): void {
|
|||||||
* Upstream: https://github.com/yincongcyincong/wechat_chatter
|
* Upstream: https://github.com/yincongcyincong/wechat_chatter
|
||||||
* Runtime version: v0.0.18
|
* Runtime version: v0.0.18
|
||||||
* License: GNU General Public License version 3 (GPL-3.0)
|
* 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.
|
* These modifications are not provided by the upstream author.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -159,6 +185,12 @@ export class PersonalWechatRuntimeManager {
|
|||||||
|
|
||||||
const runtime = findPersonalWechatRuntime()
|
const runtime = findPersonalWechatRuntime()
|
||||||
if (runtime) {
|
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)
|
return this.buildStatus('ready', ARCHIVE_SIZE, undefined, runtime.root)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,6 +293,7 @@ export class PersonalWechatRuntimeManager {
|
|||||||
const script = join(stagedDirectory, 'onebot', 'script.js')
|
const script = join(stagedDirectory, 'onebot', 'script.js')
|
||||||
patchWechatCoreModuleBase(script)
|
patchWechatCoreModuleBase(script)
|
||||||
patchPerSendPayload(script)
|
patchPerSendPayload(script)
|
||||||
|
patchVoiceAudioBuffer(script)
|
||||||
patchImageHookReadiness(script)
|
patchImageHookReadiness(script)
|
||||||
addModifiedWorkNotice(script)
|
addModifiedWorkNotice(script)
|
||||||
await chmod(executable, 0o755)
|
await chmod(executable, 0o755)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { app } from 'electron'
|
import { app } from 'electron'
|
||||||
import { execFile, spawn, type ChildProcess } from 'child_process'
|
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 { existsSync, readFileSync, readdirSync, statSync } from 'fs'
|
||||||
import { createConnection } from 'net'
|
import { createConnection } from 'net'
|
||||||
import { homedir } from 'os'
|
import { homedir } from 'os'
|
||||||
@@ -10,10 +10,19 @@ import { promisify } from 'util'
|
|||||||
import type {
|
import type {
|
||||||
PersonalWechatSendRequest,
|
PersonalWechatSendRequest,
|
||||||
PersonalWechatSendResult,
|
PersonalWechatSendResult,
|
||||||
PersonalWechatSenderStatus
|
PersonalWechatSenderStatus,
|
||||||
|
PersonalWechatVoiceDiagnostic
|
||||||
} from '../../shared/personal-wechat'
|
} from '../../shared/personal-wechat'
|
||||||
import { isPackagedRuntime } from '../runtime-mode'
|
import { isPackagedRuntime } from '../runtime-mode'
|
||||||
|
import { loadSettings, updateSettings } from './settings-store'
|
||||||
import { SilkAudioDecoder } from '../voice-pipeline/audio-decoder'
|
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 execFileAsync = promisify(execFile)
|
||||||
const DEFAULT_HOST = '127.0.0.1:58080'
|
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 STOP_TIMEOUT_MS = 3_000
|
||||||
const MAX_IMAGE_BYTES = 20 * 1024 * 1024
|
const MAX_IMAGE_BYTES = 20 * 1024 * 1024
|
||||||
const MAX_VOICE_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_APP_PATH = '/Applications/WeChat.app'
|
||||||
const WECHAT_FILES_ROOT = join(
|
const WECHAT_FILES_ROOT = join(
|
||||||
homedir(),
|
homedir(),
|
||||||
@@ -239,6 +252,221 @@ function buildRuntimePythonPath(runtimeRoot: string): string {
|
|||||||
.join(delimiter)
|
.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 {
|
function createWavBuffer(pcm: Buffer, sampleRate: number, channels: number): Buffer {
|
||||||
const header = Buffer.alloc(44)
|
const header = Buffer.alloc(44)
|
||||||
header.write('RIFF', 0)
|
header.write('RIFF', 0)
|
||||||
@@ -415,6 +643,18 @@ export class PersonalWechatSendService {
|
|||||||
private child: ChildProcess | null = null
|
private child: ChildProcess | null = null
|
||||||
private startPromise: Promise<PersonalWechatSenderStatus> | null = null
|
private startPromise: Promise<PersonalWechatSenderStatus> | null = null
|
||||||
private lastError = ''
|
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> {
|
async getStatus(): Promise<PersonalWechatSenderStatus> {
|
||||||
const preflight = await this.preflight()
|
const preflight = await this.preflight()
|
||||||
@@ -449,7 +689,7 @@ export class PersonalWechatSendService {
|
|||||||
canSend: false,
|
canSend: false,
|
||||||
canSendText: false,
|
canSendText: false,
|
||||||
canSendImage: false,
|
canSendImage: false,
|
||||||
message: 'OneBot 仍绑定旧微信进程,请点击“尝试重新绑定”'
|
message: 'OneBot 仍绑定旧微信进程,请点击“绑定微信”'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (hook.readiness === 'failed') {
|
if (hook.readiness === 'failed') {
|
||||||
@@ -459,7 +699,7 @@ export class PersonalWechatSendService {
|
|||||||
canSend: false,
|
canSend: false,
|
||||||
canSendText: false,
|
canSendText: false,
|
||||||
canSendImage: false,
|
canSendImage: false,
|
||||||
message: '微信发送 Hook 初始化失败,请尝试重新绑定',
|
message: '微信发送 Hook 初始化失败,请点击“绑定微信”',
|
||||||
...(hook.error ? { error: hook.error } : {})
|
...(hook.error ? { error: hook.error } : {})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -477,7 +717,7 @@ export class PersonalWechatSendService {
|
|||||||
canSendVoice,
|
canSendVoice,
|
||||||
message:
|
message:
|
||||||
hook.imageHookReady && preflight.status.imagePath && !imagePathBound
|
hook.imageHookReady && preflight.status.imagePath && !imagePathBound
|
||||||
? 'OneBot 尚未绑定微信图片目录,请点击“尝试重新绑定”'
|
? 'OneBot 尚未绑定微信图片目录,请点击“绑定微信”'
|
||||||
: canSendText || canSendImage || canSendVoice
|
: canSendText || canSendImage || canSendVoice
|
||||||
? '个人微信已绑定,可使用已初始化的消息类型'
|
? '个人微信已绑定,可使用已初始化的消息类型'
|
||||||
: '个人微信已绑定,发送前请先在微信中手动初始化对应消息类型',
|
: '个人微信已绑定,发送前请先在微信中手动初始化对应消息类型',
|
||||||
@@ -485,13 +725,42 @@ export class PersonalWechatSendService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getLatestVoiceDiagnostic(): PersonalWechatVoiceDiagnostic | null {
|
||||||
|
return latestVoiceDiagnostic ? { ...latestVoiceDiagnostic } : null
|
||||||
|
}
|
||||||
|
|
||||||
async send(request: PersonalWechatSendRequest): Promise<PersonalWechatSendResult> {
|
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()
|
const to = String(request?.to || '').trim()
|
||||||
if (!to) {
|
if (!to) {
|
||||||
const status = await this.getStatus()
|
const status = await this.getStatus()
|
||||||
return { success: false, status, error: '接收者不能为空' }
|
return { success: false, status, error: '接收者不能为空' }
|
||||||
}
|
}
|
||||||
let fileBase64: string | undefined
|
let fileBase64: string | undefined
|
||||||
|
let voicePcm: VoicePcmMetadata | undefined
|
||||||
|
let runtimeSnapshot: { path: string; offset: number } | undefined
|
||||||
if (request.type === 'text') {
|
if (request.type === 'text') {
|
||||||
const text = String(request.text || '').trim()
|
const text = String(request.text || '').trim()
|
||||||
if (!text) {
|
if (!text) {
|
||||||
@@ -523,9 +792,45 @@ export class PersonalWechatSendService {
|
|||||||
error: `${request.type === 'voice' ? '语音' : '图片'}必须小于 20 MB`
|
error: `${request.type === 'voice' ? '语音' : '图片'}必须小于 20 MB`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fileBase64 = (
|
let fileData: Buffer
|
||||||
request.type === 'voice' ? await prepareVoiceFile(filePath) : readFileSync(filePath)
|
try {
|
||||||
).toString('base64')
|
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 }
|
request = { ...request, to, filePath }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -547,6 +852,7 @@ export class PersonalWechatSendService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const oneBot = buildPersonalWechatOneBotRequest(request, fileBase64)
|
const oneBot = buildPersonalWechatOneBotRequest(request, fileBase64)
|
||||||
|
if (request.type === 'voice') runtimeSnapshot = await runtimeVoiceLogSnapshot()
|
||||||
try {
|
try {
|
||||||
const response = await requestWithTimeout(
|
const response = await requestWithTimeout(
|
||||||
`http://${DEFAULT_HOST}${oneBot.endpoint}`,
|
`http://${DEFAULT_HOST}${oneBot.endpoint}`,
|
||||||
@@ -561,9 +867,83 @@ export class PersonalWechatSendService {
|
|||||||
if (!response.ok) throw new Error(responseText || `HTTP ${response.status}`)
|
if (!response.ok) throw new Error(responseText || `HTTP ${response.status}`)
|
||||||
const parsed = responseText ? (JSON.parse(responseText) as { status?: string }) : {}
|
const parsed = responseText ? (JSON.parse(responseText) as { status?: string }) : {}
|
||||||
if (parsed.status && parsed.status !== 'ok') throw new Error(responseText)
|
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() }
|
return { success: true, status: await this.getStatus() }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.lastError = error instanceof Error ? error.message : String(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()
|
const failedStatus = await this.getStatus()
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
@@ -614,7 +994,13 @@ export class PersonalWechatSendService {
|
|||||||
return status
|
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 trackedPid = this.child?.pid
|
||||||
const oneBot = await readOneBotProcessInfo()
|
const oneBot = await readOneBotProcessInfo()
|
||||||
if (oneBot) await terminateOneBot(oneBot)
|
if (oneBot) await terminateOneBot(oneBot)
|
||||||
@@ -817,7 +1203,7 @@ export class PersonalWechatSendService {
|
|||||||
configPath,
|
configPath,
|
||||||
...(imagePath ? { imagePath } : {}),
|
...(imagePath ? { imagePath } : {}),
|
||||||
state: this.child && this.child.exitCode === null ? 'starting' : 'stopped',
|
state: this.child && this.child.exitCode === null ? 'starting' : 'stopped',
|
||||||
message: this.lastError || '尚未绑定当前微信,可点击“尝试重新绑定”'
|
message: this.lastError || '尚未绑定当前微信,可点击“绑定微信”'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ export interface AppSettings {
|
|||||||
showStartupProgress: boolean
|
showStartupProgress: boolean
|
||||||
ttsSelectedVoiceId: string
|
ttsSelectedVoiceId: string
|
||||||
ttsModel: TextToSpeechModel
|
ttsModel: TextToSpeechModel
|
||||||
|
/** Keep a running personal-WeChat OneBot process across app restarts. */
|
||||||
|
keepPersonalWechatProcess?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDefaultDbRoot(): string {
|
function getDefaultDbRoot(): string {
|
||||||
@@ -120,7 +122,8 @@ const DEFAULT_SETTINGS: AppSettings = {
|
|||||||
compactMode: false,
|
compactMode: false,
|
||||||
showStartupProgress: true,
|
showStartupProgress: true,
|
||||||
ttsSelectedVoiceId: '',
|
ttsSelectedVoiceId: '',
|
||||||
ttsModel: 's2.1-pro-free'
|
ttsModel: 's2.1-pro-free',
|
||||||
|
keepPersonalWechatProcess: false
|
||||||
}
|
}
|
||||||
|
|
||||||
const SETTINGS_FILE = path.join(
|
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,
|
PersonalWechatVoiceSelectionResult,
|
||||||
PersonalWechatSendRequest,
|
PersonalWechatSendRequest,
|
||||||
PersonalWechatSendResult,
|
PersonalWechatSendResult,
|
||||||
PersonalWechatSenderStatus
|
PersonalWechatSenderStatus,
|
||||||
|
PersonalWechatVoiceDiagnostic
|
||||||
} from '../shared/personal-wechat'
|
} from '../shared/personal-wechat'
|
||||||
import type {
|
import type {
|
||||||
PersonalWechatRuntimeDownloadResult,
|
PersonalWechatRuntimeDownloadResult,
|
||||||
@@ -581,6 +582,8 @@ declare global {
|
|||||||
limit?: number
|
limit?: number
|
||||||
) => Promise<{ success: boolean; insights: ImageInsight[] }>
|
) => Promise<{ success: boolean; insights: ImageInsight[] }>
|
||||||
getPersonalWechatSenderStatus: () => Promise<PersonalWechatSenderStatus>
|
getPersonalWechatSenderStatus: () => Promise<PersonalWechatSenderStatus>
|
||||||
|
getPersonalWechatKeepOneBotProcess: () => Promise<boolean>
|
||||||
|
setPersonalWechatKeepOneBotProcess: (keep: boolean) => Promise<boolean>
|
||||||
getPersonalWechatRuntimeStatus: () => Promise<PersonalWechatRuntimeStatus>
|
getPersonalWechatRuntimeStatus: () => Promise<PersonalWechatRuntimeStatus>
|
||||||
downloadPersonalWechatRuntime: () => Promise<PersonalWechatRuntimeDownloadResult>
|
downloadPersonalWechatRuntime: () => Promise<PersonalWechatRuntimeDownloadResult>
|
||||||
cancelPersonalWechatRuntimeDownload: () => Promise<{ success: boolean }>
|
cancelPersonalWechatRuntimeDownload: () => Promise<{ success: boolean }>
|
||||||
@@ -595,6 +598,7 @@ declare global {
|
|||||||
sendPersonalWechatMessage: (
|
sendPersonalWechatMessage: (
|
||||||
request: PersonalWechatSendRequest
|
request: PersonalWechatSendRequest
|
||||||
) => Promise<PersonalWechatSendResult>
|
) => Promise<PersonalWechatSendResult>
|
||||||
|
getPersonalWechatVoiceDiagnostic: () => Promise<PersonalWechatVoiceDiagnostic | null>
|
||||||
getAgentHubStatus: () => Promise<AgentHubStatus>
|
getAgentHubStatus: () => Promise<AgentHubStatus>
|
||||||
getAgentHubLogs: () => Promise<AgentHubLogEntry[]>
|
getAgentHubLogs: () => Promise<AgentHubLogEntry[]>
|
||||||
clearAgentHubLogs: () => Promise<void>
|
clearAgentHubLogs: () => Promise<void>
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ import type {
|
|||||||
PersonalWechatVoiceSelectionResult,
|
PersonalWechatVoiceSelectionResult,
|
||||||
PersonalWechatSendRequest,
|
PersonalWechatSendRequest,
|
||||||
PersonalWechatSendResult,
|
PersonalWechatSendResult,
|
||||||
PersonalWechatSenderStatus
|
PersonalWechatSenderStatus,
|
||||||
|
PersonalWechatVoiceDiagnostic
|
||||||
} from '../shared/personal-wechat'
|
} from '../shared/personal-wechat'
|
||||||
import type {
|
import type {
|
||||||
PersonalWechatRuntimeDownloadResult,
|
PersonalWechatRuntimeDownloadResult,
|
||||||
@@ -352,6 +353,10 @@ const api = {
|
|||||||
ipcRenderer.invoke('image:listInsights', sessionId, limit),
|
ipcRenderer.invoke('image:listInsights', sessionId, limit),
|
||||||
getPersonalWechatSenderStatus: (): Promise<PersonalWechatSenderStatus> =>
|
getPersonalWechatSenderStatus: (): Promise<PersonalWechatSenderStatus> =>
|
||||||
ipcRenderer.invoke('wechat-personal:getStatus'),
|
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> =>
|
getPersonalWechatRuntimeStatus: (): Promise<PersonalWechatRuntimeStatus> =>
|
||||||
ipcRenderer.invoke('wechat-personal:getRuntimeStatus'),
|
ipcRenderer.invoke('wechat-personal:getRuntimeStatus'),
|
||||||
downloadPersonalWechatRuntime: (): Promise<PersonalWechatRuntimeDownloadResult> =>
|
downloadPersonalWechatRuntime: (): Promise<PersonalWechatRuntimeDownloadResult> =>
|
||||||
@@ -381,6 +386,8 @@ const api = {
|
|||||||
sendPersonalWechatMessage: (
|
sendPersonalWechatMessage: (
|
||||||
request: PersonalWechatSendRequest
|
request: PersonalWechatSendRequest
|
||||||
): Promise<PersonalWechatSendResult> => ipcRenderer.invoke('wechat-personal:send', request),
|
): Promise<PersonalWechatSendResult> => ipcRenderer.invoke('wechat-personal:send', request),
|
||||||
|
getPersonalWechatVoiceDiagnostic: (): Promise<PersonalWechatVoiceDiagnostic | null> =>
|
||||||
|
ipcRenderer.invoke('wechat-personal:getVoiceDiagnostic'),
|
||||||
getAgentHubStatus: () => ipcRenderer.invoke('agent-hub:getStatus'),
|
getAgentHubStatus: () => ipcRenderer.invoke('agent-hub:getStatus'),
|
||||||
getAgentHubLogs: () => ipcRenderer.invoke('agent-hub:getLogs'),
|
getAgentHubLogs: () => ipcRenderer.invoke('agent-hub:getLogs'),
|
||||||
clearAgentHubLogs: () => ipcRenderer.invoke('agent-hub:clearLogs'),
|
clearAgentHubLogs: () => ipcRenderer.invoke('agent-hub:clearLogs'),
|
||||||
|
|||||||
@@ -206,7 +206,7 @@ export function PersonalWechatChatComposer({
|
|||||||
|
|
||||||
const sendGenerated = async (): Promise<void> => {
|
const sendGenerated = async (): Promise<void> => {
|
||||||
if (!generatedVoiceRef.current) return
|
if (!generatedVoiceRef.current) return
|
||||||
const result = await send(
|
await send(
|
||||||
{
|
{
|
||||||
type: 'voice',
|
type: 'voice',
|
||||||
to: targetId,
|
to: targetId,
|
||||||
@@ -215,7 +215,20 @@ export function PersonalWechatChatComposer({
|
|||||||
},
|
},
|
||||||
voiceText.trim() || '语音消息'
|
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(
|
const canSend = Boolean(
|
||||||
@@ -341,7 +354,7 @@ export function PersonalWechatChatComposer({
|
|||||||
<span style={{ width: `${Math.min(100, previewProgress)}%` }} />
|
<span style={{ width: `${Math.min(100, previewProgress)}%` }} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="personal-wechat-generated-result-actions">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -362,6 +375,15 @@ export function PersonalWechatChatComposer({
|
|||||||
<Button size="sm" onClick={() => void sendGenerated()} disabled={!canSend}>
|
<Button size="sm" onClick={() => void sendGenerated()} disabled={!canSend}>
|
||||||
{busy ? '发送中…' : '发送'}
|
{busy ? '发送中…' : '发送'}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void sendGeneratedAlternative()}
|
||||||
|
disabled={!canSend}
|
||||||
|
title="使用另一条语音发送路径"
|
||||||
|
>
|
||||||
|
{busy ? '发送中…' : '空白语音?重新处理'}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -2,19 +2,29 @@ import { useCallback, useEffect, useRef, useState } from 'react'
|
|||||||
import type { Contact } from '../../../../shared/types'
|
import type { Contact } from '../../../../shared/types'
|
||||||
import type {
|
import type {
|
||||||
PersonalWechatSendRequest,
|
PersonalWechatSendRequest,
|
||||||
PersonalWechatSenderStatus
|
PersonalWechatSenderStatus,
|
||||||
|
PersonalWechatVoiceDiagnostic
|
||||||
} from '../../../../shared/personal-wechat'
|
} from '../../../../shared/personal-wechat'
|
||||||
import type {
|
import type {
|
||||||
PersonalWechatRuntimeProgressEvent,
|
PersonalWechatRuntimeProgressEvent,
|
||||||
PersonalWechatRuntimeStatus
|
PersonalWechatRuntimeStatus
|
||||||
} from '../../../../shared/personal-wechat-runtime'
|
} 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 {
|
import {
|
||||||
PersonalWechatChatComposer,
|
PersonalWechatChatComposer,
|
||||||
type ChatMessage,
|
type ChatMessage,
|
||||||
type PersonalWechatComposerMode
|
type PersonalWechatComposerMode
|
||||||
} from './PersonalWechatChatComposer'
|
} from './PersonalWechatChatComposer'
|
||||||
import { PersonalWechatSetupGuide } from './PersonalWechatSetupGuide'
|
import { PersonalWechatSetupGuide } from './PersonalWechatSetupGuide'
|
||||||
|
import { PersonalWechatVoiceDiagnosticDialog } from './PersonalWechatVoiceDiagnosticDialog'
|
||||||
|
|
||||||
type SelectedLocalFile = { path: string; name: string }
|
type SelectedLocalFile = { path: string; name: string }
|
||||||
|
|
||||||
@@ -71,8 +81,15 @@ export function PersonalWechatSendDialog({
|
|||||||
const [runtimeBusy, setRuntimeBusy] = useState(false)
|
const [runtimeBusy, setRuntimeBusy] = useState(false)
|
||||||
const [sendBusy, setSendBusy] = useState(false)
|
const [sendBusy, setSendBusy] = useState(false)
|
||||||
const [detectionAttempted, setDetectionAttempted] = 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 [messages, setMessages] = useState<ChatMessage[]>([])
|
||||||
const [sendError, setSendError] = useState<string | null>(null)
|
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 [composerStarted, setComposerStarted] = useState(Boolean(initialImage))
|
||||||
const requestIdRef = useRef(0)
|
const requestIdRef = useRef(0)
|
||||||
const restoreFocusRef = useRef<HTMLElement | null>(null)
|
const restoreFocusRef = useRef<HTMLElement | null>(null)
|
||||||
@@ -81,7 +98,10 @@ export function PersonalWechatSendDialog({
|
|||||||
const targetId = contact.m_nsUsrName
|
const targetId = contact.m_nsUsrName
|
||||||
const isBusy = binding || runtimeBusy || sendBusy
|
const isBusy = binding || runtimeBusy || sendBusy
|
||||||
const setupReady = Boolean(
|
const setupReady = Boolean(
|
||||||
senderStatus?.canSendText && (senderStatus?.canSendImage || senderStatus?.canSendVoice)
|
sessionBound &&
|
||||||
|
detectionAttempted &&
|
||||||
|
senderStatus?.canSendText &&
|
||||||
|
(senderStatus?.canSendImage || senderStatus?.canSendVoice)
|
||||||
)
|
)
|
||||||
|
|
||||||
const refreshStatus = useCallback(async (): Promise<void> => {
|
const refreshStatus = useCallback(async (): Promise<void> => {
|
||||||
@@ -97,6 +117,15 @@ export function PersonalWechatSendDialog({
|
|||||||
setRuntimeStatus(nextRuntime)
|
setRuntimeStatus(nextRuntime)
|
||||||
setRuntimeProgress(nextRuntime?.state === 'downloading' ? nextRuntime : null)
|
setRuntimeProgress(nextRuntime?.state === 'downloading' ? nextRuntime : null)
|
||||||
setSenderStatus(nextSender)
|
setSenderStatus(nextSender)
|
||||||
|
setSessionBound(
|
||||||
|
nextSender.state === 'online' ||
|
||||||
|
Boolean(
|
||||||
|
nextSender.wechatPid &&
|
||||||
|
nextSender.boundWechatPid === nextSender.wechatPid &&
|
||||||
|
nextSender.attachReady &&
|
||||||
|
nextSender.baseAddressReady
|
||||||
|
)
|
||||||
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (requestId === requestIdRef.current) setSenderStatus(fallbackStatus(error))
|
if (requestId === requestIdRef.current) setSenderStatus(fallbackStatus(error))
|
||||||
} finally {
|
} finally {
|
||||||
@@ -114,6 +143,34 @@ export function PersonalWechatSendDialog({
|
|||||||
return unsubscribe
|
return unsubscribe
|
||||||
}, [refreshStatus])
|
}, [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> => {
|
const handleDownloadRuntime = async (): Promise<void> => {
|
||||||
if (runtimeBusy) return
|
if (runtimeBusy) return
|
||||||
setRuntimeBusy(true)
|
setRuntimeBusy(true)
|
||||||
@@ -138,6 +195,15 @@ export function PersonalWechatSendDialog({
|
|||||||
try {
|
try {
|
||||||
const nextStatus = await window.api.rebindPersonalWechatSender()
|
const nextStatus = await window.api.rebindPersonalWechatSender()
|
||||||
setSenderStatus(nextStatus)
|
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)
|
if (nextStatus.state !== 'online' && nextStatus.message) setSendError(nextStatus.message)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setSendError(error instanceof Error ? error.message : String(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 (
|
return (
|
||||||
<Dialog open onOpenChange={(open) => !open && handleClose()}>
|
<>
|
||||||
<DialogContent
|
<Dialog open onOpenChange={(open) => !open && handleClose()}>
|
||||||
className="personal-wechat-send-dialog max-h-[calc(100vh-2rem)] max-w-[720px] gap-0 overflow-y-auto p-0"
|
<DialogContent
|
||||||
onOpenAutoFocus={() => {
|
className="personal-wechat-send-dialog max-h-[calc(100vh-2rem)] max-w-[720px] gap-0 overflow-y-auto p-0"
|
||||||
restoreFocusRef.current =
|
onOpenAutoFocus={() => {
|
||||||
document.activeElement instanceof HTMLElement ? document.activeElement : null
|
restoreFocusRef.current =
|
||||||
}}
|
document.activeElement instanceof HTMLElement ? document.activeElement : null
|
||||||
onEscapeKeyDown={(event) => isBusy && event.preventDefault()}
|
}}
|
||||||
onPointerDownOutside={(event) => isBusy && event.preventDefault()}
|
onEscapeKeyDown={(event) => isBusy && event.preventDefault()}
|
||||||
>
|
onPointerDownOutside={(event) => isBusy && event.preventDefault()}
|
||||||
<DialogHeader className="personal-wechat-chat-header">
|
>
|
||||||
<div className="personal-wechat-chat-avatar" aria-hidden>
|
<DialogHeader className="personal-wechat-chat-header">
|
||||||
{displayName.slice(0, 1)}
|
<div className="personal-wechat-chat-avatar" aria-hidden>
|
||||||
</div>
|
{displayName.slice(0, 1)}
|
||||||
<div className="personal-wechat-chat-heading">
|
</div>
|
||||||
<DialogTitle>{displayName}</DialogTitle>
|
<div className="personal-wechat-chat-heading">
|
||||||
<DialogDescription>
|
<DialogTitle>{displayName}</DialogTitle>
|
||||||
{isGroupChat ? '群聊' : '联系人'} · {setupReady ? '微信已连接' : '配置微信消息发送'}
|
<DialogDescription>
|
||||||
</DialogDescription>
|
{isGroupChat ? '群聊' : '联系人'} · {setupReady ? '微信已连接' : '配置微信消息发送'}
|
||||||
</div>
|
</DialogDescription>
|
||||||
<span
|
</div>
|
||||||
className={`personal-wechat-connection-dot ${setupReady ? 'is-online' : ''}`}
|
<span
|
||||||
aria-label={setupReady ? '微信已连接' : '微信尚未配置'}
|
className={`personal-wechat-connection-dot ${setupReady ? 'is-online' : ''}`}
|
||||||
/>
|
aria-label={setupReady ? '微信已连接' : '微信尚未配置'}
|
||||||
</DialogHeader>
|
/>
|
||||||
|
</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 && (
|
{setupReady && composerStarted && (
|
||||||
<div className="personal-wechat-message-list" aria-label="消息列表">
|
<div className="personal-wechat-chat-footer flex items-center justify-between">
|
||||||
{messages.length === 0 ? (
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
<div className="personal-wechat-empty-message">还没有发送消息。</div>
|
<span>保留 OneBot 进程</span>
|
||||||
) : (
|
<Switch
|
||||||
messages.map((message) => (
|
checked={keepOneBotProcess}
|
||||||
<div
|
onCheckedChange={(checked) => void handleKeepOneBotProcessChange(checked)}
|
||||||
key={message.id}
|
aria-label="保留 OneBot 进程"
|
||||||
className={`personal-wechat-message-bubble ${message.outgoing ? 'is-outgoing' : ''}`}
|
/>
|
||||||
>
|
</div>
|
||||||
<span className="personal-wechat-message-kind">
|
<Button variant="link" size="sm" onClick={() => void handleOpenVoiceDiagnostic()}>
|
||||||
{message.type === 'text'
|
语音发送诊断
|
||||||
? '文字'
|
</Button>
|
||||||
: message.type === 'image'
|
|
||||||
? '图片'
|
|
||||||
: '语音'}
|
|
||||||
</span>
|
|
||||||
<span>{message.text || message.fileName}</span>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{(!setupReady || !composerStarted) && (
|
||||||
{(!setupReady || !composerStarted) && senderStatus && (
|
<div className="personal-wechat-chat-footer flex items-center justify-between">
|
||||||
<PersonalWechatSetupGuide
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
runtimeStatus={runtimeStatus}
|
<span>保留 OneBot 进程</span>
|
||||||
senderStatus={senderStatus}
|
<Switch
|
||||||
runtimeProgress={runtimeProgress}
|
checked={keepOneBotProcess}
|
||||||
runtimeBusy={runtimeBusy}
|
onCheckedChange={(checked) => void handleKeepOneBotProcessChange(checked)}
|
||||||
binding={binding}
|
aria-label="保留 OneBot 进程"
|
||||||
detecting={detecting}
|
/>
|
||||||
onDownloadRuntime={() => void handleDownloadRuntime()}
|
</div>
|
||||||
onBind={() => void handleBind()}
|
<Button variant="outline" onClick={handleClose} disabled={isBusy}>
|
||||||
detectionAttempted={detectionAttempted}
|
关闭
|
||||||
onDetect={() => void handleDetect()}
|
</Button>
|
||||||
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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</DialogContent>
|
||||||
{(!setupReady || !composerStarted) && (
|
</Dialog>
|
||||||
<div className="personal-wechat-chat-footer">
|
<PersonalWechatVoiceDiagnosticDialog
|
||||||
<Button variant="outline" onClick={handleClose} disabled={isBusy}>
|
open={voiceDiagnosticOpen}
|
||||||
关闭
|
diagnostic={voiceDiagnostic}
|
||||||
</Button>
|
onOpenChange={setVoiceDiagnosticOpen}
|
||||||
</div>
|
/>
|
||||||
)}
|
</>
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ interface PersonalWechatSetupGuideProps {
|
|||||||
binding: boolean
|
binding: boolean
|
||||||
detecting: boolean
|
detecting: boolean
|
||||||
detectionAttempted: boolean
|
detectionAttempted: boolean
|
||||||
|
sessionBound: boolean
|
||||||
onDownloadRuntime: () => void
|
onDownloadRuntime: () => void
|
||||||
onBind: () => void
|
onBind: () => void
|
||||||
onDetect: () => void
|
onDetect: () => void
|
||||||
@@ -61,6 +62,7 @@ export function PersonalWechatSetupGuide({
|
|||||||
binding,
|
binding,
|
||||||
detecting,
|
detecting,
|
||||||
detectionAttempted,
|
detectionAttempted,
|
||||||
|
sessionBound,
|
||||||
onDownloadRuntime,
|
onDownloadRuntime,
|
||||||
onBind,
|
onBind,
|
||||||
onDetect,
|
onDetect,
|
||||||
@@ -70,12 +72,14 @@ export function PersonalWechatSetupGuide({
|
|||||||
const [showSupportedVersions, setShowSupportedVersions] = useState(false)
|
const [showSupportedVersions, setShowSupportedVersions] = useState(false)
|
||||||
const runtimeReady = runtimeStatus?.state === 'ready' || senderStatus?.runtimeReady === true
|
const runtimeReady = runtimeStatus?.state === 'ready' || senderStatus?.runtimeReady === true
|
||||||
const runtimeDownloading = runtimeBusy || runtimeStatus?.state === 'downloading'
|
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 canSendText = Boolean(senderStatus?.canSendText)
|
||||||
const canSendImage = Boolean(senderStatus?.canSendImage)
|
const canSendImage = Boolean(senderStatus?.canSendImage)
|
||||||
const canSendVoice = Boolean(senderStatus?.canSendVoice)
|
const canSendVoice = Boolean(senderStatus?.canSendVoice)
|
||||||
const mediaReady = canSendImage || 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 allReady = verificationComplete
|
||||||
const progress = runtimeProgress || runtimeStatus
|
const progress = runtimeProgress || runtimeStatus
|
||||||
const progressPercent = Math.max(0, Math.min(100, Math.round((progress?.progress || 0) * 100)))
|
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">
|
<p className="personal-wechat-step-warning" role="note">
|
||||||
绑定微信可能导致当前微信异常闪退,这是正常现象。若微信退出,请重新启动微信后,再回到这里重新检测/绑定。
|
绑定微信可能导致当前微信异常闪退,这是正常现象。若微信退出,请重新启动微信后,再回到这里重新检测/绑定。
|
||||||
<br />
|
<br />
|
||||||
微信总是自动更新?请在微信左下角打开“设置 → 通用”,取消勾选“有更新时自动升级微信”,否则版本变化后可能无法绑定。
|
微信总是自动更新?请在微信左下角打开“设置 →
|
||||||
|
通用”,取消勾选“有更新时自动升级微信”,否则版本变化后可能无法绑定。
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
@@ -188,7 +193,7 @@ export function PersonalWechatSetupGuide({
|
|||||||
<p className="personal-wechat-step-hint">
|
<p className="personal-wechat-step-hint">
|
||||||
TraceMemo 需要通过你主动发送的消息初始化微信消息能力。完成后点击“重新检测”。
|
TraceMemo 需要通过你主动发送的消息初始化微信消息能力。完成后点击“重新检测”。
|
||||||
</p>
|
</p>
|
||||||
{!verificationComplete && (
|
{connected && !verificationComplete && (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -214,8 +219,8 @@ export function PersonalWechatSetupGuide({
|
|||||||
<strong>能力检测</strong>
|
<strong>能力检测</strong>
|
||||||
<div className="personal-wechat-capabilities" aria-label="微信消息能力">
|
<div className="personal-wechat-capabilities" aria-label="微信消息能力">
|
||||||
{[
|
{[
|
||||||
['文字消息', canSendText],
|
['文字消息', detectedTextReady],
|
||||||
['图片和语音消息', mediaReady]
|
['图片和语音消息', detectedMediaReady]
|
||||||
].map(([label, ready]) => (
|
].map(([label, ready]) => (
|
||||||
<span key={String(label)} className={ready ? 'is-ready' : ''}>
|
<span key={String(label)} className={ready ? 'is-ready' : ''}>
|
||||||
<b aria-hidden>{ready ? '✓' : '−'}</b>
|
<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 {
|
export interface PersonalWechatSendVoiceRequest extends PersonalWechatSendBaseRequest {
|
||||||
type: 'voice'
|
type: 'voice'
|
||||||
filePath: string
|
filePath: string
|
||||||
|
/** Internal comparison switch for the temporary voice regression test. */
|
||||||
|
voiceSendMode?: 'normalized' | 'legacy'
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PersonalWechatSendRequest =
|
export type PersonalWechatSendRequest =
|
||||||
@@ -75,6 +77,28 @@ export interface PersonalWechatSendResult {
|
|||||||
error?: string
|
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 {
|
export interface PersonalWechatImageSelectionResult {
|
||||||
canceled: boolean
|
canceled: boolean
|
||||||
path?: string
|
path?: string
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ const getTextToSpeechSettings = vi.fn()
|
|||||||
const listTextToSpeechVoices = vi.fn()
|
const listTextToSpeechVoices = vi.fn()
|
||||||
const synthesizeTextToSpeech = vi.fn()
|
const synthesizeTextToSpeech = vi.fn()
|
||||||
const removeGeneratedTextToSpeechAudio = vi.fn()
|
const removeGeneratedTextToSpeechAudio = vi.fn()
|
||||||
|
const getPersonalWechatVoiceDiagnostic = vi.fn()
|
||||||
|
const copyText = vi.fn()
|
||||||
|
|
||||||
const contact = {
|
const contact = {
|
||||||
m_nsUsrName: 'fixture-room@chatroom',
|
m_nsUsrName: 'fixture-room@chatroom',
|
||||||
@@ -71,6 +73,11 @@ function renderDialog(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function startComposer(): Promise<void> {
|
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: '开始发送' })
|
const start = await screen.findByRole('button', { name: '开始发送' })
|
||||||
fireEvent.click(start)
|
fireEvent.click(start)
|
||||||
}
|
}
|
||||||
@@ -125,6 +132,8 @@ describe('PersonalWechatSendDialog', () => {
|
|||||||
audioDataUrl: 'data:audio/mpeg;base64,fixture'
|
audioDataUrl: 'data:audio/mpeg;base64,fixture'
|
||||||
})
|
})
|
||||||
removeGeneratedTextToSpeechAudio.mockReset().mockResolvedValue({ success: true })
|
removeGeneratedTextToSpeechAudio.mockReset().mockResolvedValue({ success: true })
|
||||||
|
getPersonalWechatVoiceDiagnostic.mockReset().mockResolvedValue(null)
|
||||||
|
copyText.mockReset().mockResolvedValue({ success: true })
|
||||||
Object.defineProperty(window, 'api', {
|
Object.defineProperty(window, 'api', {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
value: {
|
value: {
|
||||||
@@ -139,7 +148,9 @@ describe('PersonalWechatSendDialog', () => {
|
|||||||
getTextToSpeechSettings,
|
getTextToSpeechSettings,
|
||||||
listTextToSpeechVoices,
|
listTextToSpeechVoices,
|
||||||
synthesizeTextToSpeech,
|
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.getByText('能力检测')).toBeInTheDocument()
|
||||||
expect(screen.getByText('图片和语音消息')).toBeInTheDocument()
|
expect(screen.getByText('图片和语音消息')).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('note')).toHaveTextContent(
|
||||||
|
'绑定微信可能导致当前微信异常闪退,这是正常现象。若微信退出,请重新启动微信后,再回到这里重新检测/绑定。'
|
||||||
|
)
|
||||||
fireEvent.click(screen.getByRole('button', { name: '查看支持的微信版本' }))
|
fireEvent.click(screen.getByRole('button', { name: '查看支持的微信版本' }))
|
||||||
const versionsDialog = screen.getByRole('dialog', { name: '支持的微信版本' })
|
const versionsDialog = screen.getByRole('dialog', { name: '支持的微信版本' })
|
||||||
expect(versionsDialog).toBeInTheDocument()
|
expect(versionsDialog).toBeInTheDocument()
|
||||||
expect(versionsDialog).toHaveTextContent('4.1.11.53')
|
expect(versionsDialog).toHaveTextContent('4.1.11.53')
|
||||||
expect(
|
|
||||||
screen.getByText(
|
|
||||||
'绑定微信可能导致当前微信异常闪退,这是正常现象。若微信退出,请重新启动微信后,再回到这里重新检测/绑定。'
|
|
||||||
)
|
|
||||||
).toBeInTheDocument()
|
|
||||||
expect(screen.queryByLabelText('消息列表')).not.toBeInTheDocument()
|
expect(screen.queryByLabelText('消息列表')).not.toBeInTheDocument()
|
||||||
expect(screen.queryByText('TraceMemo 消息发送')).not.toBeInTheDocument()
|
expect(screen.queryByText('TraceMemo 消息发送')).not.toBeInTheDocument()
|
||||||
expect(screen.queryByText('PID 4668')).not.toBeVisible()
|
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 () => {
|
it('uses the existing runtime, binding and detection IPC actions', async () => {
|
||||||
getRuntimeStatus
|
getRuntimeStatus
|
||||||
.mockResolvedValueOnce({ ...readyRuntime, state: 'missing', progress: 0 })
|
.mockResolvedValueOnce({ ...readyRuntime, state: 'missing', progress: 0 })
|
||||||
@@ -290,6 +320,9 @@ describe('PersonalWechatSendDialog', () => {
|
|||||||
})
|
})
|
||||||
renderDialog()
|
renderDialog()
|
||||||
expect(await screen.findByText('图片和语音消息')).toBeInTheDocument()
|
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.getByText('微信消息发送已配置完成')).toBeInTheDocument()
|
||||||
expect(screen.getByRole('button', { name: '开始发送' })).toBeEnabled()
|
expect(screen.getByRole('button', { name: '开始发送' })).toBeEnabled()
|
||||||
expect(screen.queryByText('图片消息')).not.toBeInTheDocument()
|
expect(screen.queryByText('图片消息')).not.toBeInTheDocument()
|
||||||
@@ -307,6 +340,8 @@ describe('PersonalWechatSendDialog', () => {
|
|||||||
message: '等待消息初始化'
|
message: '等待消息初始化'
|
||||||
})
|
})
|
||||||
renderDialog()
|
renderDialog()
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: '绑定微信' }))
|
||||||
|
await screen.findByText('✓ 微信已绑定')
|
||||||
const detect = await screen.findByRole('button', { name: '重新检测' })
|
const detect = await screen.findByRole('button', { name: '重新检测' })
|
||||||
expect(detect).toBeEnabled()
|
expect(detect).toBeEnabled()
|
||||||
fireEvent.click(detect)
|
fireEvent.click(detect)
|
||||||
@@ -329,4 +364,41 @@ describe('PersonalWechatSendDialog', () => {
|
|||||||
expect(screen.getByRole('button', { name: '正在发送…' })).toBeDisabled()
|
expect(screen.getByRole('button', { name: '正在发送…' })).toBeDisabled()
|
||||||
expect(screen.getByRole('radio', { 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)
|
await api.sendPersonalWechatMessage(sendRequest)
|
||||||
expect(invoke).toHaveBeenLastCalledWith('wechat-personal:send', sendRequest)
|
expect(invoke).toHaveBeenLastCalledWith('wechat-personal:send', sendRequest)
|
||||||
|
await api.getPersonalWechatVoiceDiagnostic()
|
||||||
|
expect(invoke).toHaveBeenLastCalledWith('wechat-personal:getVoiceDiagnostic')
|
||||||
await api.selectPersonalWechatImage()
|
await api.selectPersonalWechatImage()
|
||||||
expect(invoke).toHaveBeenLastCalledWith('wechat-personal:selectImage')
|
expect(invoke).toHaveBeenLastCalledWith('wechat-personal:selectImage')
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ vi.mock('electron', () => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
buildPersonalWechatVoiceDiagnostic,
|
||||||
buildPersonalWechatOneBotRequest,
|
buildPersonalWechatOneBotRequest,
|
||||||
findWechatImagePath,
|
findWechatImagePath,
|
||||||
findPersonalWechatRuntime,
|
findPersonalWechatRuntime,
|
||||||
@@ -32,6 +33,37 @@ afterEach(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('personal WeChat OneBot request', () => {
|
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', () => {
|
it('builds a private text message request', () => {
|
||||||
expect(
|
expect(
|
||||||
buildPersonalWechatOneBotRequest({
|
buildPersonalWechatOneBotRequest({
|
||||||
|
|||||||
@@ -87,6 +87,14 @@ describe('personal WeChat runtime security invariants', () => {
|
|||||||
for (const source of [runtimeManagerSource, preparationScriptSource]) {
|
for (const source of [runtimeManagerSource, preparationScriptSource]) {
|
||||||
expect(source).not.toContain('pilk==')
|
expect(source).not.toContain('pilk==')
|
||||||
expect(source).not.toMatch(/['"]pip['"]/)
|
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