@tencent-connect/openclaw-qqbot 1.7.1 → 2.0.0

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 (283) hide show
  1. package/README.md +206 -4
  2. package/README.zh.md +207 -4
  3. package/bin/qqbot-cli.js +5 -2
  4. package/dist/index.cjs +15549 -0
  5. package/dist/index.cjs.map +1 -0
  6. package/dist/index.d.cts +890 -0
  7. package/index.ts +56 -18
  8. package/openclaw.plugin.json +19 -3
  9. package/package.json +14 -23
  10. package/preload.cjs +17 -14
  11. package/scripts/link-sdk-core.cjs +133 -50
  12. package/scripts/upgrade-via-npm.sh +400 -73
  13. package/scripts/upgrade-via-source.sh +126 -89
  14. package/skills/qqbot-channel/SKILL.md +34 -12
  15. package/src/adapter/contract.ts +69 -0
  16. package/src/adapter/gateway.ts +61 -0
  17. package/src/adapter/index.ts +15 -0
  18. package/src/adapter/lint.ts +144 -0
  19. package/src/adapter/media.ts +112 -0
  20. package/src/adapter/pairing.ts +99 -0
  21. package/src/adapter/resolve.ts +333 -0
  22. package/src/adapter/setup.ts +55 -0
  23. package/src/adapter/webhook.ts +248 -0
  24. package/src/adapter/workspace.ts +29 -0
  25. package/src/bot-instance.ts +60 -0
  26. package/src/channel.ts +202 -413
  27. package/src/commands/bot-approve.ts +143 -0
  28. package/src/commands/bot-clear-storage.ts +114 -0
  29. package/src/commands/bot-group-always.ts +62 -0
  30. package/src/commands/bot-help.ts +40 -0
  31. package/src/commands/bot-logs.ts +248 -0
  32. package/src/commands/bot-me.ts +18 -0
  33. package/src/commands/bot-pairing.ts +57 -0
  34. package/src/commands/bot-ping.ts +33 -0
  35. package/src/commands/bot-streaming.ts +55 -0
  36. package/src/commands/bot-upgrade.ts +56 -0
  37. package/src/commands/bot-version.ts +41 -0
  38. package/src/commands/config-util.ts +96 -0
  39. package/src/commands/index.ts +49 -0
  40. package/src/config.ts +59 -19
  41. package/src/dispatch/body-assembler.ts +356 -0
  42. package/src/dispatch/ctx-builder.ts +110 -0
  43. package/src/dispatch/dispatch.ts +352 -0
  44. package/src/dispatch/envelope-builder.ts +112 -0
  45. package/src/dispatch/index.ts +2 -0
  46. package/src/{approval-handler.ts → features/approval-handler.ts} +40 -94
  47. package/src/features/approval-utils.ts +41 -0
  48. package/src/features/credential-backup.ts +82 -0
  49. package/src/features/history-store.ts +19 -0
  50. package/src/features/msgid-cache.ts +55 -0
  51. package/src/features/onboarding.ts +38 -0
  52. package/src/{proactive.ts → features/proactive.ts} +68 -49
  53. package/src/features/ref-index-store.ts +282 -0
  54. package/src/{update-checker.ts → features/update-checker.ts} +15 -35
  55. package/src/gateway/event-handlers.ts +241 -0
  56. package/src/gateway/index.ts +3 -0
  57. package/src/gateway/lifecycle.ts +221 -0
  58. package/src/gateway/middleware-setup.ts +139 -0
  59. package/src/gateway/qqbot-gateway.ts +342 -0
  60. package/src/middleware/access-control.ts +130 -0
  61. package/src/middleware/attachment.ts +314 -0
  62. package/src/middleware/policy-injector.ts +66 -0
  63. package/src/openclaw-plugin-sdk.d.ts +81 -10
  64. package/src/outbound/cron-scheduler.ts +130 -0
  65. package/src/outbound/debounce.ts +102 -0
  66. package/src/outbound/deliver-pipeline.ts +235 -0
  67. package/src/outbound/image-size.ts +123 -0
  68. package/src/outbound/index.ts +3 -0
  69. package/src/outbound/local-file-router.ts +145 -0
  70. package/src/outbound/media-send.ts +377 -0
  71. package/src/outbound/outbound-service.ts +222 -0
  72. package/src/outbound/reply-limiter.ts +128 -0
  73. package/src/outbound/sanitize.ts +32 -0
  74. package/src/outbound/streaming-controller.ts +228 -0
  75. package/src/outbound/target.ts +63 -0
  76. package/src/outbound/tts-provider.ts +84 -0
  77. package/src/request-context.ts +9 -8
  78. package/src/runtime.ts +31 -6
  79. package/src/setup/account-key.ts +41 -0
  80. package/src/setup/finalize.ts +110 -0
  81. package/src/setup/login.ts +197 -0
  82. package/src/setup/surface.ts +40 -0
  83. package/src/tools/platform.ts +149 -0
  84. package/src/tools/remind.ts +1 -1
  85. package/src/types-augment.d.ts +54 -0
  86. package/src/types.ts +71 -8
  87. package/src/typings/openclaw-webhook-ingress.d.ts +66 -0
  88. package/src/utils/mention.ts +52 -0
  89. package/src/utils/pkg-version.ts +84 -47
  90. package/src/utils/platform.ts +31 -7
  91. package/src/utils/plugin-logger.ts +104 -0
  92. package/src/utils/ssrf-guard.ts +32 -2
  93. package/src/utils/stt.ts +137 -0
  94. package/src/utils/voice-text.ts +61 -0
  95. package/tsconfig.json +2 -1
  96. package/tsup.config.ts +57 -0
  97. package/dist/index.d.ts +0 -17
  98. package/dist/index.js +0 -26
  99. package/dist/src/admin-resolver.d.ts +0 -33
  100. package/dist/src/admin-resolver.js +0 -157
  101. package/dist/src/api.d.ts +0 -307
  102. package/dist/src/api.js +0 -910
  103. package/dist/src/approval-handler.d.ts +0 -47
  104. package/dist/src/approval-handler.js +0 -372
  105. package/dist/src/channel.d.ts +0 -29
  106. package/dist/src/channel.js +0 -528
  107. package/dist/src/config.d.ts +0 -56
  108. package/dist/src/config.js +0 -278
  109. package/dist/src/credential-backup.d.ts +0 -31
  110. package/dist/src/credential-backup.js +0 -66
  111. package/dist/src/deliver-debounce.d.ts +0 -74
  112. package/dist/src/deliver-debounce.js +0 -174
  113. package/dist/src/gateway.d.ts +0 -18
  114. package/dist/src/gateway.js +0 -2063
  115. package/dist/src/group-history.d.ts +0 -136
  116. package/dist/src/group-history.js +0 -226
  117. package/dist/src/image-server.d.ts +0 -87
  118. package/dist/src/image-server.js +0 -570
  119. package/dist/src/inbound-attachments.d.ts +0 -60
  120. package/dist/src/inbound-attachments.js +0 -248
  121. package/dist/src/known-users.d.ts +0 -100
  122. package/dist/src/known-users.js +0 -263
  123. package/dist/src/message-gating.d.ts +0 -53
  124. package/dist/src/message-gating.js +0 -107
  125. package/dist/src/message-queue.d.ts +0 -91
  126. package/dist/src/message-queue.js +0 -257
  127. package/dist/src/onboarding.d.ts +0 -10
  128. package/dist/src/onboarding.js +0 -203
  129. package/dist/src/outbound-deliver.d.ts +0 -48
  130. package/dist/src/outbound-deliver.js +0 -392
  131. package/dist/src/outbound.d.ts +0 -220
  132. package/dist/src/outbound.js +0 -975
  133. package/dist/src/proactive.d.ts +0 -170
  134. package/dist/src/proactive.js +0 -399
  135. package/dist/src/ref-index-store.d.ts +0 -101
  136. package/dist/src/ref-index-store.js +0 -298
  137. package/dist/src/reply-dispatcher.d.ts +0 -35
  138. package/dist/src/reply-dispatcher.js +0 -311
  139. package/dist/src/request-context.d.ts +0 -25
  140. package/dist/src/request-context.js +0 -37
  141. package/dist/src/runtime.d.ts +0 -3
  142. package/dist/src/runtime.js +0 -13
  143. package/dist/src/session-store.d.ts +0 -52
  144. package/dist/src/session-store.js +0 -254
  145. package/dist/src/slash-commands.d.ts +0 -82
  146. package/dist/src/slash-commands.js +0 -2117
  147. package/dist/src/startup-greeting.d.ts +0 -30
  148. package/dist/src/startup-greeting.js +0 -97
  149. package/dist/src/streaming.d.ts +0 -247
  150. package/dist/src/streaming.js +0 -899
  151. package/dist/src/stt.d.ts +0 -21
  152. package/dist/src/stt.js +0 -70
  153. package/dist/src/tools/channel.d.ts +0 -16
  154. package/dist/src/tools/channel.js +0 -234
  155. package/dist/src/tools/remind.d.ts +0 -2
  156. package/dist/src/tools/remind.js +0 -256
  157. package/dist/src/types.d.ts +0 -443
  158. package/dist/src/types.js +0 -22
  159. package/dist/src/typing-keepalive.d.ts +0 -27
  160. package/dist/src/typing-keepalive.js +0 -64
  161. package/dist/src/update-checker.d.ts +0 -36
  162. package/dist/src/update-checker.js +0 -171
  163. package/dist/src/utils/audio-convert.d.ts +0 -98
  164. package/dist/src/utils/audio-convert.js +0 -755
  165. package/dist/src/utils/chunked-upload.d.ts +0 -68
  166. package/dist/src/utils/chunked-upload.js +0 -341
  167. package/dist/src/utils/file-utils.d.ts +0 -61
  168. package/dist/src/utils/file-utils.js +0 -172
  169. package/dist/src/utils/image-size.d.ts +0 -51
  170. package/dist/src/utils/image-size.js +0 -234
  171. package/dist/src/utils/media-send.d.ts +0 -158
  172. package/dist/src/utils/media-send.js +0 -502
  173. package/dist/src/utils/media-tags.d.ts +0 -14
  174. package/dist/src/utils/media-tags.js +0 -165
  175. package/dist/src/utils/payload.d.ts +0 -112
  176. package/dist/src/utils/payload.js +0 -186
  177. package/dist/src/utils/pkg-version.d.ts +0 -5
  178. package/dist/src/utils/pkg-version.js +0 -61
  179. package/dist/src/utils/platform.d.ts +0 -137
  180. package/dist/src/utils/platform.js +0 -390
  181. package/dist/src/utils/ssrf-guard.d.ts +0 -25
  182. package/dist/src/utils/ssrf-guard.js +0 -91
  183. package/dist/src/utils/text-parsing.d.ts +0 -31
  184. package/dist/src/utils/text-parsing.js +0 -74
  185. package/dist/src/utils/upload-cache.d.ts +0 -34
  186. package/dist/src/utils/upload-cache.js +0 -93
  187. package/node_modules/@eshaz/web-worker/LICENSE +0 -201
  188. package/node_modules/@eshaz/web-worker/README.md +0 -134
  189. package/node_modules/@eshaz/web-worker/browser.js +0 -17
  190. package/node_modules/@eshaz/web-worker/cjs/browser.js +0 -16
  191. package/node_modules/@eshaz/web-worker/cjs/node.js +0 -219
  192. package/node_modules/@eshaz/web-worker/index.d.ts +0 -4
  193. package/node_modules/@eshaz/web-worker/node.js +0 -223
  194. package/node_modules/@eshaz/web-worker/package.json +0 -54
  195. package/node_modules/@wasm-audio-decoders/common/index.js +0 -5
  196. package/node_modules/@wasm-audio-decoders/common/package.json +0 -36
  197. package/node_modules/@wasm-audio-decoders/common/src/WASMAudioDecoderCommon.js +0 -231
  198. package/node_modules/@wasm-audio-decoders/common/src/WASMAudioDecoderWorker.js +0 -129
  199. package/node_modules/@wasm-audio-decoders/common/src/puff/README +0 -67
  200. package/node_modules/@wasm-audio-decoders/common/src/puff/build_puff.js +0 -31
  201. package/node_modules/@wasm-audio-decoders/common/src/puff/puff.c +0 -863
  202. package/node_modules/@wasm-audio-decoders/common/src/puff/puff.h +0 -35
  203. package/node_modules/@wasm-audio-decoders/common/src/utilities.js +0 -3
  204. package/node_modules/@wasm-audio-decoders/common/types.d.ts +0 -7
  205. package/node_modules/mpg123-decoder/README.md +0 -265
  206. package/node_modules/mpg123-decoder/dist/mpg123-decoder.min.js +0 -185
  207. package/node_modules/mpg123-decoder/dist/mpg123-decoder.min.js.map +0 -1
  208. package/node_modules/mpg123-decoder/index.js +0 -8
  209. package/node_modules/mpg123-decoder/package.json +0 -58
  210. package/node_modules/mpg123-decoder/src/EmscriptenWasm.js +0 -464
  211. package/node_modules/mpg123-decoder/src/MPEGDecoder.js +0 -200
  212. package/node_modules/mpg123-decoder/src/MPEGDecoderWebWorker.js +0 -21
  213. package/node_modules/mpg123-decoder/types.d.ts +0 -30
  214. package/node_modules/silk-wasm/LICENSE +0 -21
  215. package/node_modules/silk-wasm/README.md +0 -85
  216. package/node_modules/silk-wasm/lib/index.cjs +0 -16
  217. package/node_modules/silk-wasm/lib/index.d.ts +0 -70
  218. package/node_modules/silk-wasm/lib/index.mjs +0 -16
  219. package/node_modules/silk-wasm/lib/silk.wasm +0 -0
  220. package/node_modules/silk-wasm/lib/utils.d.ts +0 -4
  221. package/node_modules/silk-wasm/package.json +0 -39
  222. package/node_modules/simple-yenc/.github/FUNDING.yml +0 -1
  223. package/node_modules/simple-yenc/.prettierignore +0 -1
  224. package/node_modules/simple-yenc/LICENSE +0 -7
  225. package/node_modules/simple-yenc/README.md +0 -163
  226. package/node_modules/simple-yenc/dist/esm.js +0 -1
  227. package/node_modules/simple-yenc/dist/index.js +0 -1
  228. package/node_modules/simple-yenc/package.json +0 -50
  229. package/node_modules/simple-yenc/rollup.config.js +0 -27
  230. package/node_modules/simple-yenc/src/simple-yenc.js +0 -302
  231. package/node_modules/ws/LICENSE +0 -20
  232. package/node_modules/ws/README.md +0 -548
  233. package/node_modules/ws/browser.js +0 -8
  234. package/node_modules/ws/index.js +0 -13
  235. package/node_modules/ws/lib/buffer-util.js +0 -131
  236. package/node_modules/ws/lib/constants.js +0 -19
  237. package/node_modules/ws/lib/event-target.js +0 -292
  238. package/node_modules/ws/lib/extension.js +0 -203
  239. package/node_modules/ws/lib/limiter.js +0 -55
  240. package/node_modules/ws/lib/permessage-deflate.js +0 -528
  241. package/node_modules/ws/lib/receiver.js +0 -706
  242. package/node_modules/ws/lib/sender.js +0 -602
  243. package/node_modules/ws/lib/stream.js +0 -161
  244. package/node_modules/ws/lib/subprotocol.js +0 -62
  245. package/node_modules/ws/lib/validation.js +0 -152
  246. package/node_modules/ws/lib/websocket-server.js +0 -554
  247. package/node_modules/ws/lib/websocket.js +0 -1393
  248. package/node_modules/ws/package.json +0 -69
  249. package/node_modules/ws/wrapper.mjs +0 -8
  250. package/scripts/postinstall-link-sdk.js +0 -126
  251. package/skills/qqbot-media/SKILL.md +0 -60
  252. package/src/admin-resolver.ts +0 -181
  253. package/src/api.ts +0 -1319
  254. package/src/credential-backup.ts +0 -72
  255. package/src/deliver-debounce.ts +0 -229
  256. package/src/gateway.ts +0 -2304
  257. package/src/group-history.ts +0 -328
  258. package/src/image-server.ts +0 -675
  259. package/src/inbound-attachments.ts +0 -321
  260. package/src/known-users.ts +0 -353
  261. package/src/message-gating.ts +0 -190
  262. package/src/message-queue.ts +0 -354
  263. package/src/onboarding.ts +0 -274
  264. package/src/outbound-deliver.ts +0 -486
  265. package/src/outbound.ts +0 -1179
  266. package/src/ref-index-store.ts +0 -412
  267. package/src/reply-dispatcher.ts +0 -334
  268. package/src/session-store.ts +0 -303
  269. package/src/slash-commands.ts +0 -2297
  270. package/src/startup-greeting.ts +0 -120
  271. package/src/streaming.ts +0 -1077
  272. package/src/stt.ts +0 -86
  273. package/src/tools/channel.ts +0 -281
  274. package/src/typing-keepalive.ts +0 -59
  275. package/src/utils/audio-convert.ts +0 -859
  276. package/src/utils/chunked-upload.ts +0 -483
  277. package/src/utils/file-utils.ts +0 -193
  278. package/src/utils/image-size.ts +0 -266
  279. package/src/utils/media-send.ts +0 -636
  280. package/src/utils/media-tags.ts +0 -183
  281. package/src/utils/payload.ts +0 -265
  282. package/src/utils/text-parsing.ts +0 -85
  283. package/src/utils/upload-cache.ts +0 -128
