@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,109 +0,0 @@
1
- /**
2
- * Gateway startup diagnostics — extracted from utils/platform.ts.
3
- *
4
- * Depends on utils/platform.ts for detection functions, but no plugin-sdk.
5
- */
6
-
7
- import * as fs from "node:fs";
8
- import * as os from "node:os";
9
- import * as path from "node:path";
10
- import { debugLog } from "./log.js";
11
- import {
12
- getHomeDir,
13
- getTempDir,
14
- getQQBotDataDir,
15
- getPlatform,
16
- isWindows,
17
- detectFfmpeg,
18
- checkSilkWasmAvailable,
19
- } from "./platform.js";
20
-
21
- interface DiagnosticReport {
22
- platform: string;
23
- arch: string;
24
- nodeVersion: string;
25
- homeDir: string;
26
- tempDir: string;
27
- dataDir: string;
28
- ffmpeg: string | null;
29
- silkWasm: boolean;
30
- warnings: string[];
31
- }
32
-
33
- /**
34
- * Run startup diagnostics and return an environment report.
35
- * Called during gateway startup to log environment details and warnings.
36
- */
37
- export async function runDiagnostics(): Promise<DiagnosticReport> {
38
- const warnings: string[] = [];
39
-
40
- const platform = `${process.platform} (${os.release()})`;
41
- const arch = process.arch;
42
- const nodeVersion = process.version;
43
- const homeDir = getHomeDir();
44
- const tempDir = getTempDir();
45
- const dataDir = getQQBotDataDir();
46
-
47
- const ffmpegPath = await detectFfmpeg();
48
- if (!ffmpegPath) {
49
- warnings.push(
50
- isWindows()
51
- ? "⚠️ ffmpeg is not installed. Audio/video conversion will be limited. Install it with choco install ffmpeg, scoop install ffmpeg, or from https://ffmpeg.org."
52
- : getPlatform() === "darwin"
53
- ? "⚠️ ffmpeg is not installed. Audio/video conversion will be limited. Install it with brew install ffmpeg."
54
- : "⚠️ ffmpeg is not installed. Audio/video conversion will be limited. Install it with sudo apt install ffmpeg or sudo yum install ffmpeg.",
55
- );
56
- }
57
-
58
- const silkWasm = await checkSilkWasmAvailable();
59
- if (!silkWasm) {
60
- warnings.push(
61
- "⚠️ silk-wasm is unavailable. QQ voice send/receive will not work. Ensure Node.js >= 16 and WASM support are available.",
62
- );
63
- }
64
-
65
- try {
66
- const testFile = path.join(dataDir, ".write-test");
67
- fs.writeFileSync(testFile, "test");
68
- fs.unlinkSync(testFile);
69
- } catch {
70
- warnings.push(`⚠️ Data directory is not writable: ${dataDir}. Check filesystem permissions.`);
71
- }
72
-
73
- if (isWindows()) {
74
- if (/[\u4e00-\u9fa5]/.test(homeDir) || homeDir.includes(" ")) {
75
- warnings.push(
76
- `⚠️ Home directory contains Chinese characters or spaces: ${homeDir}. Some tools may fail. Consider setting QQBOT_DATA_DIR to an ASCII-only path.`,
77
- );
78
- }
79
- }
80
-
81
- const report: DiagnosticReport = {
82
- platform,
83
- arch,
84
- nodeVersion,
85
- homeDir,
86
- tempDir,
87
- dataDir,
88
- ffmpeg: ffmpegPath,
89
- silkWasm,
90
- warnings,
91
- };
92
-
93
- debugLog("=== QQBot Environment Diagnostics ===");
94
- debugLog(` Platform: ${platform} (${arch})`);
95
- debugLog(` Node: ${nodeVersion}`);
96
- debugLog(` Home: ${homeDir}`);
97
- debugLog(` Data dir: ${dataDir}`);
98
- debugLog(` ffmpeg: ${ffmpegPath ?? "not installed"}`);
99
- debugLog(` silk-wasm: ${silkWasm ? "available" : "unavailable"}`);
100
- if (warnings.length > 0) {
101
- debugLog(" --- Warnings ---");
102
- for (const w of warnings) {
103
- debugLog(` ${w}`);
104
- }
105
- }
106
- debugLog("======================");
107
-
108
- return report;
109
- }
@@ -1,72 +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, beforeEach, describe, expect, it, vi } from "vitest";
5
-
6
- const adapterMocks = vi.hoisted(() => ({
7
- fetchMedia: vi.fn(),
8
- }));
9
-
10
- vi.mock("../adapter/index.js", () => ({
11
- getPlatformAdapter: () => ({
12
- fetchMedia: (...args: unknown[]) => adapterMocks.fetchMedia(...args),
13
- }),
14
- }));
15
-
16
- import { QQBOT_MEDIA_SSRF_POLICY, downloadFile } from "./file-utils.js";
17
-
18
- describe("qqbot file-utils downloadFile", () => {
19
- let tempDir: string;
20
-
21
- beforeEach(async () => {
22
- adapterMocks.fetchMedia.mockReset();
23
- tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "qqbot-file-utils-"));
24
- });
25
-
26
- afterEach(async () => {
27
- await fs.promises.rm(tempDir, { recursive: true, force: true });
28
- });
29
-
30
- it("downloads through the guarded media adapter with the qqbot SSRF policy", async () => {
31
- adapterMocks.fetchMedia.mockResolvedValueOnce({
32
- buffer: Buffer.from("image-bytes"),
33
- contentType: "image/png",
34
- fileName: "remote.png",
35
- });
36
-
37
- const savedPath = await downloadFile(
38
- "https://media.qq.com/assets/photo.png",
39
- tempDir,
40
- "photo.png",
41
- );
42
-
43
- expect(savedPath).toBeTruthy();
44
- expect(savedPath).toMatch(/photo_\d+_[0-9a-f]{6}\.png$/);
45
- expect(await fs.promises.readFile(savedPath!, "utf8")).toBe("image-bytes");
46
- expect(adapterMocks.fetchMedia).toHaveBeenCalledWith({
47
- url: "https://media.qq.com/assets/photo.png",
48
- filePathHint: "photo.png",
49
- ssrfPolicy: QQBOT_MEDIA_SSRF_POLICY,
50
- });
51
- expect(QQBOT_MEDIA_SSRF_POLICY).toEqual({
52
- hostnameAllowlist: [
53
- "*.qpic.cn",
54
- "*.qq.com",
55
- "*.weiyun.com",
56
- "*.qq.com.cn",
57
- "*.ugcimg.cn",
58
- "*.myqcloud.com",
59
- "*.tencentcos.cn",
60
- "*.tencentcos.com",
61
- ],
62
- allowRfc2544BenchmarkRange: true,
63
- });
64
- });
65
-
66
- it("rejects non-HTTPS URLs before attempting a fetch", async () => {
67
- const savedPath = await downloadFile("http://media.qq.com/assets/photo.png", tempDir);
68
-
69
- expect(savedPath).toBeNull();
70
- expect(adapterMocks.fetchMedia).not.toHaveBeenCalled();
71
- });
72
- });
@@ -1,225 +0,0 @@
1
- import crypto from "node:crypto";
2
- import * as fs from "node:fs";
3
- import * as path from "node:path";
4
- import { getPlatformAdapter } from "../adapter/index.js";
5
- import type { SsrfPolicyConfig } from "../adapter/types.js";
6
- import { MediaFileType } from "../types.js";
7
- import { formatErrorMessage } from "./format.js";
8
- import { normalizeLowercaseStringOrEmpty, normalizeOptionalString } from "./string-normalize.js";
9
-
10
- /** Maximum file size accepted by the QQ Bot one-shot upload API (base64 direct). */
11
- export const MAX_UPLOAD_SIZE = 20 * 1024 * 1024;
12
-
13
- /** Absolute upper bound enforced on the chunked upload path (matches server policy). */
14
- const CHUNKED_UPLOAD_MAX_SIZE = 100 * 1024 * 1024;
15
-
16
- /** Threshold used to treat an upload as a large file (dispatch to chunked path). */
17
- export const LARGE_FILE_THRESHOLD = 5 * 1024 * 1024;
18
-
19
- /**
20
- * Per-{@link MediaFileType} upload metadata: the QQ Open Platform size
21
- * ceiling and the Chinese display name used in user-facing error messages.
22
- *
23
- * Keyed by the enum value so call sites read as
24
- * `MEDIA_FILE_TYPE_INFO[MediaFileType.IMAGE].maxSize`, and adding a new
25
- * type forces both fields to be supplied in a single place.
26
- */
27
- const MEDIA_FILE_TYPE_INFO: Record<MediaFileType, { maxSize: number; name: string }> = {
28
- [MediaFileType.IMAGE]: { maxSize: 30 * 1024 * 1024, name: "图片" },
29
- [MediaFileType.VIDEO]: { maxSize: 100 * 1024 * 1024, name: "视频" },
30
- [MediaFileType.VOICE]: { maxSize: 20 * 1024 * 1024, name: "语音" },
31
- [MediaFileType.FILE]: { maxSize: 100 * 1024 * 1024, name: "文件" },
32
- };
33
-
34
- /** Return the Chinese display name for a media file type code. Defaults to "文件". */
35
- export function getFileTypeName(fileType: number): string {
36
- return MEDIA_FILE_TYPE_INFO[fileType as MediaFileType]?.name ?? "文件";
37
- }
38
-
39
- /** Return the upload ceiling for a given media file type. Defaults to 100MB. */
40
- export function getMaxUploadSize(fileType: number): number {
41
- return MEDIA_FILE_TYPE_INFO[fileType as MediaFileType]?.maxSize ?? CHUNKED_UPLOAD_MAX_SIZE;
42
- }
43
-
44
- const QQBOT_MEDIA_HOSTNAME_ALLOWLIST = [
45
- // QQ rich media
46
- "*.qpic.cn",
47
- "*.qq.com",
48
- "*.weiyun.com",
49
- "*.qq.com.cn",
50
-
51
- // QQ Bot
52
- "*.ugcimg.cn",
53
-
54
- // Tencent Cloud COS
55
- "*.myqcloud.com",
56
- "*.tencentcos.cn",
57
- "*.tencentcos.com",
58
- ];
59
-
60
- export const QQBOT_MEDIA_SSRF_POLICY: SsrfPolicyConfig = {
61
- hostnameAllowlist: QQBOT_MEDIA_HOSTNAME_ALLOWLIST,
62
- allowRfc2544BenchmarkRange: true,
63
- };
64
-
65
- /** Result of local file-size validation. */
66
- interface FileSizeCheckResult {
67
- ok: boolean;
68
- size: number;
69
- error?: string;
70
- }
71
-
72
- /** Validate that a file is within the allowed upload size. */
73
- export function checkFileSize(filePath: string, maxSize = MAX_UPLOAD_SIZE): FileSizeCheckResult {
74
- try {
75
- const stat = fs.statSync(filePath);
76
- if (stat.size > maxSize) {
77
- const sizeMB = (stat.size / (1024 * 1024)).toFixed(1);
78
- const limitMB = (maxSize / (1024 * 1024)).toFixed(0);
79
- return {
80
- ok: false,
81
- size: stat.size,
82
- error: `File is too large (${sizeMB}MB); QQ Bot API limit is ${limitMB}MB`,
83
- };
84
- }
85
- return { ok: true, size: stat.size };
86
- } catch (err) {
87
- return {
88
- ok: false,
89
- size: 0,
90
- error: `Failed to read file metadata: ${formatErrorMessage(err)}`,
91
- };
92
- }
93
- }
94
-
95
- /** Read file contents asynchronously. */
96
- export async function readFileAsync(filePath: string): Promise<Buffer> {
97
- return fs.promises.readFile(filePath);
98
- }
99
-
100
- /** Check file readability asynchronously. */
101
- export async function fileExistsAsync(filePath: string): Promise<boolean> {
102
- try {
103
- await fs.promises.access(filePath, fs.constants.R_OK);
104
- return true;
105
- } catch {
106
- return false;
107
- }
108
- }
109
-
110
- /** Format a byte count into a human-readable size string. */
111
- export function formatFileSize(bytes: number): string {
112
- if (bytes < 1024) {
113
- return `${bytes}B`;
114
- }
115
- if (bytes < 1024 * 1024) {
116
- return `${(bytes / 1024).toFixed(1)}KB`;
117
- }
118
- return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
119
- }
120
-
121
- /** Infer a MIME type from the file extension. */
122
- export function getMimeType(filePath: string): string {
123
- const ext = normalizeLowercaseStringOrEmpty(path.extname(filePath));
124
- return MIME_TYPES[ext] ?? "application/octet-stream";
125
- }
126
-
127
- /** Canonical ext → MIME table. Single source of truth. */
128
- const MIME_TYPES: Record<string, string> = {
129
- ".jpg": "image/jpeg",
130
- ".jpeg": "image/jpeg",
131
- ".png": "image/png",
132
- ".gif": "image/gif",
133
- ".webp": "image/webp",
134
- ".bmp": "image/bmp",
135
- ".mp4": "video/mp4",
136
- ".mov": "video/quicktime",
137
- ".avi": "video/x-msvideo",
138
- ".mkv": "video/x-matroska",
139
- ".webm": "video/webm",
140
- ".pdf": "application/pdf",
141
- ".doc": "application/msword",
142
- ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
143
- ".xls": "application/vnd.ms-excel",
144
- ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
145
- ".zip": "application/zip",
146
- ".tar": "application/x-tar",
147
- ".gz": "application/gzip",
148
- ".txt": "text/plain",
149
- };
150
-
151
- /** Extensions accepted as image uploads by the QQ Bot media pipeline. */
152
- const IMAGE_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"]);
153
-
154
- /**
155
- * Return the image MIME type for a local file path, or `null` if the
156
- * extension is not in the supported image whitelist.
157
- *
158
- * Use this instead of `getMimeType` when the caller must enforce
159
- * "image formats only" as a business rule (e.g. constructing a
160
- * `data:image/...;base64,` URL).
161
- */
162
- export function getImageMimeType(filePath: string): string | null {
163
- const ext = normalizeLowercaseStringOrEmpty(path.extname(filePath));
164
- if (!IMAGE_EXTENSIONS.has(ext)) {
165
- return null;
166
- }
167
- return MIME_TYPES[ext] ?? null;
168
- }
169
-
170
- /** Download a remote file into a local directory. */
171
- export async function downloadFile(
172
- url: string,
173
- destDir: string,
174
- originalFilename?: string,
175
- ): Promise<string | null> {
176
- try {
177
- let parsedUrl: URL;
178
- try {
179
- parsedUrl = new URL(url);
180
- } catch {
181
- return null;
182
- }
183
- if (parsedUrl.protocol !== "https:") {
184
- return null;
185
- }
186
-
187
- if (!fs.existsSync(destDir)) {
188
- fs.mkdirSync(destDir, { recursive: true });
189
- }
190
-
191
- const fetched = await getPlatformAdapter().fetchMedia({
192
- url: parsedUrl.toString(),
193
- filePathHint: originalFilename,
194
- ssrfPolicy: QQBOT_MEDIA_SSRF_POLICY,
195
- });
196
-
197
- let filename = normalizeOptionalString(originalFilename) ?? "";
198
- if (!filename) {
199
- filename =
200
- (normalizeOptionalString(fetched.fileName) ?? path.basename(parsedUrl.pathname)) ||
201
- "download";
202
- }
203
-
204
- const ts = Date.now();
205
- const ext = path.extname(filename);
206
- const base = path.basename(filename, ext) || "file";
207
- const rand = crypto.randomBytes(3).toString("hex");
208
- const safeFilename = `${base}_${ts}_${rand}${ext}`;
209
-
210
- const destPath = path.join(destDir, safeFilename);
211
- await fs.promises.writeFile(destPath, fetched.buffer);
212
- return destPath;
213
- } catch (err) {
214
- console.error(
215
- `[qqbot:downloadFile] FAILED url=${url.slice(0, 120)} error=${err instanceof Error ? err.message : String(err)}`,
216
- );
217
- if (err instanceof Error && err.stack) {
218
- console.error(`[qqbot:downloadFile] stack=${err.stack.split("\n").slice(0, 3).join(" | ")}`);
219
- }
220
- if (err instanceof Error && err.cause) {
221
- console.error(`[qqbot:downloadFile] cause=${formatErrorMessage(err.cause)}`);
222
- }
223
- return null;
224
- }
225
- }
@@ -1,68 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
- import { formatErrorMessage, formatDuration } from "./format.js";
3
-
4
- describe("engine/utils/format", () => {
5
- describe("formatErrorMessage", () => {
6
- it("extracts message from Error instances", () => {
7
- expect(formatErrorMessage(new Error("boom"))).toBe("boom");
8
- });
9
-
10
- it("returns strings as-is", () => {
11
- expect(formatErrorMessage("plain text")).toBe("plain text");
12
- });
13
-
14
- it("traverses the .cause chain", () => {
15
- const inner = new Error("inner");
16
- const outer = new Error("outer", { cause: inner });
17
- expect(formatErrorMessage(outer)).toBe("outer | inner");
18
- });
19
-
20
- it("handles string cause", () => {
21
- const err = new Error("outer", { cause: "string cause" });
22
- expect(formatErrorMessage(err)).toBe("outer | string cause");
23
- });
24
-
25
- it("stringifies numbers", () => {
26
- expect(formatErrorMessage(42)).toBe("42");
27
- });
28
-
29
- it("stringifies null", () => {
30
- expect(formatErrorMessage(null)).toBe("null");
31
- });
32
-
33
- it("stringifies undefined", () => {
34
- expect(formatErrorMessage(undefined)).toBe("undefined");
35
- });
36
-
37
- it("JSON-stringifies plain objects", () => {
38
- expect(formatErrorMessage({ code: 500 })).toBe('{"code":500}');
39
- });
40
- });
41
-
42
- describe("formatDuration", () => {
43
- it("formats zero", () => {
44
- expect(formatDuration(0)).toBe("0s");
45
- });
46
-
47
- it("formats sub-minute durations as seconds", () => {
48
- expect(formatDuration(45_000)).toBe("45s");
49
- });
50
-
51
- it("formats exactly 60 seconds as 1m", () => {
52
- expect(formatDuration(60_000)).toBe("1m");
53
- });
54
-
55
- it("formats mixed minutes and seconds", () => {
56
- expect(formatDuration(90_000)).toBe("1m 30s");
57
- });
58
-
59
- it("formats exact minutes without trailing seconds", () => {
60
- expect(formatDuration(300_000)).toBe("5m");
61
- });
62
-
63
- it("rounds sub-second values", () => {
64
- expect(formatDuration(1_499)).toBe("1s");
65
- expect(formatDuration(1_500)).toBe("2s");
66
- });
67
- });
68
- });
@@ -1,70 +0,0 @@
1
- /**
2
- * General formatting and string utilities.
3
- * 通用格式化与字符串工具。
4
- *
5
- * Pure utility functions with zero external dependencies.
6
- * Replaces `openclaw/plugin-sdk/error-runtime` and `text-runtime`
7
- * helpers for use inside engine/.
8
- *
9
- * NOTE: The framework `formatErrorMessage` also applies `redactSensitiveText()`
10
- * for token masking. We intentionally omit that here — the framework's log
11
- * pipeline handles redaction at a higher level.
12
- */
13
-
14
- /**
15
- * Format any error object into a readable string.
16
- * 将任意错误对象格式化为可读字符串。
17
- *
18
- * Traverses the `.cause` chain for nested Error objects to include
19
- * the full error context (e.g. network errors wrapped inside HTTP errors).
20
- */
21
- export function formatErrorMessage(err: unknown): string {
22
- if (err instanceof Error) {
23
- let formatted = err.message || err.name || "Error";
24
- let cause: unknown = err.cause;
25
- const seen = new Set<unknown>([err]);
26
- while (cause && !seen.has(cause)) {
27
- seen.add(cause);
28
- if (cause instanceof Error) {
29
- if (cause.message) {
30
- formatted += ` | ${cause.message}`;
31
- }
32
- cause = cause.cause;
33
- } else if (typeof cause === "string") {
34
- formatted += ` | ${cause}`;
35
- break;
36
- } else {
37
- break;
38
- }
39
- }
40
- return formatted;
41
- }
42
- if (typeof err === "string") {
43
- return err;
44
- }
45
- if (
46
- err === null ||
47
- err === undefined ||
48
- typeof err === "number" ||
49
- typeof err === "boolean" ||
50
- typeof err === "bigint"
51
- ) {
52
- return String(err);
53
- }
54
- try {
55
- return JSON.stringify(err);
56
- } catch {
57
- return Object.prototype.toString.call(err);
58
- }
59
- }
60
-
61
- /** Format a millisecond duration into a human-readable string (e.g. "5m 30s"). */
62
- export function formatDuration(durationMs: number): string {
63
- const seconds = Math.round(durationMs / 1000);
64
- if (seconds < 60) {
65
- return `${seconds}s`;
66
- }
67
- const minutes = Math.floor(seconds / 60);
68
- const remainSeconds = seconds % 60;
69
- return remainSeconds > 0 ? `${minutes}m ${remainSeconds}s` : `${minutes}m`;
70
- }
@@ -1,158 +0,0 @@
1
- import { Buffer } from "node:buffer";
2
- import { beforeEach, describe, expect, it, vi } from "vitest";
3
-
4
- const adapterMocks = vi.hoisted(() => ({
5
- fetchMedia: vi.fn(),
6
- }));
7
-
8
- vi.mock("../adapter/index.js", () => ({
9
- getPlatformAdapter: () => ({
10
- fetchMedia: (...args: unknown[]) => adapterMocks.fetchMedia(...args),
11
- }),
12
- }));
13
-
14
- import { getImageSizeFromUrl, parseImageSize } from "./image-size.js";
15
-
16
- /** Build a minimal valid PNG header with the given dimensions. */
17
- function buildPngHeader(width: number, height: number): Buffer {
18
- const buf = Buffer.alloc(24);
19
- // PNG signature
20
- buf[0] = 0x89;
21
- buf[1] = 0x50;
22
- buf[2] = 0x4e;
23
- buf[3] = 0x47;
24
- buf[4] = 0x0d;
25
- buf[5] = 0x0a;
26
- buf[6] = 0x1a;
27
- buf[7] = 0x0a;
28
- // IHDR chunk length
29
- buf.writeUInt32BE(13, 8);
30
- // "IHDR"
31
- buf.write("IHDR", 12, "ascii");
32
- // Width and height
33
- buf.writeUInt32BE(width, 16);
34
- buf.writeUInt32BE(height, 20);
35
- return buf;
36
- }
37
-
38
- describe("getImageSizeFromUrl", () => {
39
- beforeEach(() => {
40
- adapterMocks.fetchMedia.mockReset();
41
- });
42
-
43
- describe("fetchMedia options contract", () => {
44
- it("passes maxBytes, maxRedirects, ssrfPolicy, and headers", async () => {
45
- adapterMocks.fetchMedia.mockResolvedValueOnce({
46
- buffer: buildPngHeader(800, 600),
47
- contentType: "image/png",
48
- });
49
-
50
- await getImageSizeFromUrl("https://cdn.example.com/photo.png");
51
-
52
- expect(adapterMocks.fetchMedia).toHaveBeenCalledOnce();
53
- const opts = adapterMocks.fetchMedia.mock.calls[0][0];
54
-
55
- expect(opts.url).toBe("https://cdn.example.com/photo.png");
56
- expect(opts.maxBytes).toBe(65_536);
57
- expect(opts.maxRedirects).toBe(0);
58
- // Generic public-network-only policy: no hostname allowlist
59
- expect(opts.ssrfPolicy).toEqual({});
60
- expect(opts.requestInit.headers).toEqual({
61
- Range: "bytes=0-65535",
62
- "User-Agent": "QQBot-Image-Size-Detector/1.0",
63
- });
64
- });
65
-
66
- it("threads caller abort signal through requestInit", async () => {
67
- adapterMocks.fetchMedia.mockResolvedValueOnce({
68
- buffer: buildPngHeader(100, 100),
69
- });
70
-
71
- await getImageSizeFromUrl("https://cdn.example.com/img.png", 3000);
72
-
73
- const opts = adapterMocks.fetchMedia.mock.calls[0][0];
74
- expect(opts.requestInit.signal).toBeInstanceOf(AbortSignal);
75
- });
76
- });
77
-
78
- describe("SSRF blocking (adapter.fetchMedia rejects)", () => {
79
- it("returns null when adapter.fetchMedia throws for loopback", async () => {
80
- adapterMocks.fetchMedia.mockRejectedValueOnce(new Error("SSRF blocked: loopback address"));
81
-
82
- const result = await getImageSizeFromUrl("https://127.0.0.1/img.png");
83
-
84
- expect(result).toBeNull();
85
- });
86
-
87
- it("returns null when adapter.fetchMedia throws for IPv6 loopback", async () => {
88
- adapterMocks.fetchMedia.mockRejectedValueOnce(new Error("SSRF blocked: loopback address"));
89
-
90
- const result = await getImageSizeFromUrl("https://[::1]/img.png");
91
-
92
- expect(result).toBeNull();
93
- });
94
-
95
- it("returns null when adapter.fetchMedia throws for link-local/metadata", async () => {
96
- adapterMocks.fetchMedia.mockRejectedValueOnce(new Error("SSRF blocked: link-local address"));
97
-
98
- const result = await getImageSizeFromUrl("https://169.254.169.254/latest/meta-data/");
99
-
100
- expect(result).toBeNull();
101
- });
102
-
103
- it("returns null when adapter.fetchMedia throws for RFC1918 addresses", async () => {
104
- adapterMocks.fetchMedia.mockRejectedValueOnce(new Error("SSRF blocked: private address"));
105
-
106
- const result = await getImageSizeFromUrl("https://10.0.0.1/img.png");
107
-
108
- expect(result).toBeNull();
109
- });
110
-
111
- it("returns null on http error from adapter.fetchMedia", async () => {
112
- adapterMocks.fetchMedia.mockRejectedValueOnce(new Error("HTTP 403 Forbidden"));
113
-
114
- const result = await getImageSizeFromUrl("https://cdn.example.com/forbidden.png");
115
-
116
- expect(result).toBeNull();
117
- });
118
- });
119
-
120
- describe("happy path", () => {
121
- it("returns parsed dimensions for a valid PNG", async () => {
122
- adapterMocks.fetchMedia.mockResolvedValueOnce({
123
- buffer: buildPngHeader(1920, 1080),
124
- contentType: "image/png",
125
- });
126
-
127
- const size = await getImageSizeFromUrl("https://cdn.example.com/banner.png");
128
-
129
- expect(size).toEqual({ width: 1920, height: 1080 });
130
- });
131
-
132
- it("returns null when the buffer is not a recognized image format", async () => {
133
- adapterMocks.fetchMedia.mockResolvedValueOnce({
134
- buffer: Buffer.from("not an image"),
135
- contentType: "text/html",
136
- });
137
-
138
- const size = await getImageSizeFromUrl("https://cdn.example.com/notimage.html");
139
-
140
- expect(size).toBeNull();
141
- });
142
- });
143
- });
144
-
145
- describe("parseImageSize", () => {
146
- it("parses PNG dimensions", () => {
147
- const size = parseImageSize(buildPngHeader(640, 480));
148
- expect(size).toEqual({ width: 640, height: 480 });
149
- });
150
-
151
- it("returns null for unrecognized data", () => {
152
- expect(parseImageSize(Buffer.from("hello"))).toBeNull();
153
- });
154
-
155
- it("returns null for empty buffer", () => {
156
- expect(parseImageSize(Buffer.alloc(0))).toBeNull();
157
- });
158
- });