@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,60 +0,0 @@
1
- /**
2
- * Request-level context using AsyncLocalStorage.
3
- *
4
- * Provides ambient context (accountId, target openid, chat type, etc.)
5
- * throughout the request lifecycle without explicit parameter threading.
6
- *
7
- * Gateway establishes the scope around each inbound message via
8
- * `runWithRequestContext()`; any async code within that scope (including
9
- * AI agent calls and tool `execute` callbacks) can retrieve the current
10
- * request via `getRequestContext()` without racing with concurrent
11
- * inbound messages.
12
- *
13
- * This is a pure Node.js module with zero framework dependencies,
14
- * making it trivially portable between the built-in and standalone
15
- * versions of QQBot.
16
- */
17
-
18
- import { AsyncLocalStorage } from "node:async_hooks";
19
-
20
- /** Context values available during one inbound message handling cycle. */
21
- interface RequestContext {
22
- /** The account ID handling this request. */
23
- accountId: string;
24
- /**
25
- * Fully qualified delivery target, e.g. `qqbot:c2c:<openid>` or
26
- * `qqbot:group:<openid>`. This is what downstream code (e.g. the
27
- * `qqbot_remind` tool building a cron job) uses verbatim.
28
- */
29
- target?: string;
30
- /** The target openid (C2C) or group openid (group). */
31
- targetId?: string;
32
- /** Chat type of the originating event. */
33
- chatType?: "c2c" | "group" | "guild" | "dm" | "channel";
34
- }
35
-
36
- const store = new AsyncLocalStorage<RequestContext>();
37
-
38
- /**
39
- * Execute an async function with request-scoped context.
40
- *
41
- * All code running within `fn` (including nested async calls) can
42
- * retrieve the context via `getRequestContext()`.
43
- *
44
- * @param ctx - The context to attach to this request.
45
- * @param fn - The async function to run within the context.
46
- * @returns The return value of `fn`.
47
- */
48
- export function runWithRequestContext<T>(ctx: RequestContext, fn: () => T): T {
49
- return store.run(ctx, fn);
50
- }
51
-
52
- /**
53
- * Retrieve the current request context.
54
- *
55
- * Returns `undefined` when called outside of a `runWithRequestContext`
56
- * scope.
57
- */
58
- export function getRequestContext(): RequestContext | undefined {
59
- return store.getStore();
60
- }
@@ -1,91 +0,0 @@
1
- /**
2
- * String normalization and record-coercion helpers.
3
- *
4
- * These are self-contained re-implementations of the functions that
5
- * the plugin previously imported from `openclaw/plugin-sdk/text-runtime`
6
- * and `openclaw/plugin-sdk/text-runtime` (via record-coerce / string-coerce).
7
- *
8
- * core/ modules use these instead of importing plugin-sdk, keeping the
9
- * shared layer portable between the built-in and standalone versions.
10
- */
11
-
12
- // ---- String coercion ----
13
-
14
- /** Return the trimmed string or `null` when the value is not a non-empty string. */
15
- function normalizeNullableString(value: unknown): string | null {
16
- if (typeof value !== "string") {
17
- return null;
18
- }
19
- const trimmed = value.trim();
20
- return trimmed ? trimmed : null;
21
- }
22
-
23
- /** Return the trimmed string or `undefined` when the value is not a non-empty string. */
24
- export function normalizeOptionalString(value: unknown): string | undefined {
25
- return normalizeNullableString(value) ?? undefined;
26
- }
27
-
28
- /**
29
- * Stringify then normalize. Accepts `string | number | boolean | bigint`.
30
- * Returns `undefined` for objects, arrays, null, and undefined.
31
- */
32
- export function normalizeStringifiedOptionalString(value: unknown): string | undefined {
33
- if (typeof value === "string") {
34
- return normalizeOptionalString(value);
35
- }
36
- if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
37
- return normalizeOptionalString(String(value));
38
- }
39
- return undefined;
40
- }
41
-
42
- /** Return the trimmed lowercase string or `undefined`. */
43
- export function normalizeOptionalLowercaseString(value: unknown): string | undefined {
44
- return normalizeOptionalString(value)?.toLowerCase();
45
- }
46
-
47
- /** Return the trimmed lowercase string or `""`. */
48
- export function normalizeLowercaseStringOrEmpty(value: unknown): string {
49
- return normalizeOptionalLowercaseString(value) ?? "";
50
- }
51
-
52
- // ---- Record coercion ----
53
-
54
- /** Coerce a value into a `Record<string, unknown>` or `undefined`. */
55
- export function asOptionalObjectRecord(value: unknown): Record<string, unknown> | undefined {
56
- return value && typeof value === "object" ? (value as Record<string, unknown>) : undefined;
57
- }
58
-
59
- /** Read a string field from a record. */
60
- export function readStringField(
61
- record: Record<string, unknown> | null | undefined,
62
- key: string,
63
- ): string | undefined {
64
- const v = record?.[key];
65
- return typeof v === "string" ? v : undefined;
66
- }
67
-
68
- // ---- Filename normalization ----
69
-
70
- /**
71
- * Normalize filenames into a UTF-8 form that the QQ Bot API accepts reliably.
72
- *
73
- * Decodes percent-escaped names, converts Unicode to NFC, and strips
74
- * ASCII control characters.
75
- */
76
- export function sanitizeFileName(name: string): string {
77
- if (!name) {
78
- return name;
79
- }
80
- let result = name.trim();
81
- if (result.includes("%")) {
82
- try {
83
- result = decodeURIComponent(result);
84
- } catch {
85
- // Keep the raw value if it is not valid percent-encoding.
86
- }
87
- }
88
- result = result.normalize("NFC");
89
- result = result.replace(/\p{Cc}/gu, "");
90
- return result;
91
- }
@@ -1,104 +0,0 @@
1
- import * as fs from "node:fs";
2
- import * as os from "node:os";
3
- import * as path from "node:path";
4
- import { afterEach, describe, expect, it, vi } from "vitest";
5
- import { resolveSTTConfig, transcribeAudio } from "./stt.js";
6
-
7
- describe("engine/utils/stt", () => {
8
- afterEach(() => {
9
- vi.unstubAllGlobals();
10
- });
11
-
12
- it("resolves plugin STT config and falls back to provider credentials", () => {
13
- const cfg = {
14
- channels: {
15
- qqbot: {
16
- stt: {
17
- provider: "openai",
18
- baseUrl: "https://api.example.test/v1///",
19
- model: "whisper-large",
20
- },
21
- },
22
- },
23
- models: {
24
- providers: {
25
- openai: {
26
- apiKey: "provider-key",
27
- },
28
- },
29
- },
30
- };
31
-
32
- expect(resolveSTTConfig(cfg)).toEqual({
33
- baseUrl: "https://api.example.test/v1",
34
- apiKey: "provider-key",
35
- model: "whisper-large",
36
- });
37
- });
38
-
39
- it("falls back to framework audio model config when plugin STT is disabled", () => {
40
- const cfg = {
41
- channels: { qqbot: { stt: { enabled: false, apiKey: "ignored" } } },
42
- tools: {
43
- media: {
44
- audio: {
45
- models: [{ provider: "local", baseUrl: "https://stt.example.test/", model: "sense" }],
46
- },
47
- },
48
- },
49
- models: {
50
- providers: {
51
- local: { apiKey: "local-key" },
52
- },
53
- },
54
- };
55
-
56
- expect(resolveSTTConfig(cfg)).toEqual({
57
- baseUrl: "https://stt.example.test",
58
- apiKey: "local-key",
59
- model: "sense",
60
- });
61
- });
62
-
63
- it("returns null when no usable STT credentials are configured", () => {
64
- expect(resolveSTTConfig({ channels: { qqbot: { stt: { baseUrl: "https://x.test" } } } })).toBe(
65
- null,
66
- );
67
- expect(resolveSTTConfig({})).toBe(null);
68
- });
69
-
70
- it("posts audio to OpenAI-compatible transcription endpoint", async () => {
71
- const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-qqbot-stt-"));
72
- const audioPath = path.join(tmpDir, "voice.wav");
73
- fs.writeFileSync(audioPath, Buffer.from([1, 2, 3, 4]));
74
-
75
- const fetchMock = vi.fn(async () =>
76
- Response.json({
77
- text: "hello from audio",
78
- }),
79
- );
80
- vi.stubGlobal("fetch", fetchMock);
81
-
82
- const transcript = await transcribeAudio(audioPath, {
83
- channels: {
84
- qqbot: {
85
- stt: {
86
- baseUrl: "https://api.example.test/v1/",
87
- apiKey: "secret",
88
- model: "whisper-1",
89
- },
90
- },
91
- },
92
- });
93
-
94
- expect(transcript).toBe("hello from audio");
95
- expect(fetchMock).toHaveBeenCalledWith(
96
- "https://api.example.test/v1/audio/transcriptions",
97
- expect.objectContaining({
98
- method: "POST",
99
- headers: { Authorization: "Bearer secret" },
100
- body: expect.any(FormData),
101
- }),
102
- );
103
- });
104
- });
@@ -1,100 +0,0 @@
1
- /**
2
- * OpenAI-compatible STT (Speech-to-Text) configuration and transcription.
3
- *
4
- * Migrated from `src/stt.ts` — uses core/utils/string-normalize instead
5
- * of openclaw/plugin-sdk/text-runtime.
6
- */
7
-
8
- import * as fs from "node:fs";
9
- import path from "node:path";
10
- import {
11
- normalizeOptionalString,
12
- asOptionalObjectRecord as asRecord,
13
- readStringField as readString,
14
- sanitizeFileName,
15
- } from "./string-normalize.js";
16
-
17
- interface STTConfig {
18
- baseUrl: string;
19
- apiKey: string;
20
- model: string;
21
- }
22
-
23
- /** Resolve the STT configuration from the nested config object. */
24
- export function resolveSTTConfig(cfg: Record<string, unknown>): STTConfig | null {
25
- const channels = asRecord(cfg.channels);
26
- const qqbot = asRecord(channels?.qqbot);
27
- const channelStt = asRecord(qqbot?.stt);
28
- const models = asRecord(cfg.models);
29
- const providers = asRecord(models?.providers);
30
-
31
- // Prefer plugin-specific STT config.
32
- if (channelStt && channelStt.enabled !== false) {
33
- const providerId = readString(channelStt, "provider") ?? "openai";
34
- const providerCfg = asRecord(providers?.[providerId]);
35
- const baseUrl = readString(channelStt, "baseUrl") ?? readString(providerCfg, "baseUrl");
36
- const apiKey = readString(channelStt, "apiKey") ?? readString(providerCfg, "apiKey");
37
- const model = readString(channelStt, "model") ?? "whisper-1";
38
- if (baseUrl && apiKey) {
39
- return { baseUrl: baseUrl.replace(/\/+$/, ""), apiKey, model };
40
- }
41
- }
42
-
43
- // Fall back to framework-level audio model config.
44
- const tools = asRecord(cfg.tools);
45
- const media = asRecord(tools?.media);
46
- const audio = asRecord(media?.audio);
47
- const audioModels = audio?.models;
48
- const audioModelEntry = Array.isArray(audioModels) ? asRecord(audioModels[0]) : undefined;
49
- if (audioModelEntry) {
50
- const providerId = readString(audioModelEntry, "provider") ?? "openai";
51
- const providerCfg = asRecord(providers?.[providerId]);
52
- const baseUrl = readString(audioModelEntry, "baseUrl") ?? readString(providerCfg, "baseUrl");
53
- const apiKey = readString(audioModelEntry, "apiKey") ?? readString(providerCfg, "apiKey");
54
- const model = readString(audioModelEntry, "model") ?? "whisper-1";
55
- if (baseUrl && apiKey) {
56
- return { baseUrl: baseUrl.replace(/\/+$/, ""), apiKey, model };
57
- }
58
- }
59
-
60
- return null;
61
- }
62
-
63
- /** Send audio to an OpenAI-compatible STT endpoint and return the transcript. */
64
- export async function transcribeAudio(
65
- audioPath: string,
66
- cfg: Record<string, unknown>,
67
- ): Promise<string | null> {
68
- const sttCfg = resolveSTTConfig(cfg);
69
- if (!sttCfg) {
70
- return null;
71
- }
72
-
73
- const fileBuffer = fs.readFileSync(audioPath);
74
- const fileName = sanitizeFileName(path.basename(audioPath));
75
- const mime = fileName.endsWith(".wav")
76
- ? "audio/wav"
77
- : fileName.endsWith(".mp3")
78
- ? "audio/mpeg"
79
- : fileName.endsWith(".ogg")
80
- ? "audio/ogg"
81
- : "application/octet-stream";
82
-
83
- const form = new FormData();
84
- form.append("file", new Blob([fileBuffer], { type: mime }), fileName);
85
- form.append("model", sttCfg.model);
86
-
87
- const resp = await fetch(`${sttCfg.baseUrl}/audio/transcriptions`, {
88
- method: "POST",
89
- headers: { Authorization: `Bearer ${sttCfg.apiKey}` },
90
- body: form,
91
- });
92
-
93
- if (!resp.ok) {
94
- const detail = await resp.text().catch(() => "");
95
- throw new Error(`STT failed (HTTP ${resp.status}): ${detail.slice(0, 300)}`);
96
- }
97
-
98
- const result = (await resp.json()) as { text?: string };
99
- return normalizeOptionalString(result.text) ?? null;
100
- }
@@ -1,29 +0,0 @@
1
- import { describe, expect, it, vi } from "vitest";
2
- import { parseFaceTags } from "./text-parsing.js";
3
-
4
- describe("parseFaceTags", () => {
5
- it("returns empty string when input is undefined", () => {
6
- expect(parseFaceTags(undefined)).toBe("");
7
- });
8
-
9
- it("returns empty string when input is null", () => {
10
- expect(parseFaceTags(null)).toBe("");
11
- });
12
-
13
- it("returns empty string when input is empty string", () => {
14
- expect(parseFaceTags("")).toBe("");
15
- });
16
-
17
- it("skips oversized base64 ext payloads before decoding", () => {
18
- const oversizedBase64 = "A".repeat(100_000);
19
- const tag = `<faceType=1,faceId="1",ext="${oversizedBase64}">`;
20
- const bufferFromSpy = vi.spyOn(Buffer, "from");
21
-
22
- try {
23
- expect(parseFaceTags(tag)).toBe("[Emoji: unknown emoji]");
24
- expect(bufferFromSpy).not.toHaveBeenCalledWith(oversizedBase64, "base64");
25
- } finally {
26
- bufferFromSpy.mockRestore();
27
- }
28
- });
29
- });
@@ -1,155 +0,0 @@
1
- /**
2
- * Text parsing utilities — zero external dependency.
3
- *
4
- * Contains pure functions for message text processing.
5
- */
6
-
7
- import type { RefAttachmentSummary } from "../ref/types.js";
8
-
9
- // ============ Internal markers ============
10
-
11
- const INTERNAL_MARKER_RE = /\[internal:?\s*[^\]]*\]|\[debug:?\s*[^\]]*\]|\[system:?\s*[^\]]*\]/gi;
12
-
13
- /** Remove internal markers like `[internal:...]`, `[debug:...]`, `[system:...]`. */
14
- export function filterInternalMarkers(text: string | undefined | null): string {
15
- if (!text) {
16
- return "";
17
- }
18
- return text.replace(INTERNAL_MARKER_RE, "").trim();
19
- }
20
-
21
- // ============ Ref indices ============
22
-
23
- /** QQ 引用(回复)消息类型常量。 */
24
- export const MSG_TYPE_QUOTE = 103;
25
-
26
- /**
27
- * Parse message_scene.ext to extract refMsgIdx and msgIdx.
28
- *
29
- * Supports both ext prefix formats:
30
- * - `ref_msg_idx=` / `msg_idx=` (platform native format)
31
- * - `refMsgIdx:` / `msgIdx:` (legacy internal format)
32
- *
33
- * When `messageType` equals `MSG_TYPE_QUOTE` (103) and `msgElements` is
34
- * provided, `msgElements[0].msg_idx` takes precedence over the ext-parsed
35
- * `refMsgIdx` value — the element-level index is more authoritative for
36
- * quote messages.
37
- */
38
- export function parseRefIndices(
39
- ext?: string[],
40
- messageType?: number,
41
- msgElements?: Array<{ msg_idx?: string }>,
42
- ): { refMsgIdx?: string; msgIdx?: string } {
43
- let refMsgIdx: string | undefined;
44
- let msgIdx: string | undefined;
45
-
46
- if (ext && ext.length > 0) {
47
- for (const item of ext) {
48
- if (typeof item !== "string") {
49
- continue;
50
- }
51
- // Platform native format: ref_msg_idx= / msg_idx=
52
- if (item.startsWith("ref_msg_idx=")) {
53
- refMsgIdx = item.slice("ref_msg_idx=".length).trim();
54
- } else if (item.startsWith("msg_idx=")) {
55
- msgIdx = item.slice("msg_idx=".length).trim();
56
- }
57
- // Legacy internal format: refMsgIdx: / msgIdx:
58
- else if (item.startsWith("refMsgIdx:")) {
59
- refMsgIdx = item.slice("refMsgIdx:".length).trim();
60
- } else if (item.startsWith("msgIdx:")) {
61
- msgIdx = item.slice("msgIdx:".length).trim();
62
- }
63
- }
64
- }
65
-
66
- // For quote messages, msg_elements[0].msg_idx is more authoritative.
67
- if (messageType === MSG_TYPE_QUOTE) {
68
- const refElement = msgElements?.[0];
69
- if (refElement?.msg_idx) {
70
- refMsgIdx = refElement.msg_idx;
71
- }
72
- }
73
-
74
- return { refMsgIdx, msgIdx };
75
- }
76
-
77
- // ============ Face tags ============
78
-
79
- const MAX_FACE_EXT_BYTES = 64 * 1024;
80
-
81
- /** Estimate Base64 decoded byte size (replaces plugin-sdk estimateBase64DecodedBytes). */
82
- function estimateBase64Size(base64: string): number {
83
- const len = base64.length;
84
- const padding = base64.endsWith("==") ? 2 : base64.endsWith("=") ? 1 : 0;
85
- return Math.ceil((len * 3) / 4) - padding;
86
- }
87
-
88
- /** Replace QQ face tags with readable text labels. */
89
- export function parseFaceTags(text: string | undefined | null): string {
90
- if (!text) {
91
- return "";
92
- }
93
-
94
- return text.replace(/<faceType=\d+,faceId="[^"]*",ext="([^"]*)">/g, (_match, ext: string) => {
95
- try {
96
- if (estimateBase64Size(ext) > MAX_FACE_EXT_BYTES) {
97
- return "[Emoji: unknown emoji]";
98
- }
99
- const decoded = Buffer.from(ext, "base64").toString("utf-8");
100
- const parsed = JSON.parse(decoded);
101
- const faceName = parsed.text || "unknown emoji";
102
- return `[Emoji: ${faceName}]`;
103
- } catch {
104
- return _match;
105
- }
106
- });
107
- }
108
-
109
- // ============ Attachment summaries ============
110
-
111
- /** Lowercase a string safely (replaces plugin-sdk normalizeLowercaseStringOrEmpty). */
112
- function lc(s: string | undefined | null): string {
113
- return (s ?? "").toLowerCase();
114
- }
115
-
116
- /** Build attachment summaries for ref-index caching. */
117
- export function buildAttachmentSummaries(
118
- attachments?: Array<{
119
- content_type: string;
120
- url: string;
121
- filename?: string;
122
- voice_wav_url?: string;
123
- }>,
124
- localPaths?: Array<string | null>,
125
- ): RefAttachmentSummary[] | undefined {
126
- if (!attachments || attachments.length === 0) {
127
- return undefined;
128
- }
129
-
130
- return attachments.map((att, idx) => {
131
- const ct = lc(att.content_type);
132
- let type: RefAttachmentSummary["type"] = "unknown";
133
- if (ct.startsWith("image/")) {
134
- type = "image";
135
- } else if (
136
- ct === "voice" ||
137
- ct.startsWith("audio/") ||
138
- ct.includes("silk") ||
139
- ct.includes("amr")
140
- ) {
141
- type = "voice";
142
- } else if (ct.startsWith("video/")) {
143
- type = "video";
144
- } else if (ct.startsWith("application/") || ct.startsWith("text/")) {
145
- type = "file";
146
- }
147
-
148
- return {
149
- type,
150
- filename: att.filename,
151
- contentType: att.content_type,
152
- localPath: localPaths?.[idx] ?? undefined,
153
- };
154
- });
155
- }
@@ -1,96 +0,0 @@
1
- /**
2
- * Cache `file_info` values returned by the QQ Bot API so identical uploads can be reused
3
- * before the server-side TTL expires.
4
- */
5
-
6
- import * as crypto from "node:crypto";
7
- import type { ChatScope } from "../types.js";
8
- import { debugLog } from "./log.js";
9
-
10
- interface CacheEntry {
11
- fileInfo: string;
12
- fileUuid: string;
13
- expiresAt: number;
14
- }
15
-
16
- const cache = new Map<string, CacheEntry>();
17
- const MAX_CACHE_SIZE = 500;
18
-
19
- /** Compute an MD5 hash used as part of the cache key. */
20
- export function computeFileHash(data: string | Buffer): string {
21
- const content = typeof data === "string" ? data : data;
22
- return crypto.createHash("md5").update(content).digest("hex");
23
- }
24
-
25
- /** Build the in-memory cache key. */
26
- function buildCacheKey(
27
- contentHash: string,
28
- scope: string,
29
- targetId: string,
30
- fileType: number,
31
- ): string {
32
- return `${contentHash}:${scope}:${targetId}:${fileType}`;
33
- }
34
-
35
- /** Look up a cached `file_info` value. */
36
- export function getCachedFileInfo(
37
- contentHash: string,
38
- scope: ChatScope,
39
- targetId: string,
40
- fileType: number,
41
- ): string | null {
42
- const key = buildCacheKey(contentHash, scope, targetId, fileType);
43
- const entry = cache.get(key);
44
-
45
- if (!entry) {
46
- return null;
47
- }
48
-
49
- if (Date.now() >= entry.expiresAt) {
50
- cache.delete(key);
51
- return null;
52
- }
53
-
54
- debugLog(`[upload-cache] Cache HIT: key=${key.slice(0, 40)}..., fileUuid=${entry.fileUuid}`);
55
- return entry.fileInfo;
56
- }
57
-
58
- /** Store an upload result in the cache. */
59
- export function setCachedFileInfo(
60
- contentHash: string,
61
- scope: ChatScope,
62
- targetId: string,
63
- fileType: number,
64
- fileInfo: string,
65
- fileUuid: string,
66
- ttl: number,
67
- ): void {
68
- if (cache.size >= MAX_CACHE_SIZE) {
69
- const now = Date.now();
70
- for (const [k, v] of cache) {
71
- if (now >= v.expiresAt) {
72
- cache.delete(k);
73
- }
74
- }
75
- if (cache.size >= MAX_CACHE_SIZE) {
76
- const keys = Array.from(cache.keys());
77
- for (let i = 0; i < keys.length / 2; i++) {
78
- cache.delete(keys[i]);
79
- }
80
- }
81
- }
82
-
83
- const key = buildCacheKey(contentHash, scope, targetId, fileType);
84
- const safetyMargin = 60;
85
- const effectiveTtl = Math.max(ttl - safetyMargin, 10);
86
-
87
- cache.set(key, {
88
- fileInfo,
89
- fileUuid,
90
- expiresAt: Date.now() + effectiveTtl * 1000,
91
- });
92
-
93
- debugLog(
94
- `[upload-cache] Cache SET: key=${key.slice(0, 40)}..., ttl=${effectiveTtl}s, uuid=${fileUuid}`,
95
- );
96
- }
@@ -1,15 +0,0 @@
1
- /**
2
- * Voice transcript formatting utility.
3
- *
4
- * Zero external dependencies — pure string formatting.
5
- */
6
-
7
- /** Format voice transcripts into user-visible text. */
8
- export function formatVoiceText(transcripts: string[]): string {
9
- if (transcripts.length === 0) {
10
- return "";
11
- }
12
- return transcripts.length === 1
13
- ? `[Voice message] ${transcripts[0]}`
14
- : transcripts.map((t, i) => `[Voice ${i + 1}] ${t}`).join("\n");
15
- }