@leoqlin/openclaw-qqbot 1.6.7-alpha1

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 (218) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +484 -0
  3. package/README.zh.md +479 -0
  4. package/bin/qqbot-cli.js +243 -0
  5. package/dist/index.d.ts +17 -0
  6. package/dist/index.js +26 -0
  7. package/dist/src/admin-resolver.d.ts +33 -0
  8. package/dist/src/admin-resolver.js +157 -0
  9. package/dist/src/api.d.ts +301 -0
  10. package/dist/src/api.js +890 -0
  11. package/dist/src/channel.d.ts +29 -0
  12. package/dist/src/channel.js +452 -0
  13. package/dist/src/config.d.ts +56 -0
  14. package/dist/src/config.js +278 -0
  15. package/dist/src/credential-backup.d.ts +31 -0
  16. package/dist/src/credential-backup.js +66 -0
  17. package/dist/src/deliver-debounce.d.ts +74 -0
  18. package/dist/src/deliver-debounce.js +174 -0
  19. package/dist/src/gateway.d.ts +18 -0
  20. package/dist/src/gateway.js +2005 -0
  21. package/dist/src/group-history.d.ts +136 -0
  22. package/dist/src/group-history.js +226 -0
  23. package/dist/src/image-server.d.ts +87 -0
  24. package/dist/src/image-server.js +570 -0
  25. package/dist/src/inbound-attachments.d.ts +60 -0
  26. package/dist/src/inbound-attachments.js +248 -0
  27. package/dist/src/known-users.d.ts +100 -0
  28. package/dist/src/known-users.js +263 -0
  29. package/dist/src/message-gating.d.ts +53 -0
  30. package/dist/src/message-gating.js +107 -0
  31. package/dist/src/message-queue.d.ts +89 -0
  32. package/dist/src/message-queue.js +257 -0
  33. package/dist/src/onboarding.d.ts +10 -0
  34. package/dist/src/onboarding.js +203 -0
  35. package/dist/src/outbound-deliver.d.ts +48 -0
  36. package/dist/src/outbound-deliver.js +392 -0
  37. package/dist/src/outbound.d.ts +205 -0
  38. package/dist/src/outbound.js +938 -0
  39. package/dist/src/proactive.d.ts +170 -0
  40. package/dist/src/proactive.js +399 -0
  41. package/dist/src/ref-index-store.d.ts +101 -0
  42. package/dist/src/ref-index-store.js +298 -0
  43. package/dist/src/reply-dispatcher.d.ts +35 -0
  44. package/dist/src/reply-dispatcher.js +311 -0
  45. package/dist/src/request-context.d.ts +25 -0
  46. package/dist/src/request-context.js +37 -0
  47. package/dist/src/runtime.d.ts +3 -0
  48. package/dist/src/runtime.js +10 -0
  49. package/dist/src/session-store.d.ts +52 -0
  50. package/dist/src/session-store.js +254 -0
  51. package/dist/src/slash-commands.d.ts +77 -0
  52. package/dist/src/slash-commands.js +1866 -0
  53. package/dist/src/startup-greeting.d.ts +30 -0
  54. package/dist/src/startup-greeting.js +97 -0
  55. package/dist/src/streaming.d.ts +247 -0
  56. package/dist/src/streaming.js +899 -0
  57. package/dist/src/stt.d.ts +21 -0
  58. package/dist/src/stt.js +70 -0
  59. package/dist/src/tools/channel.d.ts +16 -0
  60. package/dist/src/tools/channel.js +234 -0
  61. package/dist/src/tools/remind.d.ts +2 -0
  62. package/dist/src/tools/remind.js +256 -0
  63. package/dist/src/types.d.ts +367 -0
  64. package/dist/src/types.js +17 -0
  65. package/dist/src/typing-keepalive.d.ts +27 -0
  66. package/dist/src/typing-keepalive.js +64 -0
  67. package/dist/src/update-checker.d.ts +36 -0
  68. package/dist/src/update-checker.js +171 -0
  69. package/dist/src/utils/audio-convert.d.ts +98 -0
  70. package/dist/src/utils/audio-convert.js +755 -0
  71. package/dist/src/utils/chunked-upload.d.ts +68 -0
  72. package/dist/src/utils/chunked-upload.js +341 -0
  73. package/dist/src/utils/file-utils.d.ts +61 -0
  74. package/dist/src/utils/file-utils.js +172 -0
  75. package/dist/src/utils/image-size.d.ts +51 -0
  76. package/dist/src/utils/image-size.js +234 -0
  77. package/dist/src/utils/media-send.d.ts +158 -0
  78. package/dist/src/utils/media-send.js +499 -0
  79. package/dist/src/utils/media-tags.d.ts +14 -0
  80. package/dist/src/utils/media-tags.js +165 -0
  81. package/dist/src/utils/payload.d.ts +112 -0
  82. package/dist/src/utils/payload.js +186 -0
  83. package/dist/src/utils/pkg-version.d.ts +5 -0
  84. package/dist/src/utils/pkg-version.js +61 -0
  85. package/dist/src/utils/platform.d.ts +137 -0
  86. package/dist/src/utils/platform.js +390 -0
  87. package/dist/src/utils/ssrf-guard.d.ts +25 -0
  88. package/dist/src/utils/ssrf-guard.js +91 -0
  89. package/dist/src/utils/text-parsing.d.ts +36 -0
  90. package/dist/src/utils/text-parsing.js +75 -0
  91. package/dist/src/utils/upload-cache.d.ts +34 -0
  92. package/dist/src/utils/upload-cache.js +93 -0
  93. package/index.ts +31 -0
  94. package/node_modules/@eshaz/web-worker/LICENSE +201 -0
  95. package/node_modules/@eshaz/web-worker/README.md +134 -0
  96. package/node_modules/@eshaz/web-worker/browser.js +17 -0
  97. package/node_modules/@eshaz/web-worker/cjs/browser.js +16 -0
  98. package/node_modules/@eshaz/web-worker/cjs/node.js +219 -0
  99. package/node_modules/@eshaz/web-worker/index.d.ts +4 -0
  100. package/node_modules/@eshaz/web-worker/node.js +223 -0
  101. package/node_modules/@eshaz/web-worker/package.json +54 -0
  102. package/node_modules/@wasm-audio-decoders/common/index.js +5 -0
  103. package/node_modules/@wasm-audio-decoders/common/package.json +36 -0
  104. package/node_modules/@wasm-audio-decoders/common/src/WASMAudioDecoderCommon.js +231 -0
  105. package/node_modules/@wasm-audio-decoders/common/src/WASMAudioDecoderWorker.js +129 -0
  106. package/node_modules/@wasm-audio-decoders/common/src/puff/README +67 -0
  107. package/node_modules/@wasm-audio-decoders/common/src/puff/build_puff.js +31 -0
  108. package/node_modules/@wasm-audio-decoders/common/src/puff/puff.c +863 -0
  109. package/node_modules/@wasm-audio-decoders/common/src/puff/puff.h +35 -0
  110. package/node_modules/@wasm-audio-decoders/common/src/utilities.js +3 -0
  111. package/node_modules/@wasm-audio-decoders/common/types.d.ts +7 -0
  112. package/node_modules/mpg123-decoder/README.md +265 -0
  113. package/node_modules/mpg123-decoder/dist/mpg123-decoder.min.js +185 -0
  114. package/node_modules/mpg123-decoder/dist/mpg123-decoder.min.js.map +1 -0
  115. package/node_modules/mpg123-decoder/index.js +8 -0
  116. package/node_modules/mpg123-decoder/package.json +58 -0
  117. package/node_modules/mpg123-decoder/src/EmscriptenWasm.js +464 -0
  118. package/node_modules/mpg123-decoder/src/MPEGDecoder.js +200 -0
  119. package/node_modules/mpg123-decoder/src/MPEGDecoderWebWorker.js +21 -0
  120. package/node_modules/mpg123-decoder/types.d.ts +30 -0
  121. package/node_modules/silk-wasm/LICENSE +21 -0
  122. package/node_modules/silk-wasm/README.md +85 -0
  123. package/node_modules/silk-wasm/lib/index.cjs +16 -0
  124. package/node_modules/silk-wasm/lib/index.d.ts +70 -0
  125. package/node_modules/silk-wasm/lib/index.mjs +16 -0
  126. package/node_modules/silk-wasm/lib/silk.wasm +0 -0
  127. package/node_modules/silk-wasm/lib/utils.d.ts +4 -0
  128. package/node_modules/silk-wasm/package.json +39 -0
  129. package/node_modules/simple-yenc/.github/FUNDING.yml +1 -0
  130. package/node_modules/simple-yenc/.prettierignore +1 -0
  131. package/node_modules/simple-yenc/LICENSE +7 -0
  132. package/node_modules/simple-yenc/README.md +163 -0
  133. package/node_modules/simple-yenc/dist/esm.js +1 -0
  134. package/node_modules/simple-yenc/dist/index.js +1 -0
  135. package/node_modules/simple-yenc/package.json +50 -0
  136. package/node_modules/simple-yenc/rollup.config.js +27 -0
  137. package/node_modules/simple-yenc/src/simple-yenc.js +302 -0
  138. package/node_modules/ws/LICENSE +20 -0
  139. package/node_modules/ws/README.md +548 -0
  140. package/node_modules/ws/browser.js +8 -0
  141. package/node_modules/ws/index.js +22 -0
  142. package/node_modules/ws/lib/buffer-util.js +131 -0
  143. package/node_modules/ws/lib/constants.js +19 -0
  144. package/node_modules/ws/lib/event-target.js +292 -0
  145. package/node_modules/ws/lib/extension.js +203 -0
  146. package/node_modules/ws/lib/limiter.js +55 -0
  147. package/node_modules/ws/lib/permessage-deflate.js +528 -0
  148. package/node_modules/ws/lib/receiver.js +706 -0
  149. package/node_modules/ws/lib/sender.js +602 -0
  150. package/node_modules/ws/lib/stream.js +161 -0
  151. package/node_modules/ws/lib/subprotocol.js +62 -0
  152. package/node_modules/ws/lib/validation.js +152 -0
  153. package/node_modules/ws/lib/websocket-server.js +554 -0
  154. package/node_modules/ws/lib/websocket.js +1393 -0
  155. package/node_modules/ws/package.json +70 -0
  156. package/node_modules/ws/wrapper.mjs +21 -0
  157. package/openclaw.plugin.json +17 -0
  158. package/package.json +70 -0
  159. package/preload.cjs +33 -0
  160. package/scripts/cleanup-legacy-plugins.sh +124 -0
  161. package/scripts/link-sdk-core.cjs +185 -0
  162. package/scripts/postinstall-link-sdk.js +126 -0
  163. package/scripts/proactive-api-server.ts +369 -0
  164. package/scripts/send-proactive.ts +293 -0
  165. package/scripts/set-markdown.sh +156 -0
  166. package/scripts/test-sendmedia.ts +116 -0
  167. package/scripts/upgrade-via-npm.ps1 +460 -0
  168. package/scripts/upgrade-via-npm.sh +652 -0
  169. package/scripts/upgrade-via-source.sh +1026 -0
  170. package/skills/qqbot-channel/SKILL.md +263 -0
  171. package/skills/qqbot-channel/references/api_references.md +521 -0
  172. package/skills/qqbot-media/SKILL.md +60 -0
  173. package/skills/qqbot-remind/SKILL.md +159 -0
  174. package/src/admin-resolver.ts +181 -0
  175. package/src/api.ts +1284 -0
  176. package/src/channel.ts +477 -0
  177. package/src/config.ts +347 -0
  178. package/src/credential-backup.ts +72 -0
  179. package/src/deliver-debounce.ts +229 -0
  180. package/src/gateway.ts +2245 -0
  181. package/src/group-history.ts +328 -0
  182. package/src/image-server.ts +675 -0
  183. package/src/inbound-attachments.ts +321 -0
  184. package/src/known-users.ts +353 -0
  185. package/src/message-gating.ts +190 -0
  186. package/src/message-queue.ts +352 -0
  187. package/src/onboarding.ts +274 -0
  188. package/src/openclaw-plugin-sdk.d.ts +587 -0
  189. package/src/outbound-deliver.ts +473 -0
  190. package/src/outbound.ts +1131 -0
  191. package/src/proactive.ts +530 -0
  192. package/src/ref-index-store.ts +412 -0
  193. package/src/reply-dispatcher.ts +334 -0
  194. package/src/request-context.ts +49 -0
  195. package/src/runtime.ts +14 -0
  196. package/src/session-store.ts +303 -0
  197. package/src/slash-commands.ts +2030 -0
  198. package/src/startup-greeting.ts +120 -0
  199. package/src/streaming.ts +1077 -0
  200. package/src/stt.ts +86 -0
  201. package/src/tools/channel.ts +281 -0
  202. package/src/tools/remind.ts +308 -0
  203. package/src/types.ts +391 -0
  204. package/src/typing-keepalive.ts +59 -0
  205. package/src/update-checker.ts +186 -0
  206. package/src/utils/audio-convert.ts +859 -0
  207. package/src/utils/chunked-upload.ts +483 -0
  208. package/src/utils/file-utils.ts +193 -0
  209. package/src/utils/image-size.ts +266 -0
  210. package/src/utils/media-send.ts +631 -0
  211. package/src/utils/media-tags.ts +183 -0
  212. package/src/utils/payload.ts +265 -0
  213. package/src/utils/pkg-version.ts +64 -0
  214. package/src/utils/platform.ts +435 -0
  215. package/src/utils/ssrf-guard.ts +102 -0
  216. package/src/utils/text-parsing.ts +85 -0
  217. package/src/utils/upload-cache.ts +128 -0
  218. package/tsconfig.json +16 -0
