@openclaw/qqbot 2026.5.2 → 2026.5.3-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (203) hide show
  1. package/dist/api.js +646 -0
  2. package/dist/approval-cg0SVahb.js +94 -0
  3. package/dist/channel-N6Y_Rcjp.js +551 -0
  4. package/dist/channel-plugin-api.js +2 -0
  5. package/dist/channel.setup-ByCRAe5H.js +25 -0
  6. package/dist/config-D6545NkC.js +94 -0
  7. package/dist/config-schema-DFcjQw73.js +309 -0
  8. package/dist/exec-approvals-COUsM6wZ.js +138 -0
  9. package/dist/gateway-CmSUJKSt.js +6726 -0
  10. package/dist/handler-runtime-Bqm6N0WG.js +120 -0
  11. package/dist/index.js +29 -0
  12. package/dist/narrowing-BoieBTIU.js +25 -0
  13. package/dist/outbound-BJfhwrPg.js +1357 -0
  14. package/dist/request-context-DXtpwWui.js +921 -0
  15. package/dist/resolve-D_06fV6-.js +284 -0
  16. package/dist/runtime-BJAS3eXW.js +18 -0
  17. package/dist/runtime-api.js +2 -0
  18. package/dist/secret-contract-api.js +56 -0
  19. package/dist/sender-p-B14eLG.js +1987 -0
  20. package/dist/setup-entry.js +15 -0
  21. package/dist/setup-plugin-api.js +2 -0
  22. package/dist/string-normalize-Ci6NM5DE.js +98 -0
  23. package/dist/target-parser-Y0prnrXD.js +245 -0
  24. package/package.json +15 -6
  25. package/api.ts +0 -56
  26. package/channel-plugin-api.ts +0 -1
  27. package/index.ts +0 -29
  28. package/runtime-api.ts +0 -9
  29. package/setup-entry.ts +0 -9
  30. package/setup-plugin-api.ts +0 -3
  31. package/src/bridge/approval/capability.ts +0 -237
  32. package/src/bridge/approval/handler-runtime.ts +0 -204
  33. package/src/bridge/bootstrap.ts +0 -135
  34. package/src/bridge/channel-entry.ts +0 -18
  35. package/src/bridge/commands/framework-context-adapter.ts +0 -60
  36. package/src/bridge/commands/framework-registration.ts +0 -47
  37. package/src/bridge/commands/from-parser.test.ts +0 -86
  38. package/src/bridge/commands/from-parser.ts +0 -60
  39. package/src/bridge/commands/result-dispatcher.ts +0 -76
  40. package/src/bridge/config-shared.ts +0 -132
  41. package/src/bridge/config.ts +0 -111
  42. package/src/bridge/gateway.ts +0 -174
  43. package/src/bridge/logger.ts +0 -31
  44. package/src/bridge/narrowing.ts +0 -31
  45. package/src/bridge/plugin-version.test.ts +0 -146
  46. package/src/bridge/plugin-version.ts +0 -102
  47. package/src/bridge/runtime.ts +0 -25
  48. package/src/bridge/sdk-adapter.ts +0 -131
  49. package/src/bridge/setup/finalize.ts +0 -144
  50. package/src/bridge/setup/surface.ts +0 -34
  51. package/src/bridge/tools/channel.ts +0 -58
  52. package/src/bridge/tools/index.ts +0 -15
  53. package/src/bridge/tools/remind.test.ts +0 -124
  54. package/src/bridge/tools/remind.ts +0 -91
  55. package/src/channel.setup.ts +0 -33
  56. package/src/channel.ts +0 -288
  57. package/src/command-auth.test.ts +0 -62
  58. package/src/config-schema.ts +0 -84
  59. package/src/config.test.ts +0 -364
  60. package/src/engine/access/access-control.test.ts +0 -198
  61. package/src/engine/access/access-control.ts +0 -226
  62. package/src/engine/access/index.ts +0 -16
  63. package/src/engine/access/resolve-policy.test.ts +0 -59
  64. package/src/engine/access/resolve-policy.ts +0 -57
  65. package/src/engine/access/sender-match.test.ts +0 -60
  66. package/src/engine/access/sender-match.ts +0 -55
  67. package/src/engine/access/types.ts +0 -53
  68. package/src/engine/adapter/audio.port.ts +0 -27
  69. package/src/engine/adapter/commands.port.ts +0 -22
  70. package/src/engine/adapter/history.port.ts +0 -52
  71. package/src/engine/adapter/index.ts +0 -139
  72. package/src/engine/adapter/mention-gate.port.ts +0 -50
  73. package/src/engine/adapter/types.ts +0 -38
  74. package/src/engine/api/api-client.ts +0 -212
  75. package/src/engine/api/media-chunked.test.ts +0 -336
  76. package/src/engine/api/media-chunked.ts +0 -622
  77. package/src/engine/api/media.ts +0 -218
  78. package/src/engine/api/messages.ts +0 -293
  79. package/src/engine/api/retry.ts +0 -217
  80. package/src/engine/api/routes.ts +0 -95
  81. package/src/engine/api/token.ts +0 -271
  82. package/src/engine/approval/index.test.ts +0 -22
  83. package/src/engine/approval/index.ts +0 -224
  84. package/src/engine/commands/builtin/log-helpers.ts +0 -319
  85. package/src/engine/commands/builtin/register-all.ts +0 -17
  86. package/src/engine/commands/builtin/register-approve.ts +0 -201
  87. package/src/engine/commands/builtin/register-basic.ts +0 -95
  88. package/src/engine/commands/builtin/register-clear-storage.ts +0 -187
  89. package/src/engine/commands/builtin/register-logs.ts +0 -20
  90. package/src/engine/commands/builtin/register-streaming.ts +0 -137
  91. package/src/engine/commands/builtin/state.ts +0 -31
  92. package/src/engine/commands/slash-command-auth.ts +0 -48
  93. package/src/engine/commands/slash-command-handler.ts +0 -146
  94. package/src/engine/commands/slash-commands-impl.test.ts +0 -8
  95. package/src/engine/commands/slash-commands-impl.ts +0 -61
  96. package/src/engine/commands/slash-commands.ts +0 -199
  97. package/src/engine/config/credential-backup.test.ts +0 -88
  98. package/src/engine/config/credential-backup.ts +0 -107
  99. package/src/engine/config/credentials.ts +0 -76
  100. package/src/engine/config/group.test.ts +0 -234
  101. package/src/engine/config/group.ts +0 -299
  102. package/src/engine/config/resolve.test.ts +0 -152
  103. package/src/engine/config/resolve.ts +0 -283
  104. package/src/engine/config/setup-logic.ts +0 -84
  105. package/src/engine/engine-import-boundary.test.ts +0 -73
  106. package/src/engine/gateway/codec.ts +0 -47
  107. package/src/engine/gateway/constants.ts +0 -117
  108. package/src/engine/gateway/event-dispatcher.ts +0 -177
  109. package/src/engine/gateway/gateway-connection.ts +0 -371
  110. package/src/engine/gateway/gateway.ts +0 -291
  111. package/src/engine/gateway/inbound-attachments.test.ts +0 -126
  112. package/src/engine/gateway/inbound-attachments.ts +0 -360
  113. package/src/engine/gateway/inbound-context.ts +0 -195
  114. package/src/engine/gateway/inbound-pipeline.self-echo.test.ts +0 -218
  115. package/src/engine/gateway/inbound-pipeline.ts +0 -235
  116. package/src/engine/gateway/interaction-handler.ts +0 -220
  117. package/src/engine/gateway/message-queue.test.ts +0 -282
  118. package/src/engine/gateway/message-queue.ts +0 -499
  119. package/src/engine/gateway/outbound-dispatch.test.ts +0 -231
  120. package/src/engine/gateway/outbound-dispatch.ts +0 -575
  121. package/src/engine/gateway/reconnect.ts +0 -199
  122. package/src/engine/gateway/stages/access-stage.ts +0 -132
  123. package/src/engine/gateway/stages/assembly-stage.ts +0 -156
  124. package/src/engine/gateway/stages/content-stage.test.ts +0 -77
  125. package/src/engine/gateway/stages/content-stage.ts +0 -77
  126. package/src/engine/gateway/stages/envelope-stage.test.ts +0 -152
  127. package/src/engine/gateway/stages/envelope-stage.ts +0 -144
  128. package/src/engine/gateway/stages/group-gate-stage.ts +0 -292
  129. package/src/engine/gateway/stages/index.ts +0 -18
  130. package/src/engine/gateway/stages/quote-stage.ts +0 -113
  131. package/src/engine/gateway/stages/refidx-stage.ts +0 -62
  132. package/src/engine/gateway/stages/stub-contexts.ts +0 -116
  133. package/src/engine/gateway/types.ts +0 -264
  134. package/src/engine/gateway/typing-keepalive.ts +0 -79
  135. package/src/engine/group/activation.test.ts +0 -114
  136. package/src/engine/group/activation.ts +0 -147
  137. package/src/engine/group/history.test.ts +0 -314
  138. package/src/engine/group/history.ts +0 -321
  139. package/src/engine/group/mention.test.ts +0 -141
  140. package/src/engine/group/mention.ts +0 -197
  141. package/src/engine/group/message-gating.test.ts +0 -188
  142. package/src/engine/group/message-gating.ts +0 -216
  143. package/src/engine/messaging/decode-media-path.ts +0 -82
  144. package/src/engine/messaging/media-source.ts +0 -215
  145. package/src/engine/messaging/media-type-detect.ts +0 -37
  146. package/src/engine/messaging/outbound-audio-port.ts +0 -38
  147. package/src/engine/messaging/outbound-deliver.ts +0 -810
  148. package/src/engine/messaging/outbound-media-send.ts +0 -702
  149. package/src/engine/messaging/outbound-reply.ts +0 -27
  150. package/src/engine/messaging/outbound-result-helpers.ts +0 -54
  151. package/src/engine/messaging/outbound-types.ts +0 -45
  152. package/src/engine/messaging/outbound.ts +0 -485
  153. package/src/engine/messaging/reply-dispatcher.ts +0 -597
  154. package/src/engine/messaging/reply-limiter.ts +0 -164
  155. package/src/engine/messaging/sender.ts +0 -729
  156. package/src/engine/messaging/streaming-c2c.ts +0 -1192
  157. package/src/engine/messaging/streaming-media-send.ts +0 -544
  158. package/src/engine/messaging/target-parser.ts +0 -104
  159. package/src/engine/ref/format-message-ref.ts +0 -142
  160. package/src/engine/ref/format-ref-entry.test.ts +0 -60
  161. package/src/engine/ref/format-ref-entry.ts +0 -27
  162. package/src/engine/ref/store.ts +0 -208
  163. package/src/engine/ref/types.ts +0 -27
  164. package/src/engine/session/known-users.ts +0 -137
  165. package/src/engine/session/session-store.ts +0 -204
  166. package/src/engine/tools/channel-api.ts +0 -244
  167. package/src/engine/tools/remind-logic.test.ts +0 -280
  168. package/src/engine/tools/remind-logic.ts +0 -377
  169. package/src/engine/types.ts +0 -313
  170. package/src/engine/utils/attachment-tags.test.ts +0 -186
  171. package/src/engine/utils/attachment-tags.ts +0 -174
  172. package/src/engine/utils/audio.test.ts +0 -250
  173. package/src/engine/utils/audio.ts +0 -585
  174. package/src/engine/utils/data-paths.ts +0 -38
  175. package/src/engine/utils/diagnostics.ts +0 -109
  176. package/src/engine/utils/file-utils.test.ts +0 -72
  177. package/src/engine/utils/file-utils.ts +0 -225
  178. package/src/engine/utils/format.test.ts +0 -68
  179. package/src/engine/utils/format.ts +0 -70
  180. package/src/engine/utils/image-size.test.ts +0 -158
  181. package/src/engine/utils/image-size.ts +0 -249
  182. package/src/engine/utils/log.test.ts +0 -28
  183. package/src/engine/utils/log.ts +0 -61
  184. package/src/engine/utils/media-tags.test.ts +0 -32
  185. package/src/engine/utils/media-tags.ts +0 -177
  186. package/src/engine/utils/payload.test.ts +0 -68
  187. package/src/engine/utils/payload.ts +0 -145
  188. package/src/engine/utils/platform-storage-laziness.test.ts +0 -63
  189. package/src/engine/utils/platform.test.ts +0 -148
  190. package/src/engine/utils/platform.ts +0 -343
  191. package/src/engine/utils/request-context.ts +0 -60
  192. package/src/engine/utils/string-normalize.ts +0 -91
  193. package/src/engine/utils/stt.test.ts +0 -104
  194. package/src/engine/utils/stt.ts +0 -100
  195. package/src/engine/utils/text-parsing.test.ts +0 -29
  196. package/src/engine/utils/text-parsing.ts +0 -155
  197. package/src/engine/utils/upload-cache.ts +0 -96
  198. package/src/engine/utils/voice-text.ts +0 -15
  199. package/src/exec-approvals.ts +0 -218
  200. package/src/manifest-schema.test.ts +0 -56
  201. package/src/qqbot-test-support.ts +0 -29
  202. package/src/types.ts +0 -210
  203. package/tsconfig.json +0 -16
