@openclaw/qqbot 2026.5.1-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 (187) hide show
  1. package/api.ts +56 -0
  2. package/channel-plugin-api.ts +1 -0
  3. package/dist/.boundary-tsc.stamp +1 -0
  4. package/dist/.boundary-tsc.tsbuildinfo +1 -0
  5. package/index.ts +29 -0
  6. package/openclaw.plugin.json +814 -0
  7. package/package.json +66 -0
  8. package/runtime-api.ts +9 -0
  9. package/setup-entry.ts +9 -0
  10. package/setup-plugin-api.ts +3 -0
  11. package/skills/qqbot-channel/SKILL.md +262 -0
  12. package/skills/qqbot-channel/references/api_references.md +521 -0
  13. package/skills/qqbot-media/SKILL.md +37 -0
  14. package/skills/qqbot-remind/SKILL.md +153 -0
  15. package/src/bridge/approval/capability.ts +237 -0
  16. package/src/bridge/approval/handler-runtime.ts +204 -0
  17. package/src/bridge/bootstrap.ts +135 -0
  18. package/src/bridge/channel-entry.ts +18 -0
  19. package/src/bridge/commands/framework-context-adapter.ts +60 -0
  20. package/src/bridge/commands/framework-registration.ts +47 -0
  21. package/src/bridge/commands/from-parser.test.ts +86 -0
  22. package/src/bridge/commands/from-parser.ts +60 -0
  23. package/src/bridge/commands/result-dispatcher.ts +76 -0
  24. package/src/bridge/config-shared.ts +132 -0
  25. package/src/bridge/config.ts +111 -0
  26. package/src/bridge/gateway.ts +174 -0
  27. package/src/bridge/logger.ts +31 -0
  28. package/src/bridge/narrowing.ts +31 -0
  29. package/src/bridge/plugin-version.test.ts +146 -0
  30. package/src/bridge/plugin-version.ts +102 -0
  31. package/src/bridge/runtime.ts +25 -0
  32. package/src/bridge/sdk-adapter.ts +131 -0
  33. package/src/bridge/setup/finalize.ts +144 -0
  34. package/src/bridge/setup/surface.ts +34 -0
  35. package/src/bridge/tools/channel.ts +58 -0
  36. package/src/bridge/tools/index.ts +15 -0
  37. package/src/bridge/tools/remind.test.ts +124 -0
  38. package/src/bridge/tools/remind.ts +91 -0
  39. package/src/channel.setup.ts +33 -0
  40. package/src/channel.ts +288 -0
  41. package/src/command-auth.test.ts +62 -0
  42. package/src/config-schema.ts +84 -0
  43. package/src/config.test.ts +364 -0
  44. package/src/engine/access/access-control.test.ts +198 -0
  45. package/src/engine/access/access-control.ts +226 -0
  46. package/src/engine/access/index.ts +16 -0
  47. package/src/engine/access/resolve-policy.test.ts +59 -0
  48. package/src/engine/access/resolve-policy.ts +57 -0
  49. package/src/engine/access/sender-match.test.ts +60 -0
  50. package/src/engine/access/sender-match.ts +55 -0
  51. package/src/engine/access/types.ts +53 -0
  52. package/src/engine/adapter/audio.port.ts +27 -0
  53. package/src/engine/adapter/commands.port.ts +22 -0
  54. package/src/engine/adapter/history.port.ts +52 -0
  55. package/src/engine/adapter/index.ts +139 -0
  56. package/src/engine/adapter/mention-gate.port.ts +50 -0
  57. package/src/engine/adapter/types.ts +38 -0
  58. package/src/engine/api/api-client.ts +212 -0
  59. package/src/engine/api/media-chunked.test.ts +336 -0
  60. package/src/engine/api/media-chunked.ts +622 -0
  61. package/src/engine/api/media.ts +218 -0
  62. package/src/engine/api/messages.ts +293 -0
  63. package/src/engine/api/retry.ts +217 -0
  64. package/src/engine/api/routes.ts +95 -0
  65. package/src/engine/api/token.ts +271 -0
  66. package/src/engine/approval/index.test.ts +22 -0
  67. package/src/engine/approval/index.ts +224 -0
  68. package/src/engine/commands/builtin/log-helpers.ts +319 -0
  69. package/src/engine/commands/builtin/register-all.ts +17 -0
  70. package/src/engine/commands/builtin/register-approve.ts +201 -0
  71. package/src/engine/commands/builtin/register-basic.ts +95 -0
  72. package/src/engine/commands/builtin/register-clear-storage.ts +187 -0
  73. package/src/engine/commands/builtin/register-logs.ts +20 -0
  74. package/src/engine/commands/builtin/register-streaming.ts +137 -0
  75. package/src/engine/commands/builtin/state.ts +31 -0
  76. package/src/engine/commands/slash-command-auth.ts +48 -0
  77. package/src/engine/commands/slash-command-handler.ts +146 -0
  78. package/src/engine/commands/slash-commands-impl.test.ts +8 -0
  79. package/src/engine/commands/slash-commands-impl.ts +61 -0
  80. package/src/engine/commands/slash-commands.ts +199 -0
  81. package/src/engine/config/credential-backup.test.ts +88 -0
  82. package/src/engine/config/credential-backup.ts +107 -0
  83. package/src/engine/config/credentials.ts +76 -0
  84. package/src/engine/config/group.test.ts +234 -0
  85. package/src/engine/config/group.ts +299 -0
  86. package/src/engine/config/resolve.test.ts +152 -0
  87. package/src/engine/config/resolve.ts +283 -0
  88. package/src/engine/config/setup-logic.ts +84 -0
  89. package/src/engine/engine-import-boundary.test.ts +73 -0
  90. package/src/engine/gateway/codec.ts +47 -0
  91. package/src/engine/gateway/constants.ts +117 -0
  92. package/src/engine/gateway/event-dispatcher.ts +177 -0
  93. package/src/engine/gateway/gateway-connection.ts +371 -0
  94. package/src/engine/gateway/gateway.ts +291 -0
  95. package/src/engine/gateway/inbound-attachments.test.ts +126 -0
  96. package/src/engine/gateway/inbound-attachments.ts +360 -0
  97. package/src/engine/gateway/inbound-context.ts +195 -0
  98. package/src/engine/gateway/inbound-pipeline.self-echo.test.ts +218 -0
  99. package/src/engine/gateway/inbound-pipeline.ts +235 -0
  100. package/src/engine/gateway/interaction-handler.ts +220 -0
  101. package/src/engine/gateway/message-queue.test.ts +282 -0
  102. package/src/engine/gateway/message-queue.ts +499 -0
  103. package/src/engine/gateway/outbound-dispatch.test.ts +231 -0
  104. package/src/engine/gateway/outbound-dispatch.ts +575 -0
  105. package/src/engine/gateway/reconnect.ts +199 -0
  106. package/src/engine/gateway/stages/access-stage.ts +132 -0
  107. package/src/engine/gateway/stages/assembly-stage.ts +156 -0
  108. package/src/engine/gateway/stages/content-stage.test.ts +77 -0
  109. package/src/engine/gateway/stages/content-stage.ts +77 -0
  110. package/src/engine/gateway/stages/envelope-stage.test.ts +152 -0
  111. package/src/engine/gateway/stages/envelope-stage.ts +144 -0
  112. package/src/engine/gateway/stages/group-gate-stage.ts +292 -0
  113. package/src/engine/gateway/stages/index.ts +18 -0
  114. package/src/engine/gateway/stages/quote-stage.ts +113 -0
  115. package/src/engine/gateway/stages/refidx-stage.ts +62 -0
  116. package/src/engine/gateway/stages/stub-contexts.ts +116 -0
  117. package/src/engine/gateway/types.ts +264 -0
  118. package/src/engine/gateway/typing-keepalive.ts +79 -0
  119. package/src/engine/group/activation.test.ts +114 -0
  120. package/src/engine/group/activation.ts +147 -0
  121. package/src/engine/group/history.test.ts +314 -0
  122. package/src/engine/group/history.ts +321 -0
  123. package/src/engine/group/mention.test.ts +141 -0
  124. package/src/engine/group/mention.ts +197 -0
  125. package/src/engine/group/message-gating.test.ts +188 -0
  126. package/src/engine/group/message-gating.ts +216 -0
  127. package/src/engine/messaging/decode-media-path.ts +82 -0
  128. package/src/engine/messaging/media-source.ts +215 -0
  129. package/src/engine/messaging/media-type-detect.ts +37 -0
  130. package/src/engine/messaging/outbound-audio-port.ts +38 -0
  131. package/src/engine/messaging/outbound-deliver.ts +810 -0
  132. package/src/engine/messaging/outbound-media-send.ts +702 -0
  133. package/src/engine/messaging/outbound-reply.ts +27 -0
  134. package/src/engine/messaging/outbound-result-helpers.ts +54 -0
  135. package/src/engine/messaging/outbound-types.ts +45 -0
  136. package/src/engine/messaging/outbound.ts +485 -0
  137. package/src/engine/messaging/reply-dispatcher.ts +597 -0
  138. package/src/engine/messaging/reply-limiter.ts +164 -0
  139. package/src/engine/messaging/sender.ts +729 -0
  140. package/src/engine/messaging/streaming-c2c.ts +1192 -0
  141. package/src/engine/messaging/streaming-media-send.ts +544 -0
  142. package/src/engine/messaging/target-parser.ts +104 -0
  143. package/src/engine/ref/format-message-ref.ts +142 -0
  144. package/src/engine/ref/format-ref-entry.test.ts +60 -0
  145. package/src/engine/ref/format-ref-entry.ts +27 -0
  146. package/src/engine/ref/store.ts +224 -0
  147. package/src/engine/ref/types.ts +27 -0
  148. package/src/engine/session/known-users.ts +254 -0
  149. package/src/engine/session/session-store.ts +284 -0
  150. package/src/engine/tools/channel-api.ts +244 -0
  151. package/src/engine/tools/remind-logic.test.ts +280 -0
  152. package/src/engine/tools/remind-logic.ts +377 -0
  153. package/src/engine/types.ts +313 -0
  154. package/src/engine/utils/attachment-tags.test.ts +186 -0
  155. package/src/engine/utils/attachment-tags.ts +174 -0
  156. package/src/engine/utils/audio.test.ts +250 -0
  157. package/src/engine/utils/audio.ts +585 -0
  158. package/src/engine/utils/data-paths.ts +38 -0
  159. package/src/engine/utils/diagnostics.ts +109 -0
  160. package/src/engine/utils/file-utils.test.ts +72 -0
  161. package/src/engine/utils/file-utils.ts +225 -0
  162. package/src/engine/utils/format.test.ts +68 -0
  163. package/src/engine/utils/format.ts +70 -0
  164. package/src/engine/utils/image-size.test.ts +158 -0
  165. package/src/engine/utils/image-size.ts +249 -0
  166. package/src/engine/utils/log.test.ts +28 -0
  167. package/src/engine/utils/log.ts +61 -0
  168. package/src/engine/utils/media-tags.test.ts +32 -0
  169. package/src/engine/utils/media-tags.ts +177 -0
  170. package/src/engine/utils/payload.test.ts +68 -0
  171. package/src/engine/utils/payload.ts +145 -0
  172. package/src/engine/utils/platform-storage-laziness.test.ts +65 -0
  173. package/src/engine/utils/platform.test.ts +148 -0
  174. package/src/engine/utils/platform.ts +343 -0
  175. package/src/engine/utils/request-context.ts +60 -0
  176. package/src/engine/utils/string-normalize.ts +91 -0
  177. package/src/engine/utils/stt.test.ts +104 -0
  178. package/src/engine/utils/stt.ts +100 -0
  179. package/src/engine/utils/text-parsing.test.ts +29 -0
  180. package/src/engine/utils/text-parsing.ts +155 -0
  181. package/src/engine/utils/upload-cache.ts +96 -0
  182. package/src/engine/utils/voice-text.ts +15 -0
  183. package/src/exec-approvals.ts +218 -0
  184. package/src/manifest-schema.test.ts +56 -0
  185. package/src/qqbot-test-support.ts +29 -0
  186. package/src/types.ts +210 -0
  187. package/tsconfig.json +16 -0
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Structured payload parsing and encoding for QQ Bot messages.
3
+ *
4
+ * Handles `QQBOT_PAYLOAD:` (model-emitted structured payloads) and
5
+ * `QQBOT_CRON:` (persisted cron reminder payloads).
6
+ *
7
+ * Zero external dependencies.
8
+ */
9
+
10
+ import type { ChatScope } from "../types.js";
11
+
12
+ /** Structured reminder payload emitted by the model. */
13
+ export interface CronReminderPayload {
14
+ type: "cron_reminder";
15
+ content: string;
16
+ targetType: ChatScope;
17
+ targetAddress: string;
18
+ originalMessageId?: string;
19
+ }
20
+
21
+ /** Structured media payload emitted by the model. */
22
+ export interface MediaPayload {
23
+ type: "media";
24
+ mediaType: "image" | "audio" | "video" | "file";
25
+ source: "url" | "file";
26
+ path: string;
27
+ caption?: string;
28
+ }
29
+
30
+ type QQBotPayload = CronReminderPayload | MediaPayload;
31
+
32
+ /** Result of parsing model output into a structured payload. */
33
+ interface ParseResult {
34
+ isPayload: boolean;
35
+ payload?: QQBotPayload;
36
+ text?: string;
37
+ error?: string;
38
+ }
39
+
40
+ const PAYLOAD_PREFIX = "QQBOT_PAYLOAD:";
41
+ const CRON_PREFIX = "QQBOT_CRON:";
42
+
43
+ function formatErr(e: unknown): string {
44
+ return e instanceof Error ? e.message : String(e);
45
+ }
46
+
47
+ /** Parse model output that may start with the QQ Bot structured payload prefix. */
48
+ export function parseQQBotPayload(text: string): ParseResult {
49
+ const trimmedText = text.trim();
50
+
51
+ if (!trimmedText.startsWith(PAYLOAD_PREFIX)) {
52
+ return { isPayload: false, text };
53
+ }
54
+
55
+ const jsonContent = trimmedText.slice(PAYLOAD_PREFIX.length).trim();
56
+
57
+ if (!jsonContent) {
58
+ return { isPayload: true, error: "Payload body is empty" };
59
+ }
60
+
61
+ try {
62
+ const payload = JSON.parse(jsonContent) as QQBotPayload;
63
+
64
+ if (!payload.type) {
65
+ return { isPayload: true, error: "Payload is missing the type field" };
66
+ }
67
+
68
+ if (payload.type === "cron_reminder") {
69
+ if (!payload.content || !payload.targetType || !payload.targetAddress) {
70
+ return {
71
+ isPayload: true,
72
+ error:
73
+ "cron_reminder payload is missing required fields (content, targetType, targetAddress)",
74
+ };
75
+ }
76
+ } else if (payload.type === "media") {
77
+ if (!payload.mediaType || !payload.source || !payload.path) {
78
+ return {
79
+ isPayload: true,
80
+ error: "media payload is missing required fields (mediaType, source, path)",
81
+ };
82
+ }
83
+ }
84
+
85
+ return { isPayload: true, payload };
86
+ } catch (e) {
87
+ return { isPayload: true, error: `Failed to parse JSON: ${formatErr(e)}` };
88
+ }
89
+ }
90
+
91
+ /** Encode a cron reminder payload into the stored cron-message format. */
92
+ export function encodePayloadForCron(payload: CronReminderPayload): string {
93
+ const jsonString = JSON.stringify(payload);
94
+ const base64 = Buffer.from(jsonString, "utf-8").toString("base64");
95
+ return `${CRON_PREFIX}${base64}`;
96
+ }
97
+
98
+ /** Decode a stored cron payload. */
99
+ export function decodeCronPayload(message: string): {
100
+ isCronPayload: boolean;
101
+ payload?: CronReminderPayload;
102
+ error?: string;
103
+ } {
104
+ const trimmedMessage = message.trim();
105
+
106
+ if (!trimmedMessage.startsWith(CRON_PREFIX)) {
107
+ return { isCronPayload: false };
108
+ }
109
+
110
+ const base64Content = trimmedMessage.slice(CRON_PREFIX.length);
111
+
112
+ if (!base64Content) {
113
+ return { isCronPayload: true, error: "Cron payload body is empty" };
114
+ }
115
+
116
+ try {
117
+ const jsonString = Buffer.from(base64Content, "base64").toString("utf-8");
118
+ const payload = JSON.parse(jsonString) as CronReminderPayload;
119
+
120
+ if (payload.type !== "cron_reminder") {
121
+ return {
122
+ isCronPayload: true,
123
+ error: `Expected type cron_reminder but got ${String(payload.type)}`,
124
+ };
125
+ }
126
+
127
+ if (!payload.content || !payload.targetType || !payload.targetAddress) {
128
+ return { isCronPayload: true, error: "Cron payload is missing required fields" };
129
+ }
130
+
131
+ return { isCronPayload: true, payload };
132
+ } catch (e) {
133
+ return { isCronPayload: true, error: `Failed to decode cron payload: ${formatErr(e)}` };
134
+ }
135
+ }
136
+
137
+ /** Type guard for cron reminder payloads. */
138
+ export function isCronReminderPayload(payload: QQBotPayload): payload is CronReminderPayload {
139
+ return payload.type === "cron_reminder";
140
+ }
141
+
142
+ /** Type guard for media payloads. */
143
+ export function isMediaPayload(payload: QQBotPayload): payload is MediaPayload {
144
+ return payload.type === "media";
145
+ }
@@ -0,0 +1,65 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+
6
+ const createdHomes: string[] = [];
7
+
8
+ async function useMockHome(homeDir: string): Promise<void> {
9
+ vi.resetModules();
10
+ vi.doMock("node:os", async (importOriginal) => {
11
+ const actual = await importOriginal<typeof import("node:os")>();
12
+ return {
13
+ ...actual,
14
+ default: { ...actual, homedir: () => homeDir },
15
+ homedir: () => homeDir,
16
+ };
17
+ });
18
+ }
19
+
20
+ function makeHome(): string {
21
+ const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "qqbot-home-"));
22
+ createdHomes.push(homeDir);
23
+ return homeDir;
24
+ }
25
+
26
+ describe("qqbot storage laziness", () => {
27
+ afterEach(() => {
28
+ vi.doUnmock("node:os");
29
+ vi.resetModules();
30
+ for (const home of createdHomes.splice(0)) {
31
+ fs.rmSync(home, { recursive: true, force: true });
32
+ }
33
+ });
34
+
35
+ it("does not create ~/.openclaw/qqbot from module imports or read-only probes", async () => {
36
+ const homeDir = makeHome();
37
+ await useMockHome(homeDir);
38
+
39
+ const qqbotRoot = path.join(homeDir, ".openclaw", "qqbot");
40
+
41
+ const sessionStore = await import("../session/session-store.js");
42
+ await import("../session/known-users.js");
43
+ await import("../ref/store.js");
44
+ const { loadCredentialBackup } = await import("../config/credential-backup.js");
45
+
46
+ expect(loadCredentialBackup("default")).toBeNull();
47
+ expect(sessionStore.getAllSessions()).toEqual([]);
48
+ expect(sessionStore.cleanupExpiredSessions()).toBe(0);
49
+ expect(fs.existsSync(qqbotRoot)).toBe(false);
50
+ });
51
+
52
+ it("creates storage when qqbot persists runtime state", async () => {
53
+ const homeDir = makeHome();
54
+ await useMockHome(homeDir);
55
+
56
+ const qqbotRoot = path.join(homeDir, ".openclaw", "qqbot");
57
+ const { saveCredentialBackup } = await import("../config/credential-backup.js");
58
+
59
+ saveCredentialBackup("default", "123456", "secret");
60
+
61
+ expect(fs.existsSync(path.join(qqbotRoot, "data", "credential-backup-default.json"))).toBe(
62
+ true,
63
+ );
64
+ });
65
+ });
@@ -0,0 +1,148 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import {
6
+ getHomeDir,
7
+ resolveQQBotLocalMediaPath,
8
+ resolveQQBotPayloadLocalFilePath,
9
+ } from "./platform.js";
10
+
11
+ describe("qqbot local media path remapping", () => {
12
+ const createdPaths: string[] = [];
13
+
14
+ function createOpenClawTestRoot() {
15
+ const actualHome = getHomeDir();
16
+ const openclawDir = path.join(actualHome, ".openclaw");
17
+ fs.mkdirSync(openclawDir, { recursive: true });
18
+ const testRoot = fs.mkdtempSync(path.join(openclawDir, "qqbot-platform-test-"));
19
+ createdPaths.push(testRoot);
20
+ return { actualHome, testRootName: path.basename(testRoot) };
21
+ }
22
+
23
+ function createQqbotMediaFile(fileName: string) {
24
+ const { actualHome, testRootName } = createOpenClawTestRoot();
25
+ const mediaFile = path.join(
26
+ actualHome,
27
+ ".openclaw",
28
+ "media",
29
+ "qqbot",
30
+ "downloads",
31
+ testRootName,
32
+ fileName,
33
+ );
34
+ fs.mkdirSync(path.dirname(mediaFile), { recursive: true });
35
+ fs.writeFileSync(mediaFile, "image", "utf8");
36
+ createdPaths.push(path.dirname(mediaFile));
37
+ return { actualHome, testRootName, mediaFile };
38
+ }
39
+
40
+ afterEach(() => {
41
+ vi.restoreAllMocks();
42
+ for (const target of createdPaths.splice(0)) {
43
+ fs.rmSync(target, { recursive: true, force: true });
44
+ }
45
+ });
46
+
47
+ it("remaps missing workspace media paths to the real media directory", () => {
48
+ const { actualHome, testRootName, mediaFile } = createQqbotMediaFile("example.png");
49
+
50
+ const missingWorkspacePath = path.join(
51
+ actualHome,
52
+ ".openclaw",
53
+ "workspace",
54
+ "qqbot",
55
+ "downloads",
56
+ testRootName,
57
+ "example.png",
58
+ );
59
+
60
+ expect(resolveQQBotLocalMediaPath(missingWorkspacePath)).toBe(mediaFile);
61
+ });
62
+
63
+ it("leaves existing media paths unchanged", () => {
64
+ const { mediaFile } = createQqbotMediaFile("existing.png");
65
+
66
+ expect(resolveQQBotLocalMediaPath(mediaFile)).toBe(mediaFile);
67
+ });
68
+
69
+ it("blocks structured payload files outside QQ Bot storage", () => {
70
+ const outsideRoot = fs.mkdtempSync(path.join(os.tmpdir(), "qqbot-platform-outside-"));
71
+ createdPaths.push(outsideRoot);
72
+
73
+ const outsideFile = path.join(outsideRoot, "secret.txt");
74
+ fs.writeFileSync(outsideFile, "secret", "utf8");
75
+
76
+ expect(resolveQQBotPayloadLocalFilePath(outsideFile)).toBeNull();
77
+ });
78
+
79
+ it("blocks structured payload paths that escape QQ Bot media via '..'", () => {
80
+ const escapedPath = path.join(
81
+ getHomeDir(),
82
+ ".openclaw",
83
+ "media",
84
+ "qqbot",
85
+ "..",
86
+ "..",
87
+ "qqbot-escape.txt",
88
+ );
89
+
90
+ expect(resolveQQBotPayloadLocalFilePath(escapedPath)).toBeNull();
91
+ });
92
+
93
+ it("allows structured payload files inside the QQ Bot media directory", () => {
94
+ const { mediaFile } = createQqbotMediaFile("allowed.png");
95
+
96
+ expect(resolveQQBotPayloadLocalFilePath(mediaFile)).toBe(fs.realpathSync(mediaFile));
97
+ });
98
+
99
+ it("allows structured payload files inside sibling OpenClaw media subdirectories", () => {
100
+ // Core helpers such as `saveMediaBuffer(..., "outbound", ...)` place framework
101
+ // attachments under sibling directories of `media/qqbot/`. The plugin must
102
+ // trust the shared `~/.openclaw/media` root so auto-routed sends can access
103
+ // those files without the path-outside-storage guard firing.
104
+ const actualHome = getHomeDir();
105
+ const outboundDir = path.join(actualHome, ".openclaw", "media", "outbound");
106
+ fs.mkdirSync(outboundDir, { recursive: true });
107
+ const outboundFile = fs.mkdtempSync(path.join(outboundDir, "qqbot-outbound-"));
108
+ const mediaFile = path.join(outboundFile, "tts.mp3");
109
+ fs.writeFileSync(mediaFile, "audio", "utf8");
110
+ createdPaths.push(outboundFile);
111
+
112
+ expect(resolveQQBotPayloadLocalFilePath(mediaFile)).toBe(fs.realpathSync(mediaFile));
113
+ });
114
+
115
+ it("blocks structured payload files inside the QQ Bot data directory", () => {
116
+ const { actualHome, testRootName } = createOpenClawTestRoot();
117
+
118
+ const dataFile = path.join(
119
+ actualHome,
120
+ ".openclaw",
121
+ "qqbot",
122
+ "sessions",
123
+ testRootName,
124
+ "session.json",
125
+ );
126
+ fs.mkdirSync(path.dirname(dataFile), { recursive: true });
127
+ fs.writeFileSync(dataFile, "{}", "utf8");
128
+ createdPaths.push(path.dirname(dataFile));
129
+
130
+ expect(resolveQQBotPayloadLocalFilePath(dataFile)).toBeNull();
131
+ });
132
+
133
+ it("allows legacy workspace paths when they remap into QQ Bot media storage", () => {
134
+ const { actualHome, testRootName, mediaFile } = createQqbotMediaFile("legacy.png");
135
+
136
+ const missingWorkspacePath = path.join(
137
+ actualHome,
138
+ ".openclaw",
139
+ "workspace",
140
+ "qqbot",
141
+ "downloads",
142
+ testRootName,
143
+ "legacy.png",
144
+ );
145
+
146
+ expect(resolveQQBotPayloadLocalFilePath(missingWorkspacePath)).toBe(fs.realpathSync(mediaFile));
147
+ });
148
+ });
@@ -0,0 +1,343 @@
1
+ /**
2
+ * Cross-platform path and detection helpers for core/ modules.
3
+ *
4
+ * Provides home/data/media directory helpers, platform detection,
5
+ * ffmpeg/silk-wasm availability checks — all without importing
6
+ * `openclaw/plugin-sdk`. The temp-directory fallback is delegated
7
+ * to the PlatformAdapter.
8
+ */
9
+
10
+ import { execFile } from "node:child_process";
11
+ import * as fs from "node:fs";
12
+ import * as os from "node:os";
13
+ import * as path from "node:path";
14
+ import { getPlatformAdapter } from "../adapter/index.js";
15
+ import { formatErrorMessage } from "./format.js";
16
+ import { debugLog, debugWarn } from "./log.js";
17
+
18
+ /**
19
+ * Resolve the current user's home directory safely across platforms.
20
+ *
21
+ * Priority:
22
+ * 1. `os.homedir()`
23
+ * 2. `$HOME` or `%USERPROFILE%`
24
+ * 3. PlatformAdapter.getTempDir() as a last resort
25
+ */
26
+ export function getHomeDir(): string {
27
+ try {
28
+ const home = os.homedir();
29
+ if (home && fs.existsSync(home)) {
30
+ return home;
31
+ }
32
+ } catch {
33
+ /* fallback */
34
+ }
35
+
36
+ const envHome = process.env.HOME || process.env.USERPROFILE;
37
+ if (envHome && fs.existsSync(envHome)) {
38
+ return envHome;
39
+ }
40
+
41
+ return getPlatformAdapter().getTempDir();
42
+ }
43
+
44
+ /** Return a path under `~/.openclaw/qqbot` without creating it. */
45
+ export function getQQBotDataPath(...subPaths: string[]): string {
46
+ return path.join(getHomeDir(), ".openclaw", "qqbot", ...subPaths);
47
+ }
48
+
49
+ /** Return a path under `~/.openclaw/qqbot`, creating it on demand. */
50
+ export function getQQBotDataDir(...subPaths: string[]): string {
51
+ const dir = getQQBotDataPath(...subPaths);
52
+ if (!fs.existsSync(dir)) {
53
+ fs.mkdirSync(dir, { recursive: true });
54
+ }
55
+ return dir;
56
+ }
57
+
58
+ /**
59
+ * Return a path under `~/.openclaw/media/qqbot` without creating it.
60
+ *
61
+ * Unlike `getQQBotDataPath`, this lives under OpenClaw's core media allowlist so
62
+ * downloaded images and audio can be accessed by framework media tooling.
63
+ */
64
+ export function getQQBotMediaPath(...subPaths: string[]): string {
65
+ return path.join(getHomeDir(), ".openclaw", "media", "qqbot", ...subPaths);
66
+ }
67
+
68
+ /** Return a path under `~/.openclaw/media/qqbot`, creating it on demand. */
69
+ export function getQQBotMediaDir(...subPaths: string[]): string {
70
+ const dir = getQQBotMediaPath(...subPaths);
71
+ if (!fs.existsSync(dir)) {
72
+ fs.mkdirSync(dir, { recursive: true });
73
+ }
74
+ return dir;
75
+ }
76
+
77
+ /**
78
+ * Return `~/.openclaw/media`, OpenClaw's shared media root.
79
+ *
80
+ * This mirrors the directory that core's `buildMediaLocalRoots` exposes as an
81
+ * allowlisted location (see `openclaw/src/media/local-roots.ts`). Using it as a
82
+ * QQ Bot payload root lets the plugin trust framework-produced files that live
83
+ * in sibling subdirectories such as `outbound/` (written by
84
+ * `saveMediaBuffer(..., "outbound", ...)`) or `inbound/`, while still keeping
85
+ * the check anchored to a single, well-known directory.
86
+ */
87
+ function getOpenClawMediaDir(): string {
88
+ return path.join(getHomeDir(), ".openclaw", "media");
89
+ }
90
+
91
+ // ---- Basic platform information ----
92
+
93
+ type PlatformType = "darwin" | "linux" | "win32" | "other";
94
+
95
+ export function getPlatform(): PlatformType {
96
+ const p = process.platform;
97
+ if (p === "darwin" || p === "linux" || p === "win32") {
98
+ return p;
99
+ }
100
+ return "other";
101
+ }
102
+
103
+ export function isWindows(): boolean {
104
+ return process.platform === "win32";
105
+ }
106
+
107
+ /** Return the preferred temporary directory. */
108
+ export function getTempDir(): string {
109
+ return getPlatformAdapter().getTempDir();
110
+ }
111
+
112
+ // ---- ffmpeg detection ----
113
+
114
+ let _ffmpegPath: string | null | undefined;
115
+ let _ffmpegCheckPromise: Promise<string | null> | null = null;
116
+
117
+ /** Detect ffmpeg and return an executable path when available. */
118
+ export function detectFfmpeg(): Promise<string | null> {
119
+ if (_ffmpegPath !== undefined) {
120
+ return Promise.resolve(_ffmpegPath);
121
+ }
122
+ if (_ffmpegCheckPromise) {
123
+ return _ffmpegCheckPromise;
124
+ }
125
+
126
+ _ffmpegCheckPromise = (async () => {
127
+ const envPath = process.env.FFMPEG_PATH;
128
+ if (envPath) {
129
+ const ok = await testExecutable(envPath, ["-version"]);
130
+ if (ok) {
131
+ _ffmpegPath = envPath;
132
+ debugLog(`[platform] ffmpeg found via FFMPEG_PATH: ${envPath}`);
133
+ return _ffmpegPath;
134
+ }
135
+ debugWarn(`[platform] FFMPEG_PATH set but not working: ${envPath}`);
136
+ }
137
+
138
+ const cmd = isWindows() ? "ffmpeg.exe" : "ffmpeg";
139
+ const ok = await testExecutable(cmd, ["-version"]);
140
+ if (ok) {
141
+ _ffmpegPath = cmd;
142
+ debugLog(`[platform] ffmpeg detected in PATH`);
143
+ return _ffmpegPath;
144
+ }
145
+
146
+ const commonPaths = isWindows()
147
+ ? [
148
+ "C:\\ffmpeg\\bin\\ffmpeg.exe",
149
+ path.join(process.env.LOCALAPPDATA || "", "Programs", "ffmpeg", "bin", "ffmpeg.exe"),
150
+ path.join(process.env.ProgramFiles || "", "ffmpeg", "bin", "ffmpeg.exe"),
151
+ ]
152
+ : [
153
+ "/usr/local/bin/ffmpeg",
154
+ "/opt/homebrew/bin/ffmpeg",
155
+ "/usr/bin/ffmpeg",
156
+ "/snap/bin/ffmpeg",
157
+ ];
158
+
159
+ for (const p of commonPaths) {
160
+ if (p && fs.existsSync(p)) {
161
+ const works = await testExecutable(p, ["-version"]);
162
+ if (works) {
163
+ _ffmpegPath = p;
164
+ debugLog(`[platform] ffmpeg found at: ${p}`);
165
+ return _ffmpegPath;
166
+ }
167
+ }
168
+ }
169
+
170
+ _ffmpegPath = null;
171
+ return null;
172
+ })().finally(() => {
173
+ _ffmpegCheckPromise = null;
174
+ });
175
+
176
+ return _ffmpegCheckPromise;
177
+ }
178
+
179
+ /** Return true when an executable responds successfully to the given args. */
180
+ function testExecutable(cmd: string, args: string[]): Promise<boolean> {
181
+ return new Promise((resolve) => {
182
+ execFile(cmd, args, { timeout: 5000 }, (err) => {
183
+ resolve(!err);
184
+ });
185
+ });
186
+ }
187
+
188
+ // ---- silk-wasm detection ----
189
+
190
+ let _silkWasmAvailable: boolean | null = null;
191
+
192
+ /** Check whether silk-wasm can run in the current environment. */
193
+ export async function checkSilkWasmAvailable(): Promise<boolean> {
194
+ if (_silkWasmAvailable !== null) {
195
+ return _silkWasmAvailable;
196
+ }
197
+ try {
198
+ const { isSilk } = await import("silk-wasm");
199
+ isSilk(new Uint8Array(0));
200
+ _silkWasmAvailable = true;
201
+ debugLog("[platform] silk-wasm: available");
202
+ } catch (err) {
203
+ _silkWasmAvailable = false;
204
+ debugWarn(`[platform] silk-wasm: NOT available (${formatErrorMessage(err)})`);
205
+ }
206
+ return _silkWasmAvailable;
207
+ }
208
+
209
+ // ---- Tilde expansion and path normalization ----
210
+
211
+ /** Expand `~` to the current user's home directory. */
212
+ function expandTilde(p: string): string {
213
+ if (!p) {
214
+ return p;
215
+ }
216
+ if (p === "~") {
217
+ return getHomeDir();
218
+ }
219
+ if (p.startsWith("~/") || p.startsWith("~\\")) {
220
+ return path.join(getHomeDir(), p.slice(2));
221
+ }
222
+ return p;
223
+ }
224
+
225
+ /** Normalize a user-provided path by trimming, stripping `file://`, and expanding `~`. */
226
+ export function normalizePath(p: string): string {
227
+ let result = p.trim();
228
+ if (result.startsWith("file://")) {
229
+ result = result.slice("file://".length);
230
+ try {
231
+ result = decodeURIComponent(result);
232
+ } catch {
233
+ // Keep the raw string if decoding fails.
234
+ }
235
+ }
236
+ return expandTilde(result);
237
+ }
238
+
239
+ // ---- Local path detection ----
240
+
241
+ /** Return true when the string looks like a local filesystem path rather than a URL. */
242
+ export function isLocalPath(p: string): boolean {
243
+ if (!p) {
244
+ return false;
245
+ }
246
+ if (p.startsWith("file://")) {
247
+ return true;
248
+ }
249
+ if (p === "~" || p.startsWith("~/") || p.startsWith("~\\")) {
250
+ return true;
251
+ }
252
+ if (p.startsWith("/")) {
253
+ return true;
254
+ }
255
+ if (/^[a-zA-Z]:[\\/]/.test(p)) {
256
+ return true;
257
+ }
258
+ if (p.startsWith("\\\\")) {
259
+ return true;
260
+ }
261
+ if (p.startsWith("./") || p.startsWith("../")) {
262
+ return true;
263
+ }
264
+ if (p.startsWith(".\\") || p.startsWith("..\\")) {
265
+ return true;
266
+ }
267
+ return false;
268
+ }
269
+
270
+ // ---- QQBot media path resolution ----
271
+
272
+ function isPathWithinRoot(candidate: string, root: string): boolean {
273
+ const relative = path.relative(root, candidate);
274
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
275
+ }
276
+
277
+ /** Remap legacy or hallucinated QQ Bot local media paths to real files when possible. */
278
+ export function resolveQQBotLocalMediaPath(p: string): string {
279
+ const normalized = normalizePath(p);
280
+ if (!isLocalPath(normalized) || fs.existsSync(normalized)) {
281
+ return normalized;
282
+ }
283
+
284
+ const homeDir = getHomeDir();
285
+ const mediaRoot = getQQBotMediaPath();
286
+ const dataRoot = getQQBotDataPath();
287
+ const workspaceRoot = path.join(homeDir, ".openclaw", "workspace", "qqbot");
288
+ const candidateRoots = [
289
+ { from: workspaceRoot, to: mediaRoot },
290
+ { from: dataRoot, to: mediaRoot },
291
+ { from: mediaRoot, to: dataRoot },
292
+ ];
293
+
294
+ for (const { from, to } of candidateRoots) {
295
+ if (!isPathWithinRoot(normalized, from)) {
296
+ continue;
297
+ }
298
+ const relative = path.relative(from, normalized);
299
+ const candidate = path.join(to, relative);
300
+ if (fs.existsSync(candidate)) {
301
+ debugWarn(`[platform] Remapped missing QQBot media path ${normalized} -> ${candidate}`);
302
+ return candidate;
303
+ }
304
+ }
305
+
306
+ return normalized;
307
+ }
308
+
309
+ /**
310
+ * Resolve a structured-payload local file path and enforce that it stays within
311
+ * QQ Bot-owned storage roots.
312
+ */
313
+ export function resolveQQBotPayloadLocalFilePath(p: string): string | null {
314
+ const candidate = resolveQQBotLocalMediaPath(p);
315
+ if (!candidate.trim()) {
316
+ return null;
317
+ }
318
+
319
+ const resolvedCandidate = path.resolve(candidate);
320
+ if (!fs.existsSync(resolvedCandidate)) {
321
+ return null;
322
+ }
323
+
324
+ const canonicalCandidate = fs.realpathSync(resolvedCandidate);
325
+ // Trust both the QQ Bot-owned subdirectory and OpenClaw's shared `~/.openclaw/media`
326
+ // root. Core helpers like `saveMediaBuffer(..., "outbound", ...)` place framework
327
+ // attachments under sibling directories (e.g. `media/outbound/`) that are already
328
+ // part of the core media allowlist; we mirror that so auto-routed sends work
329
+ // without leaving the plugin's trust boundary.
330
+ const allowedRoots = [getOpenClawMediaDir(), getQQBotMediaPath()];
331
+
332
+ for (const root of allowedRoots) {
333
+ const resolvedRoot = path.resolve(root);
334
+ const canonicalRoot = fs.existsSync(resolvedRoot)
335
+ ? fs.realpathSync(resolvedRoot)
336
+ : resolvedRoot;
337
+ if (isPathWithinRoot(canonicalCandidate, canonicalRoot)) {
338
+ return canonicalCandidate;
339
+ }
340
+ }
341
+
342
+ return null;
343
+ }