@@ -0,0 +1,499 @@
1
+ /**
2
+ * 富媒体标签解析与发送队列
3
+ *
4
+ * 提供媒体标签(qqimg / qqvoice / qqvideo / qqfile / qqmedia)的检测、
5
+ * 拆分、路径编码修复,以及统一的发送队列执行器。
6
+ */
7
+ /* eslint-disable no-undef -- Buffer is a Node.js global */
8
+ import { normalizeMediaTags } from "./media-tags.js";
9
+ import { normalizePath } from "./platform.js";
10
+ import { sendPhoto, sendVoice, sendVideoMsg, sendDocument, sendMedia as sendMediaAuto, } from "../outbound.js";
11
+ /** 统一的媒体标签正则 — 匹配标准化后的 6 种标签 */
12
+ export const MEDIA_TAG_REGEX = /<(qqimg|qqvoice|qqvideo|qqfile|qqmedia|img)>([^<>]+)<\/(?:qqimg|qqvoice|qqvideo|qqfile|qqmedia|img)>/gi;
13
+ /** 创建一个新的全局标签正则实例(每次调用 reset lastIndex) */
14
+ export function createMediaTagRegex() {
15
+ return new RegExp(MEDIA_TAG_REGEX.source, MEDIA_TAG_REGEX.flags);
16
+ }
17
+ // ============ 路径编码修复 ============
18
+ /**
19
+ * 修复路径编码问题(双反斜杠、八进制转义、UTF-8 双重编码)
20
+ *
21
+ * 这是由于 LLM 输出路径时可能引入的编码问题:
22
+ * - Markdown 转义导致双反斜杠
23
+ * - 八进制转义序列(来自某些 shell 工具的输出)
24
+ * - UTF-8 双重编码(中文路径经过多层处理后的乱码)
25
+ *
26
+ * 此方法在 gateway.ts deliver 回调、outbound.ts sendText、
27
+ * streaming.ts sendMediaQueue 中共用。
28
+ */
29
+ export function fixPathEncoding(mediaPath, log) {
30
+ // 1. 双反斜杠 -> 单反斜杠(Markdown 转义)
31
+ let result = mediaPath.replace(/\\\\/g, "\\");
32
+ // 2. 八进制转义序列 + UTF-8 双重编码修复
33
+ try {
34
+ const hasOctal = /\\[0-7]{1,3}/.test(result);
35
+ const hasNonASCII = /[\u0080-\u00FF]/.test(result);
36
+ if (hasOctal || hasNonASCII) {
37
+ log?.debug?.(`Decoding path with mixed encoding: ${result}`);
38
+ // Step 1: 将八进制转义转换为字节
39
+ let decoded = result.replace(/\\([0-7]{1,3})/g, (_, octal) => String.fromCharCode(parseInt(octal, 8)));
40
+ // Step 2: 提取所有字节(包括 Latin-1 字符)
41
+ const bytes = [];
42
+ for (let i = 0; i < decoded.length; i++) {
43
+ const code = decoded.charCodeAt(i);
44
+ if (code <= 0xff) {
45
+ bytes.push(code);
46
+ }
47
+ else {
48
+ const charBytes = Buffer.from(decoded[i], "utf8");
49
+ bytes.push(...charBytes);
50
+ }
51
+ }
52
+ // Step 3: 尝试按 UTF-8 解码
53
+ const buffer = Buffer.from(bytes);
54
+ const utf8Decoded = buffer.toString("utf8");
55
+ if (!utf8Decoded.includes("\uFFFD") ||
56
+ utf8Decoded.length < decoded.length) {
57
+ result = utf8Decoded;
58
+ log?.debug?.(`Successfully decoded path: ${result}`);
59
+ }
60
+ }
61
+ }
62
+ catch (decodeErr) {
63
+ log?.error?.(`Path decode error: ${decodeErr}`);
64
+ }
65
+ return result;
66
+ }
67
+ // ============ 代码块检测 ============
68
+ /**
69
+ * 判断文本中给定位置是否处于围栏代码块内(``` 块)。
70
+ *
71
+ * 围栏代码块:行首 ``` 开始,到下一个行首 ``` 结束(或文本末尾)
72
+ *
73
+ * @param text 完整文本
74
+ * @param position 要检测的位置(字符索引)
75
+ * @returns 如果 position 在围栏代码块内返回 true
76
+ */
77
+ export function isInsideCodeBlock(text, position) {
78
+ const fenceRegex = /^(`{3,})[^\n]*$/gm;
79
+ let fenceMatch;
80
+ let openFence = null;
81
+ while ((fenceMatch = fenceRegex.exec(text)) !== null) {
82
+ const ticks = fenceMatch[1].length;
83
+ if (!openFence) {
84
+ openFence = { pos: fenceMatch.index, ticks };
85
+ }
86
+ else if (ticks >= openFence.ticks) {
87
+ // 闭合围栏
88
+ if (position >= openFence.pos && position < fenceMatch.index + fenceMatch[0].length)
89
+ return true;
90
+ openFence = null;
91
+ }
92
+ }
93
+ // 未闭合的围栏一直延伸到文本末尾
94
+ if (openFence && position >= openFence.pos)
95
+ return true;
96
+ return false;
97
+ }
98
+ // ============ 媒体标签解析 ============
99
+ /**
100
+ * 检测文本是否包含富媒体标签(忽略代码块内的标签)
101
+ */
102
+ export function hasMediaTags(text) {
103
+ const normalized = normalizeMediaTags(text);
104
+ const regex = createMediaTagRegex();
105
+ let match;
106
+ while ((match = regex.exec(normalized)) !== null) {
107
+ if (!isInsideCodeBlock(normalized, match.index))
108
+ return true;
109
+ }
110
+ return false;
111
+ }
112
+ /**
113
+ * 在文本中查找**第一个**完整闭合的媒体标签
114
+ *
115
+ * 与 splitByMediaTags 不同,此函数只匹配一个标签就停止,
116
+ * 用于流式场景的"循环消费"模式:每次处理一个标签,更新偏移,再找下一个。
117
+ *
118
+ * @param text 待检查的文本(应已 normalize 过)
119
+ * @returns 第一个闭合标签的信息,没有则返回 null
120
+ */
121
+ export function findFirstClosedMediaTag(text, log) {
122
+ const regex = createMediaTagRegex();
123
+ let match;
124
+ while ((match = regex.exec(text)) !== null) {
125
+ // 跳过代码块内的媒体标签
126
+ if (isInsideCodeBlock(text, match.index)) {
127
+ log?.debug?.(`findFirstClosedMediaTag: skipping <${match[1]}> at index ${match.index} (inside code block)`);
128
+ continue;
129
+ }
130
+ const textBefore = text.slice(0, match.index);
131
+ const tagName = match[1].toLowerCase();
132
+ let mediaPath = match[2]?.trim() ?? "";
133
+ // 剥离 MEDIA: 前缀
134
+ if (mediaPath.startsWith("MEDIA:")) {
135
+ mediaPath = mediaPath.slice("MEDIA:".length);
136
+ }
137
+ mediaPath = normalizePath(mediaPath);
138
+ mediaPath = fixPathEncoding(mediaPath, log);
139
+ const typeMap = {
140
+ qqimg: "image",
141
+ qqvoice: "voice",
142
+ qqvideo: "video",
143
+ qqfile: "file",
144
+ qqmedia: "media",
145
+ };
146
+ return {
147
+ textBefore,
148
+ tagName,
149
+ mediaPath,
150
+ tagEndIndex: match.index + match[0].length,
151
+ itemType: typeMap[tagName] ?? "image",
152
+ };
153
+ }
154
+ return null;
155
+ }
156
+ /**
157
+ * 将文本按富媒体标签拆分为三部分
158
+ *
159
+ * 用于两个场景:
160
+ * 1. 流式模式:中断-恢复流程(标签前文本 → 结束流式 → 发送媒体 → 新流式 → 标签后文本)
161
+ * 2. 普通模式:构建按顺序发送的队列
162
+ */
163
+ export function splitByMediaTags(text, log) {
164
+ const normalized = normalizeMediaTags(text);
165
+ const regex = createMediaTagRegex();
166
+ // 过滤掉代码块内的匹配
167
+ const matches = [...normalized.matchAll(regex)].filter(m => !isInsideCodeBlock(normalized, m.index));
168
+ if (matches.length === 0) {
169
+ return {
170
+ hasMediaTags: false,
171
+ textBeforeFirstTag: normalized,
172
+ textAfterLastTag: "",
173
+ mediaQueue: [],
174
+ };
175
+ }
176
+ // 第一个标签前的纯文本
177
+ const firstMatch = matches[0];
178
+ const textBeforeFirstTag = normalized
179
+ .slice(0, firstMatch.index)
180
+ .replace(/\n{3,}/g, "\n\n")
181
+ .trim();
182
+ // 最后一个标签后的纯文本
183
+ const lastMatch = matches[matches.length - 1];
184
+ const lastMatchEnd = lastMatch.index + lastMatch[0].length;
185
+ const textAfterLastTag = normalized
186
+ .slice(lastMatchEnd)
187
+ .replace(/\n{3,}/g, "\n\n")
188
+ .trim();
189
+ // 构建媒体发送队列
190
+ const mediaQueue = [];
191
+ let lastIndex = firstMatch.index;
192
+ for (const match of matches) {
193
+ // 标签前的文本(标签之间的间隔文本)
194
+ const textBetween = normalized
195
+ .slice(lastIndex, match.index)
196
+ .replace(/\n{3,}/g, "\n\n")
197
+ .trim();
198
+ if (textBetween && lastIndex !== firstMatch.index) {
199
+ // 只添加非首段的间隔文本(首段由 textBeforeFirstTag 覆盖)
200
+ mediaQueue.push({ type: "text", content: textBetween });
201
+ }
202
+ // 解析标签内容
203
+ const tagName = match[1].toLowerCase();
204
+ let mediaPath = match[2]?.trim() ?? "";
205
+ // 剥离 MEDIA: 前缀
206
+ if (mediaPath.startsWith("MEDIA:")) {
207
+ mediaPath = mediaPath.slice("MEDIA:".length);
208
+ }
209
+ mediaPath = normalizePath(mediaPath);
210
+ // 修复路径编码问题
211
+ mediaPath = fixPathEncoding(mediaPath, log);
212
+ // 根据标签类型加入队列
213
+ const typeMap = {
214
+ qqimg: "image",
215
+ qqvoice: "voice",
216
+ qqvideo: "video",
217
+ qqfile: "file",
218
+ qqmedia: "media",
219
+ };
220
+ const itemType = typeMap[tagName] ?? "image";
221
+ if (mediaPath) {
222
+ mediaQueue.push({ type: itemType, content: mediaPath });
223
+ log?.info?.(`Found ${itemType} in <${tagName}>: ${mediaPath.slice(0, 80)}`);
224
+ }
225
+ lastIndex = match.index + match[0].length;
226
+ }
227
+ return {
228
+ hasMediaTags: true,
229
+ textBeforeFirstTag,
230
+ textAfterLastTag,
231
+ mediaQueue,
232
+ };
233
+ }
234
+ /**
235
+ * 从文本中解析出完整的发送队列(含标签前后的纯文本)
236
+ *
237
+ * 与 splitByMediaTags 的区别:
238
+ * - splitByMediaTags 分为 before / queue / after 三段(供流式模式的中断-恢复)
239
+ * - parseMediaTagsToSendQueue 返回一个扁平的完整队列(供普通模式按顺序发送)
240
+ *
241
+ * 适用于 gateway.ts deliver 回调和 outbound.ts sendText。
242
+ */
243
+ export function parseMediaTagsToSendQueue(text, log) {
244
+ const split = splitByMediaTags(text, log);
245
+ if (!split.hasMediaTags) {
246
+ return { hasMediaTags: false, sendQueue: [] };
247
+ }
248
+ const sendQueue = [];
249
+ // 标签前的文本
250
+ if (split.textBeforeFirstTag) {
251
+ sendQueue.push({ type: "text", content: split.textBeforeFirstTag });
252
+ }
253
+ // 媒体队列(含标签间文本)
254
+ sendQueue.push(...split.mediaQueue);
255
+ // 标签后的文本
256
+ if (split.textAfterLastTag) {
257
+ sendQueue.push({ type: "text", content: split.textAfterLastTag });
258
+ }
259
+ return { hasMediaTags: true, sendQueue };
260
+ }
261
+ // ============ 发送队列执行 ============
262
+ /**
263
+ * 统一执行发送队列
264
+ *
265
+ * 遍历 sendQueue,按类型调用对应的发送函数。
266
+ * 文本项通过 onSendText 回调处理(不同场景的文本发送方式不同)。
267
+ * 媒体发送失败时,通过 onSendText 发送兜底文本通知用户。
268
+ */
269
+ export async function executeSendQueue(queue, ctx, options = {}) {
270
+ const { mediaTarget, qualifiedTarget, account, replyToId, log } = ctx;
271
+ const prefix = mediaTarget.logPrefix ?? `[qqbot:${account.accountId}]`;
272
+ /** 媒体发送失败时的兜底:通过 onSendText 发送错误文本给用户 */
273
+ const sendFallbackText = async (errorMsg) => {
274
+ if (!options.onSendText) {
275
+ log?.info(`${prefix} executeSendQueue: no onSendText handler, cannot send fallback text`);
276
+ return;
277
+ }
278
+ try {
279
+ await options.onSendText(errorMsg);
280
+ }
281
+ catch (fallbackErr) {
282
+ log?.error(`${prefix} executeSendQueue: fallback text send failed: ${fallbackErr}`);
283
+ }
284
+ };
285
+ for (const item of queue) {
286
+ const FALLBACK_MSG = "发送失败,请稍后重试。";
287
+ try {
288
+ if (item.type === "text") {
289
+ if (options.skipInterTagText) {
290
+ log?.info(`${prefix} executeSendQueue: skipping inter-tag text (${item.content.length} chars)`);
291
+ continue;
292
+ }
293
+ if (options.onSendText) {
294
+ await options.onSendText(item.content);
295
+ }
296
+ else {
297
+ log?.info(`${prefix} executeSendQueue: no onSendText handler, skipping text`);
298
+ }
299
+ continue;
300
+ }
301
+ log?.info(`${prefix} executeSendQueue: sending ${item.type}: ${item.content.slice(0, 80)}...`);
302
+ if (item.type === "image") {
303
+ const result = await sendPhoto(mediaTarget, item.content);
304
+ if (result.error) {
305
+ log?.error(`${prefix} sendPhoto error: ${result.error}`);
306
+ await sendFallbackText(FALLBACK_MSG);
307
+ }
308
+ }
309
+ else if (item.type === "voice") {
310
+ const uploadFormats = account.config?.audioFormatPolicy?.uploadDirectFormats ??
311
+ account.config?.voiceDirectUploadFormats;
312
+ const transcodeEnabled = account.config?.audioFormatPolicy?.transcodeEnabled !== false;
313
+ const voiceTimeout = 45000; // 45s
314
+ try {
315
+ const result = await Promise.race([
316
+ sendVoice(mediaTarget, item.content, uploadFormats, transcodeEnabled),
317
+ new Promise((resolve) => setTimeout(() => resolve({ channel: "qqbot", error: "语音发送超时,已跳过" }), voiceTimeout)),
318
+ ]);
319
+ if (result.error) {
320
+ log?.error(`${prefix} sendVoice error: ${result.error}`);
321
+ await sendFallbackText(FALLBACK_MSG);
322
+ }
323
+ }
324
+ catch (err) {
325
+ log?.error(`${prefix} sendVoice unexpected error: ${err}`);
326
+ await sendFallbackText(FALLBACK_MSG);
327
+ }
328
+ }
329
+ else if (item.type === "video") {
330
+ const result = await sendVideoMsg(mediaTarget, item.content);
331
+ if (result.error) {
332
+ log?.error(`${prefix} sendVideoMsg error: ${result.error}`);
333
+ await sendFallbackText(FALLBACK_MSG);
334
+ }
335
+ }
336
+ else if (item.type === "file") {
337
+ const result = await sendDocument(mediaTarget, item.content);
338
+ if (result.error) {
339
+ log?.error(`${prefix} sendDocument error: ${result.error}`);
340
+ await sendFallbackText(FALLBACK_MSG);
341
+ }
342
+ }
343
+ else if (item.type === "media") {
344
+ const result = await sendMediaAuto({
345
+ to: qualifiedTarget,
346
+ text: "",
347
+ mediaUrl: item.content,
348
+ accountId: account.accountId,
349
+ replyToId,
350
+ account,
351
+ });
352
+ if (result.error) {
353
+ log?.error(`${prefix} sendMedia(auto) error: ${result.error}`);
354
+ await sendFallbackText(FALLBACK_MSG);
355
+ }
356
+ }
357
+ }
358
+ catch (err) {
359
+ log?.error(`${prefix} executeSendQueue: failed to send ${item.type}: ${err}`);
360
+ await sendFallbackText(FALLBACK_MSG);
361
+ }
362
+ }
363
+ }
364
+ /**
365
+ * 从文本中剥离所有媒体标签(用于最终显示)
366
+ */
367
+ export function stripMediaTags(text) {
368
+ const regex = createMediaTagRegex();
369
+ return text.replace(regex, "").replace(/\n{3,}/g, "\n\n").trim();
370
+ }
371
+ /**
372
+ * 检测文本中是否有未闭合的媒体标签,如果有则截断到安全位置。
373
+ *
374
+ * 流式输出中 LLM 逐 token 吐出媒体标签,中间态不应直接发给用户。
375
+ * 只检查最后一行,从右到左扫描 `<`,找到第一个有意义的媒体标签片段并判断是否完整。
376
+ *
377
+ * 核心原则:截断只能截到**开标签**前面;闭合标签前缀若找不到对应开标签则原样返回。
378
+ */
379
+ export function stripIncompleteMediaTag(text) {
380
+ if (!text)
381
+ return [text, false];
382
+ const lastNL = text.lastIndexOf("\n");
383
+ const lastLine = lastNL === -1 ? text : text.slice(lastNL + 1);
384
+ if (!lastLine)
385
+ return [text, false]; // 以换行结尾,安全
386
+ const lineStart = lastNL === -1 ? 0 : lastNL + 1;
387
+ // ---- 媒体标签名判断 ----
388
+ const MEDIA_NAMES = [
389
+ "qq", "img", "image", "pic", "photo", "voice", "audio", "video",
390
+ "file", "doc", "media", "attach", "send", "document", "picture",
391
+ "qqvoice", "qqaudio", "qqvideo", "qqimg", "qqimage", "qqfile",
392
+ "qqpic", "qqphoto", "qqmedia", "qqattach", "qqsend", "qqdocument", "qqpicture",
393
+ ];
394
+ const isMedia = (n) => MEDIA_NAMES.includes(n.toLowerCase());
395
+ const couldBeMedia = (n) => { const l = n.toLowerCase(); return MEDIA_NAMES.some(m => m.startsWith(l)); };
396
+ /** 截断到 lastLine 中位置 pos 之前,返回 [safe, true] */
397
+ const cutAt = (pos) => [
398
+ text.slice(0, lineStart + pos).trimEnd(),
399
+ true,
400
+ ];
401
+ /** 检查 lastLine 中位置 pos 处的媒体开标签后面是否有完整闭合标签 */
402
+ const hasClosingAfter = (pos, name) => {
403
+ const rest = lastLine.slice(pos + 1); // < 之后
404
+ const gt = rest.search(/[>>]/);
405
+ if (gt < 0)
406
+ return false;
407
+ const after = rest.slice(gt + 1);
408
+ return new RegExp(`[<\uFF1C]/${name}\\s*[>\uFF1E]`, "i").test(after);
409
+ };
410
+ // ---- 回溯状态 ----
411
+ // 遇到不完整的闭合标签/孤立 < 时,记录并继续往左找对应的开标签
412
+ let searchTag = null; // 要找的开标签名,"*" = 来自孤立 <
413
+ let searchIsClosing = false; // 触发回溯的是闭合类(</、</tag)还是开类(<)
414
+ let fallbackPos = -1; // 最右边触发回溯的 < 的位置
415
+ for (let i = lastLine.length - 1; i >= 0; i--) {
416
+ const ch = lastLine[i];
417
+ if (ch !== "<" && ch !== "\uFF1C")
418
+ continue;
419
+ const after = lastLine.slice(i + 1);
420
+ const isClosing = after.startsWith("/");
421
+ const nameStr = isClosing ? after.slice(1) : after;
422
+ const nameMatch = nameStr.match(/^(\w+)/);
423
+ // ======== 回溯模式:正在找对应的开标签 ========
424
+ if (searchTag) {
425
+ if (!nameMatch || isClosing)
426
+ continue;
427
+ const cand = nameMatch[1].toLowerCase();
428
+ if (!isMedia(cand))
429
+ continue;
430
+ // 跳过已有完整闭合对的开标签
431
+ if (hasClosingAfter(i, cand))
432
+ continue;
433
+ if (searchTag === "*") {
434
+ return cutAt(i); // 通配:任何未闭合的媒体开标签都匹配
435
+ }
436
+ // 精确/前缀匹配(闭合标签名可能不完整,如 </qq 对 <qqvoice)
437
+ const t = searchTag.toLowerCase();
438
+ if (cand === t || cand.startsWith(t))
439
+ return cutAt(i);
440
+ continue;
441
+ }
442
+ // ======== 正常扫描 ========
443
+ // --- 无标签名:孤立 < 或 </ ---
444
+ if (!nameMatch) {
445
+ if (!after) {
446
+ // 孤立 <:可能是新开标签,往左找未闭合的媒体开标签
447
+ if (fallbackPos < 0)
448
+ fallbackPos = i;
449
+ searchTag = "*";
450
+ searchIsClosing = false;
451
+ }
452
+ else if (after === "/") {
453
+ // 孤立 </:闭合标签开始,找不到开标签时原样返回
454
+ if (fallbackPos < 0)
455
+ fallbackPos = i;
456
+ searchTag = "*";
457
+ searchIsClosing = true;
458
+ }
459
+ // 其他(如 "< 3"):非标签,跳过
460
+ continue;
461
+ }
462
+ const tag = nameMatch[1];
463
+ const restAfterName = nameStr.slice(tag.length);
464
+ const hasGT = /[>>]/.test(restAfterName);
465
+ // --- 不是媒体标签(也不是前缀) ---
466
+ if (!isMedia(tag) && !(couldBeMedia(tag) && !hasGT))
467
+ continue;
468
+ // --- 标签未闭合(无 >),还在输入中 ---
469
+ if (!hasGT) {
470
+ if (isClosing) {
471
+ // 不完整闭合标签(如 </voice、</i)→ 回溯找开标签
472
+ if (fallbackPos < 0)
473
+ fallbackPos = i;
474
+ searchTag = tag;
475
+ searchIsClosing = true;
476
+ continue;
477
+ }
478
+ // 不完整开标签(如 <img、<i)→ 截断
479
+ return cutAt(i);
480
+ }
481
+ // --- 标签有 >,是完整的 ---
482
+ if (isClosing)
483
+ return [text, false]; // 完整闭合标签 </tag> → 安全
484
+ // 完整开标签 <tag...>,检查后面有无对应 </tag>
485
+ if (hasClosingAfter(i, tag))
486
+ return [text, false];
487
+ return cutAt(i); // 无闭合 → 截断
488
+ }
489
+ // ---- 循环结束,处理回溯未命中 ----
490
+ if (searchTag) {
491
+ if (!searchIsClosing) {
492
+ // 来自孤立 <,前面没有媒体开标签 → 截断到那个 < 前面
493
+ return cutAt(fallbackPos);
494
+ }
495
+ // 来自闭合类(</、</tag),前面找不到对应开标签 → 不可能是有效媒体标签,原样返回
496
+ return [text, true];
497
+ }
498
+ return [text, false]; // 最后一行无任何 < → 安全
499
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * 富媒体标签预处理与纠错
3
+ *
4
+ * 小模型常见的标签拼写错误及变体,在正则匹配前统一修正为标准格式。
5
+ */
6
+ /**
7
+ * 预处理 LLM 输出文本,将各种畸形/错误的富媒体标签修正为标准格式。
8
+ *
9
+ * 标准格式:<qqimg>/path/to/file</qqimg>
10
+ *
11
+ * @param text LLM 原始输出
12
+ * @returns 修正后的文本(如果没有匹配到任何标签则原样返回)
13
+ */
14
+ export declare function normalizeMediaTags(text: string): string;
@@ -0,0 +1,165 @@
1
+ /**
2
+ * 富媒体标签预处理与纠错
3
+ *
4
+ * 小模型常见的标签拼写错误及变体,在正则匹配前统一修正为标准格式。
5
+ */
6
+ import { expandTilde } from "./platform.js";
7
+ // 标准标签名(qqmedia = 统一标签,系统根据文件扩展名自动路由)
8
+ const VALID_TAGS = ["qqimg", "qqvoice", "qqvideo", "qqfile", "qqmedia"];
9
+ // 开头标签别名映射(key 全部小写)
10
+ const TAG_ALIASES = {
11
+ // ---- qqimg 变体 ----
12
+ "qq_img": "qqimg",
13
+ "qqimage": "qqimg",
14
+ "qq_image": "qqimg",
15
+ "qqpic": "qqimg",
16
+ "qq_pic": "qqimg",
17
+ "qqpicture": "qqimg",
18
+ "qq_picture": "qqimg",
19
+ "qqphoto": "qqimg",
20
+ "qq_photo": "qqimg",
21
+ "img": "qqimg",
22
+ "image": "qqimg",
23
+ "pic": "qqimg",
24
+ "picture": "qqimg",
25
+ "photo": "qqimg",
26
+ // ---- qqvoice 变体 ----
27
+ "qq_voice": "qqvoice",
28
+ "qqaudio": "qqvoice",
29
+ "qq_audio": "qqvoice",
30
+ "voice": "qqvoice",
31
+ "audio": "qqvoice",
32
+ // ---- qqvideo 变体 ----
33
+ "qq_video": "qqvideo",
34
+ "video": "qqvideo",
35
+ // ---- qqfile 变体 ----
36
+ "qq_file": "qqfile",
37
+ "qqdoc": "qqfile",
38
+ "qq_doc": "qqfile",
39
+ "file": "qqfile",
40
+ "doc": "qqfile",
41
+ "document": "qqfile",
42
+ // ---- qqmedia 变体(统一标签,根据扩展名自动路由) ----
43
+ "qq_media": "qqmedia",
44
+ "media": "qqmedia",
45
+ "attachment": "qqmedia",
46
+ "attach": "qqmedia",
47
+ "qqattachment": "qqmedia",
48
+ "qq_attachment": "qqmedia",
49
+ "qqsend": "qqmedia",
50
+ "qq_send": "qqmedia",
51
+ "send": "qqmedia",
52
+ };
53
+ // 构建所有可识别的标签名列表(标准名 + 别名)
54
+ const ALL_TAG_NAMES = [...VALID_TAGS, ...Object.keys(TAG_ALIASES)];
55
+ // 按长度降序排列,优先匹配更长的名称(避免 "img" 抢先匹配 "qqimg" 的子串)
56
+ ALL_TAG_NAMES.sort((a, b) => b.length - a.length);
57
+ const TAG_NAME_PATTERN = ALL_TAG_NAMES.join("|");
58
+ /**
59
+ * 自闭合属性语法的正则:
60
+ * <qqmedia file="/path/to/file.png" />
61
+ * <qqimg src="/path" />
62
+ * <image file="..." />
63
+ * <qqmedia type="file" path="/path/to/file.zip" /> ← 多属性
64
+ * 支持 file= / src= / path= / url= 属性名,引号可选
65
+ * 也支持前面有其他属性(如 type="file")的多属性写法
66
+ */
67
+ const SELF_CLOSING_TAG_REGEX = new RegExp("`?" +
68
+ "[<<<]\\s*(" + TAG_NAME_PATTERN + ")" +
69
+ // 允许前面有任意其他属性(如 type="file"),非贪婪跳过
70
+ "(?:\\s+(?!file|src|path|url)[a-z_-]+\\s*=\\s*[\"']?[^\"'/>>>]*?[\"']?)*" +
71
+ "\\s+(?:file|src|path|url)\\s*=\\s*" +
72
+ "[\"']?" +
73
+ // 注意:文件路径包含 /,不能在字符类中排除 /
74
+ "([^\"'>>]+?)" +
75
+ "[\"']?" +
76
+ // 允许后面还有其他属性
77
+ "(?:\\s+[a-z_-]+\\s*=\\s*[\"']?[^\"'/>>>]*?[\"']?)*" +
78
+ "\\s*/?" +
79
+ "\\s*[>>>]" +
80
+ "`?", "gi");
81
+ /**
82
+ * 构建一个宽容的正则,能匹配各种畸形标签写法:
83
+ *
84
+ * 常见错误模式:
85
+ * 1. 标签名拼错:<qq_img>, <qqimage>, <image>, <img>, <pic> ...
86
+ * 2. 标签内多余空格:<qqimg >, < qqimg>, <qqimg >
87
+ * 3. 闭合标签不匹配:<qqimg>url</qqvoice>, <qqimg>url</img>
88
+ * 4. 闭合标签缺失斜杠:<qqimg>url<qqimg> (用开头标签代替闭合标签)
89
+ * 5. 闭合标签缺失尖括号:<qqimg>url/qqimg>
90
+ * 6. 中文尖括号:<qqimg>url</qqimg> 或 <qqimg>url</qqimg>
91
+ * 7. 多余引号包裹路径:<qqimg>"path"</qqimg>
92
+ * 8. Markdown 代码块包裹:`<qqimg>path</qqimg>`
93
+ * 9. 自闭合属性语法:<qqmedia file="/path" /> (由 SELF_CLOSING_TAG_REGEX 处理)
94
+ */
95
+ const FUZZY_MEDIA_TAG_REGEX = new RegExp(
96
+ // 可选 Markdown 行内代码反引号
97
+ "`?" +
98
+ // 开头标签:允许中文/英文尖括号,标签名前后可有空格
99
+ "[<<<]\\s*(" + TAG_NAME_PATTERN + ")\\s*[>>>]" +
100
+ // 内容:非贪婪匹配,允许引号包裹
101
+ "[\"']?\\s*" +
102
+ "([^<<<>>\"'`]+?)" +
103
+ "\\s*[\"']?" +
104
+ // 闭合标签:允许各种不规范写法
105
+ "[<<<]\\s*/?\\s*(?:" + TAG_NAME_PATTERN + ")\\s*[>>>]" +
106
+ // 可选结尾反引号
107
+ "`?", "gi");
108
+ /**
109
+ * 将标签名映射为标准名称
110
+ */
111
+ function resolveTagName(raw) {
112
+ const lower = raw.toLowerCase();
113
+ if (VALID_TAGS.includes(lower)) {
114
+ return lower;
115
+ }
116
+ return TAG_ALIASES[lower] ?? "qqimg";
117
+ }
118
+ /**
119
+ * 预清理:将富媒体标签内部的换行/回车/制表符压缩为单个空格。
120
+ *
121
+ * 部分模型会在标签内部插入 \n \r \t 等空白字符,例如:
122
+ * <qqimg>\n /path/to/file.png\n</qqimg>
123
+ * <qqimg>/path/to/\nfile.png</qqimg>
124
+ *
125
+ * 此正则匹配从开标签到闭标签之间的内容(允许跨行),
126
+ * 将内部所有 [\r\n\t] 替换为空格,然后压缩连续空格。
127
+ */
128
+ const MULTILINE_TAG_CLEANUP = new RegExp("([<<<]\\s*(?:" + TAG_NAME_PATTERN + ")\\s*[>>>])" +
129
+ "([\\s\\S]*?)" +
130
+ "([<<<]\\s*/?\\s*(?:" + TAG_NAME_PATTERN + ")\\s*[>>>])", "gi");
131
+ /**
132
+ * 预处理 LLM 输出文本,将各种畸形/错误的富媒体标签修正为标准格式。
133
+ *
134
+ * 标准格式:<qqimg>/path/to/file</qqimg>
135
+ *
136
+ * @param text LLM 原始输出
137
+ * @returns 修正后的文本(如果没有匹配到任何标签则原样返回)
138
+ */
139
+ export function normalizeMediaTags(text) {
140
+ // 第 0 步:将自闭合属性语法转换为标准包裹语法
141
+ // <qqmedia file="/path/to/file.png" /> → <qqmedia>/path/to/file.png</qqmedia>
142
+ let cleaned = text.replace(SELF_CLOSING_TAG_REGEX, (_match, rawTag, content) => {
143
+ const tag = resolveTagName(rawTag);
144
+ const trimmed = content.trim();
145
+ if (!trimmed)
146
+ return _match;
147
+ const expanded = expandTilde(trimmed);
148
+ return `<${tag}>${expanded}</${tag}>`;
149
+ });
150
+ // 第 1 步:将标签内部的换行/回车/制表符压缩为空格
151
+ cleaned = cleaned.replace(MULTILINE_TAG_CLEANUP, (_m, open, body, close) => {
152
+ const flat = body.replace(/[\r\n\t]+/g, " ").replace(/ {2,}/g, " ");
153
+ return open + flat + close;
154
+ });
155
+ // 第 2 步:将各种畸形标签统一为标准格式
156
+ return cleaned.replace(FUZZY_MEDIA_TAG_REGEX, (_match, rawTag, content) => {
157
+ const tag = resolveTagName(rawTag);
158
+ const trimmed = content.trim();
159
+ if (!trimmed)
160
+ return _match; // 空内容不处理
161
+ // 展开波浪线路径:~/Desktop/file.png → /Users/xxx/Desktop/file.png
162
+ const expanded = expandTilde(trimmed);
163
+ return `<${tag}>${expanded}</${tag}>`;
164
+ });
165
+ }