@@ -1,585 +0,0 @@
1
- /**
2
- * Audio format conversion utilities.
3
- * 音频格式转换工具。
4
- *
5
- * Handles SILK ↔ PCM ↔ WAV ↔ MP3 conversions for QQ Bot voice messaging.
6
- * Prefers ffmpeg when available; falls back to WASM decoders (silk-wasm,
7
- * mpg123-decoder) for environments without native tooling.
8
- *
9
- * Self-contained within engine/ — no framework SDK dependency.
10
- */
11
-
12
- import { execFile } from "node:child_process";
13
- import * as fs from "node:fs";
14
- import * as path from "node:path";
15
- import { formatErrorMessage } from "./format.js";
16
- import { debugLog, debugError, debugWarn } from "./log.js";
17
- import { detectFfmpeg, isWindows } from "./platform.js";
18
- import { normalizeLowercaseStringOrEmpty as normalizeLowercase } from "./string-normalize.js";
19
-
20
- type SilkWasm = typeof import("silk-wasm");
21
- let _silkWasmPromise: Promise<SilkWasm | null> | null = null;
22
-
23
- /** Lazy-load the silk-wasm module (singleton cache; returns null on failure). */
24
- function loadSilkWasm(): Promise<SilkWasm | null> {
25
- if (_silkWasmPromise) {
26
- return _silkWasmPromise;
27
- }
28
- _silkWasmPromise = import("silk-wasm").catch((err) => {
29
- debugWarn(
30
- `[audio-convert] silk-wasm not available; SILK encode/decode disabled (${formatErrorMessage(err)})`,
31
- );
32
- return null;
33
- });
34
- return _silkWasmPromise;
35
- }
36
-
37
- /** Wrap raw PCM s16le data into a standard WAV file. */
38
- export function pcmToWav(
39
- pcmData: Uint8Array,
40
- sampleRate: number,
41
- channels: number = 1,
42
- bitsPerSample: number = 16,
43
- ): Buffer {
44
- const byteRate = sampleRate * channels * (bitsPerSample / 8);
45
- const blockAlign = channels * (bitsPerSample / 8);
46
- const dataSize = pcmData.length;
47
- const headerSize = 44;
48
- const fileSize = headerSize + dataSize;
49
-
50
- const buffer = Buffer.alloc(fileSize);
51
-
52
- buffer.write("RIFF", 0);
53
- buffer.writeUInt32LE(fileSize - 8, 4);
54
- buffer.write("WAVE", 8);
55
-
56
- buffer.write("fmt ", 12);
57
- buffer.writeUInt32LE(16, 16);
58
- buffer.writeUInt16LE(1, 20);
59
- buffer.writeUInt16LE(channels, 22);
60
- buffer.writeUInt32LE(sampleRate, 24);
61
- buffer.writeUInt32LE(byteRate, 28);
62
- buffer.writeUInt16LE(blockAlign, 32);
63
- buffer.writeUInt16LE(bitsPerSample, 34);
64
-
65
- buffer.write("data", 36);
66
- buffer.writeUInt32LE(dataSize, 40);
67
- Buffer.from(pcmData.buffer, pcmData.byteOffset, pcmData.byteLength).copy(buffer, headerSize);
68
-
69
- return buffer;
70
- }
71
-
72
- /** Strip the AMR header that may be present in QQ voice payloads. */
73
- export function stripAmrHeader(buf: Buffer): Buffer {
74
- const AMR_HEADER = Buffer.from("#!AMR\n");
75
- if (buf.length > 6 && buf.subarray(0, 6).equals(AMR_HEADER)) {
76
- return buf.subarray(6);
77
- }
78
- return buf;
79
- }
80
-
81
- /** Convert a SILK or AMR voice file to WAV format. */
82
- export async function convertSilkToWav(
83
- inputPath: string,
84
- outputDir?: string,
85
- ): Promise<{ wavPath: string; duration: number } | null> {
86
- if (!fs.existsSync(inputPath)) {
87
- return null;
88
- }
89
-
90
- const fileBuf = fs.readFileSync(inputPath);
91
- const strippedBuf = stripAmrHeader(fileBuf);
92
- const rawData = new Uint8Array(
93
- strippedBuf.buffer,
94
- strippedBuf.byteOffset,
95
- strippedBuf.byteLength,
96
- );
97
-
98
- const silk = await loadSilkWasm();
99
- if (!silk || !silk.isSilk(rawData)) {
100
- return null;
101
- }
102
-
103
- const sampleRate = 24000;
104
- const result = await silk.decode(rawData, sampleRate);
105
- const wavBuffer = pcmToWav(result.data, sampleRate);
106
-
107
- const dir = outputDir || path.dirname(inputPath);
108
- if (!fs.existsSync(dir)) {
109
- fs.mkdirSync(dir, { recursive: true });
110
- }
111
- const baseName = path.basename(inputPath, path.extname(inputPath));
112
- const wavPath = path.join(dir, `${baseName}.wav`);
113
- fs.writeFileSync(wavPath, wavBuffer);
114
-
115
- return { wavPath, duration: result.duration };
116
- }
117
-
118
- /** Check whether an attachment is a voice file (by MIME type or extension). */
119
- export function isVoiceAttachment(att: { content_type?: string; filename?: string }): boolean {
120
- if (att.content_type === "voice" || att.content_type?.startsWith("audio/")) {
121
- return true;
122
- }
123
- const ext = att.filename ? normalizeLowercase(path.extname(att.filename)) : "";
124
- return [".amr", ".silk", ".slk", ".slac"].includes(ext);
125
- }
126
-
127
- /** Check whether a file path is a known audio format. */
128
- export function isAudioFile(filePath: string, mimeType?: string): boolean {
129
- if (mimeType) {
130
- if (mimeType === "voice" || mimeType.startsWith("audio/")) {
131
- return true;
132
- }
133
- }
134
- const ext = normalizeLowercase(path.extname(filePath));
135
- return [
136
- ".silk",
137
- ".slk",
138
- ".amr",
139
- ".wav",
140
- ".mp3",
141
- ".ogg",
142
- ".opus",
143
- ".aac",
144
- ".flac",
145
- ".m4a",
146
- ".wma",
147
- ".pcm",
148
- ].includes(ext);
149
- }
150
-
151
- const QQ_NATIVE_VOICE_MIMES = new Set([
152
- "audio/silk",
153
- "audio/amr",
154
- "audio/wav",
155
- "audio/wave",
156
- "audio/x-wav",
157
- "audio/mpeg",
158
- "audio/mp3",
159
- ]);
160
-
161
- const QQ_NATIVE_VOICE_EXTS = new Set([".silk", ".slk", ".amr", ".wav", ".mp3"]);
162
-
163
- /** Check whether a voice file needs transcoding for upload (QQ-native formats skip it). */
164
- export function shouldTranscodeVoice(filePath: string, mimeType?: string): boolean {
165
- if (mimeType && QQ_NATIVE_VOICE_MIMES.has(normalizeLowercase(mimeType))) {
166
- return false;
167
- }
168
- const ext = normalizeLowercase(path.extname(filePath));
169
- if (QQ_NATIVE_VOICE_EXTS.has(ext)) {
170
- return false;
171
- }
172
- return isAudioFile(filePath, mimeType);
173
- }
174
-
175
- const QQ_NATIVE_UPLOAD_FORMATS = [".wav", ".mp3", ".silk"];
176
-
177
- function normalizeFormats(formats: string[]): string[] {
178
- return formats.map((f) => {
179
- const lower = normalizeLowercase(f);
180
- return lower.startsWith(".") ? lower : `.${lower}`;
181
- });
182
- }
183
-
184
- /**
185
- * Convert a local audio file to Base64-encoded SILK for QQ API upload.
186
- *
187
- * Attempts conversion via ffmpeg → WASM decoders → null fallback chain.
188
- */
189
- export async function audioFileToSilkBase64(
190
- filePath: string,
191
- directUploadFormats?: string[],
192
- ): Promise<string | null> {
193
- if (!fs.existsSync(filePath)) {
194
- return null;
195
- }
196
-
197
- const buf = fs.readFileSync(filePath);
198
- if (buf.length === 0) {
199
- debugError(`[audio-convert] file is empty: ${filePath}`);
200
- return null;
201
- }
202
-
203
- const ext = normalizeLowercase(path.extname(filePath));
204
- const uploadFormats = directUploadFormats
205
- ? normalizeFormats(directUploadFormats)
206
- : QQ_NATIVE_UPLOAD_FORMATS;
207
- if (uploadFormats.includes(ext)) {
208
- debugLog(`[audio-convert] direct upload (QQ native format): ${ext} (${buf.length} bytes)`);
209
- return buf.toString("base64");
210
- }
211
-
212
- if ([".slk", ".slac"].includes(ext)) {
213
- const stripped = stripAmrHeader(buf);
214
- const raw = new Uint8Array(stripped.buffer, stripped.byteOffset, stripped.byteLength);
215
- const silk = await loadSilkWasm();
216
- if (silk?.isSilk(raw)) {
217
- debugLog(`[audio-convert] SILK file, direct use: ${filePath} (${buf.length} bytes)`);
218
- return buf.toString("base64");
219
- }
220
- }
221
-
222
- const rawCheck = new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
223
- const strippedCheck = stripAmrHeader(buf);
224
- const strippedRaw = new Uint8Array(
225
- strippedCheck.buffer,
226
- strippedCheck.byteOffset,
227
- strippedCheck.byteLength,
228
- );
229
- const silkForCheck = await loadSilkWasm();
230
- if (silkForCheck?.isSilk(rawCheck) || silkForCheck?.isSilk(strippedRaw)) {
231
- debugLog(`[audio-convert] SILK detected by header: ${filePath} (${buf.length} bytes)`);
232
- return buf.toString("base64");
233
- }
234
-
235
- const targetRate = 24000;
236
-
237
- const ffmpegCmd = await detectFfmpeg();
238
- if (ffmpegCmd) {
239
- try {
240
- debugLog(
241
- `[audio-convert] ffmpeg (${ffmpegCmd}): converting ${ext} (${buf.length} bytes) → PCM s16le ${targetRate}Hz`,
242
- );
243
- const pcmBuf = await ffmpegToPCM(ffmpegCmd, filePath, targetRate);
244
- if (pcmBuf.length === 0) {
245
- debugError(`[audio-convert] ffmpeg produced empty PCM output`);
246
- return null;
247
- }
248
- const { silkBuffer } = await pcmToSilk(pcmBuf, targetRate);
249
- debugLog(`[audio-convert] ffmpeg: ${ext} → SILK done (${silkBuffer.length} bytes)`);
250
- return silkBuffer.toString("base64");
251
- } catch (err) {
252
- debugError(`[audio-convert] ffmpeg conversion failed: ${formatErrorMessage(err)}`);
253
- }
254
- }
255
-
256
- debugLog(`[audio-convert] fallback: trying WASM decoders for ${ext}`);
257
-
258
- if (ext === ".pcm") {
259
- const pcmBuf = Buffer.from(buf.buffer, buf.byteOffset, buf.byteLength);
260
- const { silkBuffer } = await pcmToSilk(pcmBuf, targetRate);
261
- return silkBuffer.toString("base64");
262
- }
263
-
264
- if (ext === ".wav" || (buf.length >= 4 && buf.toString("ascii", 0, 4) === "RIFF")) {
265
- const wavInfo = parseWavFallback(buf);
266
- if (wavInfo) {
267
- const { silkBuffer } = await pcmToSilk(wavInfo, targetRate);
268
- return silkBuffer.toString("base64");
269
- }
270
- }
271
-
272
- if (ext === ".mp3" || ext === ".mpeg") {
273
- const pcmBuf = await wasmDecodeMp3ToPCM(buf, targetRate);
274
- if (pcmBuf) {
275
- const { silkBuffer } = await pcmToSilk(pcmBuf, targetRate);
276
- debugLog(`[audio-convert] WASM: MP3 → SILK done (${silkBuffer.length} bytes)`);
277
- return silkBuffer.toString("base64");
278
- }
279
- }
280
-
281
- const installHint = isWindows()
282
- ? "Install ffmpeg with choco install ffmpeg, scoop install ffmpeg, or from https://ffmpeg.org"
283
- : process.platform === "darwin"
284
- ? "Install ffmpeg with brew install ffmpeg"
285
- : "Install ffmpeg with sudo apt install ffmpeg or sudo yum install ffmpeg";
286
- debugError(`[audio-convert] unsupported format: ${ext} (no ffmpeg available). ${installHint}`);
287
- return null;
288
- }
289
-
290
- /**
291
- * Wait for a file to appear and stabilize, then return its final size.
292
- *
293
- * Polls at `pollMs` intervals; returns 0 on timeout or persistent empty file.
294
- */
295
- export async function waitForFile(
296
- filePath: string,
297
- timeoutMs: number = 30000,
298
- pollMs: number = 500,
299
- ): Promise<number> {
300
- const start = Date.now();
301
- let lastSize = -1;
302
- let stableCount = 0;
303
- let fileExists = false;
304
- let fileAppearedAt = 0;
305
- let pollCount = 0;
306
-
307
- const emptyGiveUpMs = 10000;
308
- const noFileGiveUpMs = 15000;
309
-
310
- while (Date.now() - start < timeoutMs) {
311
- pollCount++;
312
- try {
313
- const stat = fs.statSync(filePath);
314
- if (!fileExists) {
315
- fileExists = true;
316
- fileAppearedAt = Date.now();
317
- debugLog(
318
- `[audio-convert] waitForFile: file appeared (${stat.size} bytes, after ${Date.now() - start}ms): ${path.basename(filePath)}`,
319
- );
320
- }
321
- if (stat.size > 0) {
322
- if (stat.size === lastSize) {
323
- stableCount++;
324
- if (stableCount >= 2) {
325
- debugLog(
326
- `[audio-convert] waitForFile: ready (${stat.size} bytes, waited ${Date.now() - start}ms, polls=${pollCount})`,
327
- );
328
- return stat.size;
329
- }
330
- } else {
331
- stableCount = 0;
332
- }
333
- lastSize = stat.size;
334
- } else {
335
- if (Date.now() - fileAppearedAt > emptyGiveUpMs) {
336
- debugError(
337
- `[audio-convert] waitForFile: file still empty after ${emptyGiveUpMs}ms, giving up: ${path.basename(filePath)}`,
338
- );
339
- return 0;
340
- }
341
- }
342
- } catch {
343
- if (!fileExists && Date.now() - start > noFileGiveUpMs) {
344
- debugError(
345
- `[audio-convert] waitForFile: file never appeared after ${noFileGiveUpMs}ms, giving up: ${path.basename(filePath)}`,
346
- );
347
- return 0;
348
- }
349
- }
350
- await new Promise((r) => setTimeout(r, pollMs));
351
- }
352
-
353
- try {
354
- const finalStat = fs.statSync(filePath);
355
- if (finalStat.size > 0) {
356
- debugWarn(
357
- `[audio-convert] waitForFile: timeout but file has data (${finalStat.size} bytes), using it`,
358
- );
359
- return finalStat.size;
360
- }
361
- debugError(
362
- `[audio-convert] waitForFile: timeout after ${timeoutMs}ms, file exists but empty (0 bytes): ${path.basename(filePath)}`,
363
- );
364
- } catch {
365
- debugError(
366
- `[audio-convert] waitForFile: timeout after ${timeoutMs}ms, file never appeared: ${path.basename(filePath)}`,
367
- );
368
- }
369
- return 0;
370
- }
371
-
372
- /** Encode PCM s16le data into SILK format. */
373
- async function pcmToSilk(
374
- pcmBuffer: Buffer,
375
- sampleRate: number,
376
- ): Promise<{ silkBuffer: Buffer; duration: number }> {
377
- const silk = await loadSilkWasm();
378
- if (!silk) {
379
- throw new Error("silk-wasm is not available; cannot encode PCM to SILK");
380
- }
381
- const pcmData = new Uint8Array(pcmBuffer.buffer, pcmBuffer.byteOffset, pcmBuffer.byteLength);
382
- const result = await silk.encode(pcmData, sampleRate);
383
- return {
384
- silkBuffer: Buffer.from(result.data.buffer, result.data.byteOffset, result.data.byteLength),
385
- duration: result.duration,
386
- };
387
- }
388
-
389
- /** Use ffmpeg to convert any audio to mono 24 kHz PCM s16le. */
390
- function ffmpegToPCM(
391
- ffmpegCmd: string,
392
- inputPath: string,
393
- sampleRate: number = 24000,
394
- ): Promise<Buffer> {
395
- return new Promise((resolve, reject) => {
396
- const args = [
397
- "-i",
398
- inputPath,
399
- "-f",
400
- "s16le",
401
- "-ar",
402
- String(sampleRate),
403
- "-ac",
404
- "1",
405
- "-acodec",
406
- "pcm_s16le",
407
- "-v",
408
- "error",
409
- "pipe:1",
410
- ];
411
- execFile(
412
- ffmpegCmd,
413
- args,
414
- {
415
- maxBuffer: 50 * 1024 * 1024,
416
- encoding: "buffer",
417
- ...(isWindows() ? { windowsHide: true } : {}),
418
- },
419
- (err, stdout) => {
420
- if (err) {
421
- reject(new Error(`ffmpeg failed: ${err.message}`));
422
- return;
423
- }
424
- resolve(stdout as unknown as Buffer);
425
- },
426
- );
427
- });
428
- }
429
-
430
- /** Decode MP3 to PCM via mpg123-decoder WASM (fallback when ffmpeg is unavailable). */
431
- async function wasmDecodeMp3ToPCM(buf: Buffer, targetRate: number): Promise<Buffer | null> {
432
- try {
433
- const { MPEGDecoder } = await import("mpg123-decoder");
434
- debugLog(`[audio-convert] WASM MP3 decode: size=${buf.length} bytes`);
435
- const decoder = new MPEGDecoder();
436
- await decoder.ready;
437
-
438
- const decoded = decoder.decode(new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength));
439
- decoder.free();
440
-
441
- if (decoded.samplesDecoded === 0 || decoded.channelData.length === 0) {
442
- debugError(
443
- `[audio-convert] WASM MP3 decode: no samples (samplesDecoded=${decoded.samplesDecoded})`,
444
- );
445
- return null;
446
- }
447
-
448
- debugLog(
449
- `[audio-convert] WASM MP3 decode: samples=${decoded.samplesDecoded}, sampleRate=${decoded.sampleRate}, channels=${decoded.channelData.length}`,
450
- );
451
-
452
- let floatMono: Float32Array;
453
- if (decoded.channelData.length === 1) {
454
- floatMono = decoded.channelData[0];
455
- } else {
456
- floatMono = new Float32Array(decoded.samplesDecoded);
457
- const channels = decoded.channelData.length;
458
- for (let i = 0; i < decoded.samplesDecoded; i++) {
459
- let sum = 0;
460
- for (let ch = 0; ch < channels; ch++) {
461
- sum += decoded.channelData[ch][i];
462
- }
463
- floatMono[i] = sum / channels;
464
- }
465
- }
466
-
467
- const s16 = new Uint8Array(floatMono.length * 2);
468
- const view = new DataView(s16.buffer);
469
- for (let i = 0; i < floatMono.length; i++) {
470
- const clamped = Math.max(-1, Math.min(1, floatMono[i]));
471
- const val = clamped < 0 ? clamped * 32768 : clamped * 32767;
472
- view.setInt16(i * 2, Math.round(val), true);
473
- }
474
-
475
- let pcm: Uint8Array = s16;
476
- if (decoded.sampleRate !== targetRate) {
477
- const inputSamples = s16.length / 2;
478
- const outputSamples = Math.round((inputSamples * targetRate) / decoded.sampleRate);
479
- const output = new Uint8Array(outputSamples * 2);
480
- const inView = new DataView(s16.buffer, s16.byteOffset, s16.byteLength);
481
- const outView = new DataView(output.buffer, output.byteOffset, output.byteLength);
482
- for (let i = 0; i < outputSamples; i++) {
483
- const srcIdx = (i * decoded.sampleRate) / targetRate;
484
- const idx0 = Math.floor(srcIdx);
485
- const idx1 = Math.min(idx0 + 1, inputSamples - 1);
486
- const frac = srcIdx - idx0;
487
- const s0 = inView.getInt16(idx0 * 2, true);
488
- const s1 = inView.getInt16(idx1 * 2, true);
489
- const sample = Math.round(s0 + (s1 - s0) * frac);
490
- outView.setInt16(i * 2, Math.max(-32768, Math.min(32767, sample)), true);
491
- }
492
- pcm = output;
493
- }
494
-
495
- return Buffer.from(pcm.buffer, pcm.byteOffset, pcm.byteLength);
496
- } catch (err) {
497
- debugError(`[audio-convert] WASM MP3 decode failed: ${formatErrorMessage(err)}`);
498
- if (err instanceof Error && err.stack) {
499
- debugError(`[audio-convert] stack: ${err.stack}`);
500
- }
501
- return null;
502
- }
503
- }
504
-
505
- /** Parse a standard PCM WAV and extract mono 24 kHz PCM data (fallback without ffmpeg). */
506
- export function parseWavFallback(buf: Buffer): Buffer | null {
507
- if (buf.length < 44) {
508
- return null;
509
- }
510
- if (buf.toString("ascii", 0, 4) !== "RIFF") {
511
- return null;
512
- }
513
- if (buf.toString("ascii", 8, 12) !== "WAVE") {
514
- return null;
515
- }
516
- if (buf.toString("ascii", 12, 16) !== "fmt ") {
517
- return null;
518
- }
519
-
520
- const audioFormat = buf.readUInt16LE(20);
521
- if (audioFormat !== 1) {
522
- return null;
523
- }
524
-
525
- const channels = buf.readUInt16LE(22);
526
- const sampleRate = buf.readUInt32LE(24);
527
- const bitsPerSample = buf.readUInt16LE(34);
528
- if (bitsPerSample !== 16) {
529
- return null;
530
- }
531
-
532
- let offset = 36;
533
- while (offset < buf.length - 8) {
534
- const chunkId = buf.toString("ascii", offset, offset + 4);
535
- const chunkSize = buf.readUInt32LE(offset + 4);
536
- if (chunkId === "data") {
537
- const dataStart = offset + 8;
538
- const dataEnd = Math.min(dataStart + chunkSize, buf.length);
539
- let pcm = new Uint8Array(buf.buffer, buf.byteOffset + dataStart, dataEnd - dataStart);
540
-
541
- if (channels > 1) {
542
- const samplesPerCh = pcm.length / (2 * channels);
543
- const mono = new Uint8Array(samplesPerCh * 2);
544
- const inV = new DataView(pcm.buffer, pcm.byteOffset, pcm.byteLength);
545
- const outV = new DataView(mono.buffer, mono.byteOffset, mono.byteLength);
546
- for (let i = 0; i < samplesPerCh; i++) {
547
- let sum = 0;
548
- for (let ch = 0; ch < channels; ch++) {
549
- sum += inV.getInt16((i * channels + ch) * 2, true);
550
- }
551
- outV.setInt16(i * 2, Math.max(-32768, Math.min(32767, Math.round(sum / channels))), true);
552
- }
553
- pcm = mono;
554
- }
555
-
556
- const targetRate = 24000;
557
- if (sampleRate !== targetRate) {
558
- const inSamples = pcm.length / 2;
559
- const outSamples = Math.round((inSamples * targetRate) / sampleRate);
560
- const out = new Uint8Array(outSamples * 2);
561
- const inV = new DataView(pcm.buffer, pcm.byteOffset, pcm.byteLength);
562
- const outV = new DataView(out.buffer, out.byteOffset, out.byteLength);
563
- for (let i = 0; i < outSamples; i++) {
564
- const src = (i * sampleRate) / targetRate;
565
- const i0 = Math.floor(src);
566
- const i1 = Math.min(i0 + 1, inSamples - 1);
567
- const f = src - i0;
568
- const s0 = inV.getInt16(i0 * 2, true);
569
- const s1 = inV.getInt16(i1 * 2, true);
570
- outV.setInt16(
571
- i * 2,
572
- Math.max(-32768, Math.min(32767, Math.round(s0 + (s1 - s0) * f))),
573
- true,
574
- );
575
- }
576
- pcm = out;
577
- }
578
-
579
- return Buffer.from(pcm.buffer, pcm.byteOffset, pcm.byteLength);
580
- }
581
- offset += 8 + chunkSize;
582
- }
583
-
584
- return null;
585
- }
@@ -1,38 +0,0 @@
1
- /**
2
- * Centralised filename helpers for persisted QQBot state.
3
- *
4
- * Every persistence module routes file paths through these helpers so the
5
- * naming convention stays in sync and legacy migrations are handled
6
- * consistently.
7
- *
8
- * Key design decisions:
9
- * - Credential backup is keyed only by `accountId` because recovery runs
10
- * exactly when the appId is missing from config.
11
- */
12
-
13
- import path from "node:path";
14
- import { getQQBotDataPath } from "./platform.js";
15
-
16
- /**
17
- * Normalise an identifier so it is safe to embed in a filename.
18
- * Keeps alphanumerics, dot, underscore, dash; everything else becomes `_`.
19
- */
20
- function safeName(id: string): string {
21
- return id.replace(/[^a-zA-Z0-9._-]/g, "_");
22
- }
23
-
24
- // ---- credential backup ----
25
-
26
- /**
27
- * Per-accountId credential backup file. Not keyed by appId because the
28
- * whole point of this file is to recover credentials when appId is
29
- * missing from the live config.
30
- */
31
- export function getCredentialBackupFile(accountId: string): string {
32
- return path.join(getQQBotDataPath("data"), `credential-backup-${safeName(accountId)}.json`);
33
- }
34
-
35
- /** Legacy single-file credential backup (pre-multi-account-isolation). */
36
- export function getLegacyCredentialBackupFile(): string {
37
- return path.join(getQQBotDataPath("data"), "credential-backup.json");
38
- }