@@ -0,0 +1,145 @@
1
+ /**
2
+ * 本地文件路由 — 检测路径并按类型自动选择发送方式
3
+ *
4
+ * 本地文件路径检测 + 文件类型推断。
5
+ */
6
+ import * as path from 'node:path';
7
+ import * as fs from 'node:fs';
8
+ import * as os from 'node:os';
9
+ import type { MediaKind } from './outbound-service.js';
10
+
11
+ /**
12
+ * 规范化本地路径
13
+ * - 剥离 file:// 前缀
14
+ * - 展开 ~ 为 home 目录
15
+ * - URL decode
16
+ */
17
+ export function normalizePath(p: string): string {
18
+ let result = p;
19
+ if (result.startsWith('file://')) {
20
+ result = result.slice('file://'.length);
21
+ // Windows: file:///C:/path → C:/path
22
+ if (/^\/[a-zA-Z]:[\\/]/.test(result)) {
23
+ result = result.slice(1);
24
+ }
25
+ }
26
+ // URL decode
27
+ try {
28
+ result = decodeURIComponent(result);
29
+ } catch {
30
+ // keep raw if decode fails
31
+ }
32
+ // ~ 展开
33
+ if (result === '~' || result.startsWith('~/') || result.startsWith('~\\')) {
34
+ result = result.replace(/^~/, os.homedir());
35
+ }
36
+ return result;
37
+ }
38
+
39
+ /**
40
+ * 判断一个 source 是否为本地文件路径
41
+ */
42
+ export function isLocalFilePath(source: string): boolean {
43
+ if (!source) return false;
44
+ if (source.startsWith('http://') || source.startsWith('https://')) return false;
45
+ if (source.startsWith('data:')) return false;
46
+ if (source.startsWith('file://')) return true;
47
+ if (source === '~' || source.startsWith('~/') || source.startsWith('~\\')) return true;
48
+ if (source.startsWith('/')) return true;
49
+ if (/^[a-zA-Z]:[\\/]/.test(source)) return true;
50
+ if (source.startsWith('\\\\')) return true;
51
+ if (source.startsWith('./') || source.startsWith('../')) return true;
52
+ if (source.startsWith('.\\') || source.startsWith('..\\')) return true;
53
+ return false;
54
+ }
55
+
56
+ /**
57
+ * 判断一个 source 是否为 Base64 data URL
58
+ */
59
+ export function isDataUrl(source: string): boolean {
60
+ return source.startsWith('data:');
61
+ }
62
+
63
+ /**
64
+ * 验证本地文件存在且可读
65
+ */
66
+ export function validateLocalFile(filePath: string): boolean {
67
+ try {
68
+ fs.accessSync(filePath, fs.constants.R_OK);
69
+ return true;
70
+ } catch {
71
+ return false;
72
+ }
73
+ }
74
+
75
+ /**
76
+ * 获取本地文件大小(字节),不存在返回 -1
77
+ */
78
+ export function getFileSize(filePath: string): number {
79
+ try {
80
+ return fs.statSync(filePath).size;
81
+ } catch {
82
+ return -1;
83
+ }
84
+ }
85
+
86
+ // ── 扩展名 → MediaKind 映射 ──
87
+
88
+ const IMAGE_EXTS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.svg', '.ico']);
89
+ const VOICE_EXTS = new Set(['.wav', '.mp3', '.silk', '.amr', '.ogg', '.flac', '.aac', '.m4a']);
90
+ const VIDEO_EXTS = new Set(['.mp4', '.avi', '.mov', '.mkv', '.webm', '.flv', '.wmv']);
91
+
92
+ /**
93
+ * 根据文件扩展名推断 MediaKind
94
+ */
95
+ export function inferMediaKind(filePath: string): MediaKind {
96
+ const ext = path.extname(filePath).toLowerCase();
97
+ if (IMAGE_EXTS.has(ext)) return 'image';
98
+ if (VOICE_EXTS.has(ext)) return 'voice';
99
+ if (VIDEO_EXTS.has(ext)) return 'video';
100
+ return 'file';
101
+ }
102
+
103
+ /**
104
+ * 根据 MIME type 推断 MediaKind
105
+ */
106
+ export function inferMediaKindFromMime(mime: string): MediaKind {
107
+ const lower = mime.toLowerCase();
108
+ if (lower.startsWith('image/')) return 'image';
109
+ if (lower.startsWith('audio/') || lower === 'voice') return 'voice';
110
+ if (lower.startsWith('video/')) return 'video';
111
+ return 'file';
112
+ }
113
+
114
+ /**
115
+ * 从 data URL 提取 MIME type
116
+ */
117
+ export function extractMimeFromDataUrl(dataUrl: string): string | undefined {
118
+ const match = dataUrl.match(/^data:([^;,]+)/);
119
+ return match?.[1];
120
+ }
121
+
122
+ // ── 路径安全校验 ──
123
+
124
+ /**
125
+ * 校验本地文件真实路径是否在允许的目录列表中。
126
+ * 同时解析符号链接和规范化路径,防止路径穿越。
127
+ *
128
+ * @returns true 表示路径在允许的目录内
129
+ */
130
+ export function isPathInAllowedRoots(absPath: string, allowedRoots: string[]): boolean {
131
+ if (!allowedRoots.length) return false;
132
+ try {
133
+ const real = fs.realpathSync(absPath);
134
+ return allowedRoots.some((root) => {
135
+ try {
136
+ const rootReal = fs.existsSync(root) ? fs.realpathSync(root) : root;
137
+ return real.startsWith(rootReal + path.sep) || real === rootReal;
138
+ } catch {
139
+ return false;
140
+ }
141
+ });
142
+ } catch {
143
+ return false;
144
+ }
145
+ }
@@ -0,0 +1,377 @@
1
+ /**
2
+ * 统一富媒体发送入口
3
+ *
4
+ * 职责:
5
+ * 1. 路径安全校验(只允许白名单目录下的本地文件)
6
+ * 2. 类型推断(扩展名 / MIME / 显式指定)
7
+ * 3. 路由分发(image / voice / video / file)
8
+ * 4. 语音发送失败 fallback 到文件
9
+ *
10
+ * 所有出站媒体发送(channel.outbound.sendMedia / deliver pipeline / Message 工具)
11
+ * 统一经过此入口。
12
+ */
13
+ import * as path from 'node:path';
14
+ import * as fs from 'node:fs';
15
+ import * as os from 'node:os';
16
+ import { resolveAgentWorkspace } from '../adapter/workspace.js';
17
+ import type { ReplyTarget } from '@tencent-connect/qqbot-nodejs';
18
+ import type { QQBotGateway } from '../gateway/index.js';
19
+ import { tryGetQQBotRuntime } from '../runtime.js';
20
+ import { getAdapters } from '../adapter/resolve.js';
21
+ import type { PluginLogger } from '../utils/plugin-logger.js';
22
+ import { validateRemoteUrl } from '../utils/ssrf-guard.js';
23
+ import { getGateway } from './outbound-service.js';
24
+ import { parseTarget } from './target.js';
25
+ import {
26
+ isLocalFilePath,
27
+ isDataUrl,
28
+ normalizePath,
29
+ inferMediaKind,
30
+ inferMediaKindFromMime,
31
+ isPathInAllowedRoots,
32
+ } from './local-file-router.js';
33
+ import type { MediaKind } from './outbound-service.js';
34
+
35
+ // ── 类型 ──
36
+
37
+ export interface SendMediaParams {
38
+ /** 目标 (qqbot:c2c:xxx / qqbot:group:xxx) */
39
+ to: string;
40
+ /** 媒体源(URL / 本地路径 / data URL) */
41
+ source: string;
42
+ /** 附带文本 */
43
+ text?: string;
44
+ /** 显式指定类型(优先级最高) */
45
+ mediaKind?: MediaKind;
46
+ /** MIME type 提示(优先级次于 mediaKind) */
47
+ mimeType?: string;
48
+ /** 被动回复 ID */
49
+ replyToId?: string;
50
+ /** 账户 ID */
51
+ accountId: string;
52
+ /** 日志 */
53
+ log?: PluginLogger;
54
+ /** Agent ID(用于解析相对路径的工作区) */
55
+ agentId?: string;
56
+ }
57
+
58
+ export interface SendMediaResult {
59
+ messageId?: string;
60
+ error?: string;
61
+ /** 是否走了 fallback 路径 */
62
+ fallback?: boolean;
63
+ }
64
+
65
+ // ── 安全常量 ──
66
+
67
+ /** 收集临时目录根路径:os.tmpdir + Unix /tmp(处理 macOS /tmp→/private/tmp 符号链接) */
68
+ function resolveTempRoots(): string[] {
69
+ const roots = new Set<string>();
70
+ try {
71
+ const tmp = os.tmpdir();
72
+ roots.add(fs.existsSync(tmp) ? fs.realpathSync(tmp) : tmp);
73
+ } catch { /* skip */ }
74
+ // Unix: 解析 /tmp 真实路径,覆盖 macOS 符号链接场景
75
+ if (process.platform !== 'win32') {
76
+ try { roots.add(fs.realpathSync('/tmp')); } catch { /* skip */ }
77
+ }
78
+ return [...roots];
79
+ }
80
+
81
+ /**
82
+ * 构建动态白名单目录列表。
83
+ *
84
+ * 从 openclaw 核心已解析的 workspaceDir 反向推导配置目录,
85
+ * 同时保留已知配置目录名以向后兼容。
86
+ */
87
+ function buildDynamicAllowedRoots(workspaceDir?: string): string[] {
88
+ const home = os.homedir();
89
+ const derivedBase = workspaceDir ? path.dirname(workspaceDir) : path.join(home, '.openclaw');
90
+ const roots: string[] = [];
91
+ const added = new Set<string>();
92
+
93
+ const addRoot = (p: string) => {
94
+ try {
95
+ const real = fs.existsSync(p) ? fs.realpathSync(p) : p;
96
+ if (!added.has(real)) {
97
+ added.add(real);
98
+ roots.push(real);
99
+ }
100
+ } catch { /* skip */ }
101
+ };
102
+
103
+ // 已知配置目录名(包括默认和开发模式)
104
+ const knownBases = [path.join(home, '.openclaw'), path.join(home, '.openclaw-dev')];
105
+
106
+ // 当前配置目录优先(从 workspaceDir 反推)
107
+ if (workspaceDir) {
108
+ addRoot(path.join(derivedBase, 'media'));
109
+ addRoot(path.join(derivedBase, 'workspace'));
110
+ addRoot(path.join(derivedBase, 'outbound'));
111
+ addRoot(workspaceDir);
112
+ }
113
+
114
+ // 所有已知配置目录(去重,确保向后兼容)
115
+ for (const base of knownBases) {
116
+ addRoot(path.join(base, 'media'));
117
+ addRoot(path.join(base, 'workspace'));
118
+ addRoot(path.join(base, 'outbound'));
119
+ }
120
+
121
+ // 临时目录
122
+ for (const t of resolveTempRoots()) addRoot(t);
123
+
124
+ return roots;
125
+ }
126
+
127
+ /** 入站 Base64 / Data URL 最大字节数(10MB) */
128
+ const MAX_DATA_URL_BYTES = 10 * 1024 * 1024;
129
+
130
+ // ── 统一入口 ──
131
+
132
+ /**
133
+ * 统一富媒体发送入口
134
+ */
135
+ export async function sendMedia(params: SendMediaParams): Promise<SendMediaResult> {
136
+ const { source, accountId, log } = params;
137
+ const mlog = log?.child('media');
138
+
139
+ if (!source) {
140
+ mlog?.error('source is empty');
141
+ return { error: 'sendMedia: source is required' };
142
+ }
143
+
144
+ // 1. 安全校验 + 路径规范化
145
+ const wsDir = resolveWorkspaceFromAgent(params.agentId);
146
+ mlog?.debug(`resolveMediaPath source=${source} agentId=${params.agentId ?? 'none'} workspaceDir=${wsDir ?? 'none'}`);
147
+ const resolved = await resolveMediaPath(source, mlog, wsDir);
148
+ if (!resolved.ok) {
149
+ mlog?.error(`resolveMediaPath failed: ${resolved.error}`);
150
+ return { error: resolved.error };
151
+ }
152
+
153
+ // 2. 推断类型
154
+ const kind = params.mediaKind
155
+ ?? (params.mimeType ? inferMediaKindFromMime(params.mimeType) : undefined)
156
+ ?? inferMediaKind(resolved.path);
157
+
158
+ // 3. 获取 gateway
159
+ const gw = getGateway(accountId);
160
+ if (!gw) {
161
+ return { error: `Bot "${accountId}" not running` };
162
+ }
163
+
164
+ const target = parseTarget(params.to);
165
+
166
+ // 4. 路由分发
167
+ switch (kind) {
168
+ case 'voice':
169
+ return sendVoiceMedia(gw, target, resolved.path, params);
170
+ case 'video':
171
+ return sendVideoMedia(gw, target, resolved.path, params);
172
+ case 'file':
173
+ return sendFileMedia(gw, target, resolved.path, params);
174
+ case 'image':
175
+ default:
176
+ return sendImageMedia(gw, target, resolved.path, params);
177
+ }
178
+ }
179
+
180
+ // ── 路径安全校验 ──
181
+
182
+ interface ResolveResult {
183
+ ok: true;
184
+ path: string;
185
+ isLocal: boolean;
186
+ }
187
+
188
+ interface ResolveError {
189
+ ok: false;
190
+ error: string;
191
+ }
192
+
193
+ async function resolveMediaPath(source: string, log?: SendMediaParams['log'], workspaceDir?: string): Promise<ResolveResult | ResolveError> {
194
+ const normalized = normalizePath(source);
195
+
196
+ // Data URL → 大小限制检查
197
+ if (isDataUrl(normalized)) {
198
+ if (normalized.length > MAX_DATA_URL_BYTES) {
199
+ const sizeMB = (normalized.length / (1024 * 1024)).toFixed(1);
200
+ return { ok: false, error: `Data URL 过大(${sizeMB}MB,最大 10MB)` };
201
+ }
202
+ return { ok: true, path: normalized, isLocal: false };
203
+ }
204
+
205
+ // 远程 URL → SSRF 安全检查
206
+ if (normalized.startsWith('http://') || normalized.startsWith('https://')) {
207
+ try {
208
+ await validateRemoteUrl(normalized);
209
+ } catch (err) {
210
+ log?.warn(`SSRF blocked for media URL: ${normalized}`);
211
+ return { ok: false, error: `媒体 URL 被 SSRF 防护拦截: ${err instanceof Error ? err.message : String(err)}` };
212
+ }
213
+ return { ok: true, path: normalized, isLocal: false };
214
+ }
215
+
216
+ // 纯文件名→工作区兜底查找
217
+ if (!isLocalFilePath(normalized)) {
218
+ const resolved = resolveWorkingFile(normalized, workspaceDir);
219
+ if (resolved) {
220
+ return { ok: true, path: resolved, isLocal: true };
221
+ }
222
+ return { ok: true, path: normalized, isLocal: false };
223
+ }
224
+
225
+ // 本地路径 → 安全校验
226
+ const resolved = path.resolve(normalized);
227
+ if (!fs.existsSync(resolved)) {
228
+ return { ok: false, error: `File not found: ${resolved}` };
229
+ }
230
+
231
+ let real: string;
232
+ try {
233
+ real = fs.realpathSync(resolved);
234
+ } catch {
235
+ return { ok: false, error: `Cannot resolve path: ${resolved}` };
236
+ }
237
+
238
+ // 动态白名单:静态根目录 + 当前 agent 工作区
239
+ const dynamicRoots = buildDynamicAllowedRoots(workspaceDir);
240
+ const allowed = isPathInAllowedRoots(real, dynamicRoots);
241
+
242
+ if (!allowed) {
243
+ log?.warn(`path blocked — not in allowed directory: ${real}`);
244
+ return { ok: false, error: `文件路径不在允许的目录中` };
245
+ }
246
+
247
+ return { ok: true, path: real, isLocal: true };
248
+ }
249
+
250
+ /** Agent ID → workspaceDir(动态加载 plugin-sdk/health) */
251
+ function resolveWorkspaceFromAgent(agentId?: string): string | undefined {
252
+ const cfg = resolveConfigViaAdapter();
253
+ if (!cfg) return undefined;
254
+ return resolveAgentWorkspace(cfg, agentId);
255
+ }
256
+
257
+ /** 通过 adapter 获取配置 */
258
+ function resolveConfigViaAdapter(): Record<string, unknown> | undefined {
259
+ try {
260
+ const rt = tryGetQQBotRuntime();
261
+ if (!rt) return undefined;
262
+ return getAdapters(rt).getConfig?.();
263
+ } catch { return undefined; }
264
+ }
265
+
266
+ /** 纯文件名在 cwd + 工作区兜底查找 */
267
+ function resolveWorkingFile(name: string, workspaceDir?: string): string | null {
268
+ for (const p of [path.resolve(name), workspaceDir ? path.join(workspaceDir, name) : null]) {
269
+ if (p && fs.existsSync(p)) return p;
270
+ }
271
+ return null;
272
+ }
273
+
274
+ // ── 各类型 sender ──
275
+
276
+ async function sendImageMedia(
277
+ gw: QQBotGateway,
278
+ target: ReplyTarget,
279
+ source: string,
280
+ params: SendMediaParams,
281
+ ): Promise<SendMediaResult> {
282
+ try {
283
+ const result = await gw.sendMedia(target, source, {
284
+ text: params.text,
285
+ msgId: params.replyToId,
286
+ });
287
+ return { messageId: result.id };
288
+ } catch (err) {
289
+ return { error: formatErr(err) };
290
+ }
291
+ }
292
+
293
+ async function sendVoiceMedia(
294
+ gw: QQBotGateway,
295
+ target: ReplyTarget,
296
+ source: string,
297
+ params: SendMediaParams,
298
+ ): Promise<SendMediaResult> {
299
+ // 语音源路由:本地 → { localPath },URL → { url },其他 → { base64 }
300
+ const voiceSource = resolveVoiceSource(source);
301
+
302
+ try {
303
+ const result = await gw.sendVoice(target, voiceSource, {
304
+ msgId: params.replyToId,
305
+ });
306
+ return { messageId: result.id };
307
+ } catch (err) {
308
+ // 语音失败 → fallback 到文件发送
309
+ params.log?.child('media')?.warn(`sendVoice failed (${formatErr(err)}), falling back to sendFile`);
310
+ try {
311
+ const fileName = path.basename(source);
312
+ const fallback = await gw.sendFile(target, source, {
313
+ text: params.text,
314
+ msgId: params.replyToId,
315
+ fileName,
316
+ });
317
+ return { messageId: fallback.id, fallback: true };
318
+ } catch (fallbackErr) {
319
+ return { error: `voice: ${formatErr(err)} | fallback file: ${formatErr(fallbackErr)}` };
320
+ }
321
+ }
322
+ }
323
+
324
+ async function sendVideoMedia(
325
+ gw: QQBotGateway,
326
+ target: ReplyTarget,
327
+ source: string,
328
+ params: SendMediaParams,
329
+ ): Promise<SendMediaResult> {
330
+ try {
331
+ const result = await gw.sendVideo(target, source, {
332
+ text: params.text,
333
+ msgId: params.replyToId,
334
+ });
335
+ return { messageId: result.id };
336
+ } catch (err) {
337
+ return { error: formatErr(err) };
338
+ }
339
+ }
340
+
341
+ async function sendFileMedia(
342
+ gw: QQBotGateway,
343
+ target: ReplyTarget,
344
+ source: string,
345
+ params: SendMediaParams,
346
+ ): Promise<SendMediaResult> {
347
+ try {
348
+ const fileName = path.basename(source);
349
+ const result = await gw.sendFile(target, source, {
350
+ text: params.text,
351
+ msgId: params.replyToId,
352
+ fileName,
353
+ });
354
+ return { messageId: result.id };
355
+ } catch (err) {
356
+ return { error: formatErr(err) };
357
+ }
358
+ }
359
+
360
+ // ── 辅助 ──
361
+
362
+ function resolveVoiceSource(source: string): { url?: string; base64?: string; localPath?: string } {
363
+ if (source.startsWith('http://') || source.startsWith('https://')) {
364
+ return { url: source };
365
+ }
366
+ if (source.startsWith('data:') || (!source.startsWith('/') && !source.startsWith('./') && !source.startsWith('../') && !source.startsWith('~'))) {
367
+ // data URL 或纯 base64 字符串
368
+ const commaIdx = source.indexOf(',');
369
+ return { base64: commaIdx > 0 ? source.slice(commaIdx + 1) : source };
370
+ }
371
+ return { localPath: source };
372
+ }
373
+
374
+ function formatErr(err: unknown): string {
375
+ if (err instanceof Error) return err.message;
376
+ return String(err);
377
+ }