@ryantest/openclaw-qqbot 0.0.3 → 1.6.6-alpha.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 (89) hide show
  1. package/README.md +2 -15
  2. package/README.zh.md +3 -16
  3. package/dist/src/admin-resolver.d.ts +12 -6
  4. package/dist/src/admin-resolver.js +69 -34
  5. package/dist/src/api.d.ts +105 -1
  6. package/dist/src/api.js +164 -15
  7. package/dist/src/channel.js +13 -0
  8. package/dist/src/config.js +3 -10
  9. package/dist/src/deliver-debounce.d.ts +74 -0
  10. package/dist/src/deliver-debounce.js +174 -0
  11. package/dist/src/gateway.js +450 -248
  12. package/dist/src/image-server.d.ts +27 -8
  13. package/dist/src/image-server.js +179 -71
  14. package/dist/src/inbound-attachments.d.ts +3 -1
  15. package/dist/src/inbound-attachments.js +28 -14
  16. package/dist/src/outbound-deliver.js +77 -148
  17. package/dist/src/outbound.d.ts +6 -4
  18. package/dist/src/outbound.js +266 -442
  19. package/dist/src/reply-dispatcher.js +4 -4
  20. package/dist/src/request-context.d.ts +18 -0
  21. package/dist/src/request-context.js +30 -0
  22. package/dist/src/slash-commands.js +277 -32
  23. package/dist/src/startup-greeting.d.ts +5 -5
  24. package/dist/src/startup-greeting.js +32 -13
  25. package/dist/src/streaming.d.ts +244 -0
  26. package/dist/src/streaming.js +907 -0
  27. package/dist/src/tools/remind.js +11 -10
  28. package/dist/src/types.d.ts +101 -0
  29. package/dist/src/types.js +17 -1
  30. package/dist/src/update-checker.js +2 -8
  31. package/dist/src/utils/audio-convert.d.ts +9 -0
  32. package/dist/src/utils/audio-convert.js +51 -0
  33. package/dist/src/utils/chunked-upload.d.ts +59 -0
  34. package/dist/src/utils/chunked-upload.js +289 -0
  35. package/dist/src/utils/file-utils.d.ts +7 -1
  36. package/dist/src/utils/file-utils.js +24 -2
  37. package/dist/src/utils/media-send.d.ts +147 -0
  38. package/dist/src/utils/media-send.js +434 -0
  39. package/dist/src/utils/pkg-version.d.ts +5 -0
  40. package/dist/src/utils/pkg-version.js +51 -0
  41. package/dist/src/utils/ssrf-guard.d.ts +25 -0
  42. package/dist/src/utils/ssrf-guard.js +91 -0
  43. package/node_modules/ws/index.js +15 -6
  44. package/node_modules/ws/lib/permessage-deflate.js +6 -6
  45. package/node_modules/ws/lib/websocket-server.js +5 -5
  46. package/node_modules/ws/lib/websocket.js +6 -6
  47. package/node_modules/ws/package.json +4 -3
  48. package/node_modules/ws/wrapper.mjs +14 -1
  49. package/openclaw.plugin.json +1 -0
  50. package/package.json +11 -22
  51. package/scripts/postinstall-link-sdk.js +113 -0
  52. package/scripts/upgrade-via-npm.ps1 +161 -6
  53. package/scripts/upgrade-via-npm.sh +311 -104
  54. package/scripts/upgrade-via-source.sh +117 -0
  55. package/skills/qqbot-media/SKILL.md +9 -5
  56. package/skills/qqbot-remind/SKILL.md +3 -3
  57. package/src/admin-resolver.ts +76 -35
  58. package/src/api.ts +284 -12
  59. package/src/channel.ts +12 -0
  60. package/src/config.ts +3 -10
  61. package/src/deliver-debounce.ts +229 -0
  62. package/src/gateway.ts +277 -67
  63. package/src/image-server.ts +213 -77
  64. package/src/inbound-attachments.ts +32 -15
  65. package/src/outbound-deliver.ts +77 -157
  66. package/src/outbound.ts +304 -451
  67. package/src/reply-dispatcher.ts +4 -4
  68. package/src/request-context.ts +39 -0
  69. package/src/slash-commands.ts +303 -33
  70. package/src/startup-greeting.ts +35 -13
  71. package/src/streaming.ts +1096 -0
  72. package/src/tools/remind.ts +15 -11
  73. package/src/types.ts +111 -0
  74. package/src/update-checker.ts +2 -7
  75. package/src/utils/audio-convert.ts +56 -0
  76. package/src/utils/chunked-upload.ts +419 -0
  77. package/src/utils/file-utils.ts +28 -2
  78. package/src/utils/media-send.ts +563 -0
  79. package/src/utils/pkg-version.ts +54 -0
  80. package/src/utils/ssrf-guard.ts +102 -0
  81. package/clawdbot.plugin.json +0 -16
  82. package/dist/src/user-messages.d.ts +0 -8
  83. package/dist/src/user-messages.js +0 -8
  84. package/moltbot.plugin.json +0 -16
  85. package/scripts/upgrade-via-alt-pkg.sh +0 -307
  86. package/src/bot-logs-2026-03-21T11-21-47(2).txt +0 -46
  87. package/src/gateway.log +0 -43
  88. package/src/openclaw-2026-03-21.log +0 -3729
  89. package/src/user-messages.ts +0 -7
@@ -4,8 +4,30 @@
4
4
  import * as fs from "node:fs";
5
5
  import * as path from "node:path";
6
6
  import crypto from "node:crypto";
7
- /** QQ Bot API 最大上传文件大小:20MB */
8
- export const MAX_UPLOAD_SIZE = 20 * 1024 * 1024;
7
+ /** QQ Bot API 各类型文件上传大小限制(QQ 机器人上行) */
8
+ export const UPLOAD_SIZE_LIMITS = {
9
+ 1: 30 * 1024 * 1024, // IMAGE: 30MB
10
+ 2: 200 * 1024 * 1024, // VIDEO: 200MB
11
+ 3: 20 * 1024 * 1024, // VOICE: 20MB
12
+ 4: 200 * 1024 * 1024, // FILE: 200MB
13
+ };
14
+ /** 文件类型中文名映射 */
15
+ const FILE_TYPE_NAMES = {
16
+ 1: "图片",
17
+ 2: "视频",
18
+ 3: "语音",
19
+ 4: "文件",
20
+ };
21
+ /** 获取文件类型的中文名称;未知类型返回 "文件" */
22
+ export function getFileTypeName(fileType) {
23
+ return FILE_TYPE_NAMES[fileType] ?? "文件";
24
+ }
25
+ /** 获取指定文件类型的上传大小限制;未知类型默认 200MB */
26
+ export function getMaxUploadSize(fileType) {
27
+ return UPLOAD_SIZE_LIMITS[fileType] ?? 200 * 1024 * 1024;
28
+ }
29
+ /** @deprecated 使用 getMaxUploadSize(fileType) 代替 */
30
+ export const MAX_UPLOAD_SIZE = 200 * 1024 * 1024;
9
31
  /** 大文件阈值(超过此值发送进度提示):5MB */
10
32
  export const LARGE_FILE_THRESHOLD = 5 * 1024 * 1024;
11
33
  /**
@@ -0,0 +1,147 @@
1
+ /**
2
+ * 富媒体标签解析与发送队列
3
+ *
4
+ * 提供媒体标签(qqimg / qqvoice / qqvideo / qqfile / qqmedia)的检测、
5
+ * 拆分、路径编码修复,以及统一的发送队列执行器。
6
+ */
7
+ import { type MediaTargetContext } from "../outbound.js";
8
+ import type { ResolvedQQBotAccount } from "../types.js";
9
+ /** 发送队列项 */
10
+ export interface SendQueueItem {
11
+ type: "text" | "image" | "voice" | "video" | "file" | "media";
12
+ content: string;
13
+ }
14
+ /** 统一的媒体标签正则 — 匹配标准化后的 6 种标签 */
15
+ export declare const MEDIA_TAG_REGEX: RegExp;
16
+ /** 创建一个新的全局标签正则实例(每次调用 reset lastIndex) */
17
+ export declare function createMediaTagRegex(): RegExp;
18
+ /** 媒体发送上下文(统一的,供流式和普通模式共用) */
19
+ export interface MediaSendContext {
20
+ /** 媒体目标上下文(用于 sendPhoto/sendVoice 等) */
21
+ mediaTarget: MediaTargetContext;
22
+ /** qualifiedTarget(格式 "qqbot:c2c:xxx" 或 "qqbot:group:xxx",用于 sendMediaAuto) */
23
+ qualifiedTarget: string;
24
+ /** 账户配置 */
25
+ account: ResolvedQQBotAccount;
26
+ /** 事件消息 ID(用于被动回复) */
27
+ replyToId?: string;
28
+ /** 日志 */
29
+ log?: {
30
+ info: (msg: string) => void;
31
+ error: (msg: string) => void;
32
+ debug?: (msg: string) => void;
33
+ };
34
+ }
35
+ /**
36
+ * 修复路径编码问题(双反斜杠、八进制转义、UTF-8 双重编码)
37
+ *
38
+ * 这是由于 LLM 输出路径时可能引入的编码问题:
39
+ * - Markdown 转义导致双反斜杠
40
+ * - 八进制转义序列(来自某些 shell 工具的输出)
41
+ * - UTF-8 双重编码(中文路径经过多层处理后的乱码)
42
+ *
43
+ * 此方法在 gateway.ts deliver 回调、outbound.ts sendText、
44
+ * streaming.ts sendMediaQueue 中共用。
45
+ */
46
+ export declare function fixPathEncoding(mediaPath: string, log?: {
47
+ debug?: (msg: string) => void;
48
+ error?: (msg: string) => void;
49
+ }): string;
50
+ /**
51
+ * 检测文本是否包含富媒体标签
52
+ */
53
+ export declare function hasMediaTags(text: string): boolean;
54
+ /** findFirstClosedMediaTag 的返回值 */
55
+ export interface FirstClosedMediaTag {
56
+ /** 标签前的纯文本(已 trim) */
57
+ textBefore: string;
58
+ /** 标签类型(小写,如 "qqvoice") */
59
+ tagName: string;
60
+ /** 标签内的媒体路径(已 trim、去 MEDIA: 前缀、修复编码) */
61
+ mediaPath: string;
62
+ /** 标签在输入文本中的结束索引(紧接标签后的第一个字符位置) */
63
+ tagEndIndex: number;
64
+ /** 映射后的发送队列项类型 */
65
+ itemType: SendQueueItem["type"];
66
+ }
67
+ /**
68
+ * 在文本中查找**第一个**完整闭合的媒体标签
69
+ *
70
+ * 与 splitByMediaTags 不同,此函数只匹配一个标签就停止,
71
+ * 用于流式场景的"循环消费"模式:每次处理一个标签,更新偏移,再找下一个。
72
+ *
73
+ * @param text 待检查的文本(应已 normalize 过)
74
+ * @returns 第一个闭合标签的信息,没有则返回 null
75
+ */
76
+ export declare function findFirstClosedMediaTag(text: string, log?: {
77
+ info?: (msg: string) => void;
78
+ debug?: (msg: string) => void;
79
+ error?: (msg: string) => void;
80
+ }): FirstClosedMediaTag | null;
81
+ /**
82
+ * 媒体标签拆分结果
83
+ */
84
+ export interface MediaSplitResult {
85
+ /** 是否包含媒体标签 */
86
+ hasMediaTags: boolean;
87
+ /** 媒体标签前的纯文本 */
88
+ textBeforeFirstTag: string;
89
+ /** 媒体标签后的剩余文本 */
90
+ textAfterLastTag: string;
91
+ /** 完整的发送队列(标签间的文本 + 媒体项) */
92
+ mediaQueue: SendQueueItem[];
93
+ }
94
+ /**
95
+ * 将文本按富媒体标签拆分为三部分
96
+ *
97
+ * 用于两个场景:
98
+ * 1. 流式模式:中断-恢复流程(标签前文本 → 结束流式 → 发送媒体 → 新流式 → 标签后文本)
99
+ * 2. 普通模式:构建按顺序发送的队列
100
+ */
101
+ export declare function splitByMediaTags(text: string, log?: {
102
+ info?: (msg: string) => void;
103
+ debug?: (msg: string) => void;
104
+ error?: (msg: string) => void;
105
+ }): MediaSplitResult;
106
+ /**
107
+ * 从文本中解析出完整的发送队列(含标签前后的纯文本)
108
+ *
109
+ * 与 splitByMediaTags 的区别:
110
+ * - splitByMediaTags 分为 before / queue / after 三段(供流式模式的中断-恢复)
111
+ * - parseMediaTagsToSendQueue 返回一个扁平的完整队列(供普通模式按顺序发送)
112
+ *
113
+ * 适用于 gateway.ts deliver 回调和 outbound.ts sendText。
114
+ */
115
+ export declare function parseMediaTagsToSendQueue(text: string, log?: {
116
+ info?: (msg: string) => void;
117
+ debug?: (msg: string) => void;
118
+ error?: (msg: string) => void;
119
+ }): {
120
+ hasMediaTags: boolean;
121
+ sendQueue: SendQueueItem[];
122
+ };
123
+ /**
124
+ * 统一执行发送队列
125
+ *
126
+ * 遍历 sendQueue,按类型调用对应的发送函数。
127
+ * 文本项通过 onSendText 回调处理(不同场景的文本发送方式不同)。
128
+ */
129
+ export declare function executeSendQueue(queue: SendQueueItem[], ctx: MediaSendContext, options?: {
130
+ /** 文本发送回调(每种场景的文本发送方式不同) */
131
+ onSendText?: (text: string) => Promise<void>;
132
+ /** 是否跳过 inter-tag 文本(流式模式下通常跳过,由新流式会话处理) */
133
+ skipInterTagText?: boolean;
134
+ }): Promise<void>;
135
+ /**
136
+ * 从文本中剥离所有媒体标签(用于最终显示)
137
+ */
138
+ export declare function stripMediaTags(text: string): string;
139
+ /**
140
+ * 检测文本中是否有未闭合的媒体标签,如果有则截断到安全位置。
141
+ *
142
+ * 流式输出中 LLM 逐 token 吐出媒体标签,中间态不应直接发给用户。
143
+ * 只检查最后一行,从右到左扫描 `<`,找到第一个有意义的媒体标签片段并判断是否完整。
144
+ *
145
+ * 核心原则:截断只能截到**开标签**前面;闭合标签前缀若找不到对应开标签则原样返回。
146
+ */
147
+ export declare function stripIncompleteMediaTag(text: string): [safeText: string, hasIncomplete: boolean];
@@ -0,0 +1,434 @@
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
+ export function hasMediaTags(text) {
72
+ const normalized = normalizeMediaTags(text);
73
+ const regex = createMediaTagRegex();
74
+ return regex.test(normalized);
75
+ }
76
+ /**
77
+ * 在文本中查找**第一个**完整闭合的媒体标签
78
+ *
79
+ * 与 splitByMediaTags 不同,此函数只匹配一个标签就停止,
80
+ * 用于流式场景的"循环消费"模式:每次处理一个标签,更新偏移,再找下一个。
81
+ *
82
+ * @param text 待检查的文本(应已 normalize 过)
83
+ * @returns 第一个闭合标签的信息,没有则返回 null
84
+ */
85
+ export function findFirstClosedMediaTag(text, log) {
86
+ const regex = createMediaTagRegex();
87
+ const match = regex.exec(text);
88
+ if (!match)
89
+ return null;
90
+ const textBefore = text.slice(0, match.index).replace(/\n{3,}/g, "\n\n").trim();
91
+ const tagName = match[1].toLowerCase();
92
+ let mediaPath = match[2]?.trim() ?? "";
93
+ // 剥离 MEDIA: 前缀
94
+ if (mediaPath.startsWith("MEDIA:")) {
95
+ mediaPath = mediaPath.slice("MEDIA:".length);
96
+ }
97
+ mediaPath = normalizePath(mediaPath);
98
+ mediaPath = fixPathEncoding(mediaPath, log);
99
+ const typeMap = {
100
+ qqimg: "image",
101
+ qqvoice: "voice",
102
+ qqvideo: "video",
103
+ qqfile: "file",
104
+ qqmedia: "media",
105
+ };
106
+ return {
107
+ textBefore,
108
+ tagName,
109
+ mediaPath,
110
+ tagEndIndex: match.index + match[0].length,
111
+ itemType: typeMap[tagName] ?? "image",
112
+ };
113
+ }
114
+ /**
115
+ * 将文本按富媒体标签拆分为三部分
116
+ *
117
+ * 用于两个场景:
118
+ * 1. 流式模式:中断-恢复流程(标签前文本 → 结束流式 → 发送媒体 → 新流式 → 标签后文本)
119
+ * 2. 普通模式:构建按顺序发送的队列
120
+ */
121
+ export function splitByMediaTags(text, log) {
122
+ const normalized = normalizeMediaTags(text);
123
+ const regex = createMediaTagRegex();
124
+ const matches = [...normalized.matchAll(regex)];
125
+ if (matches.length === 0) {
126
+ return {
127
+ hasMediaTags: false,
128
+ textBeforeFirstTag: normalized,
129
+ textAfterLastTag: "",
130
+ mediaQueue: [],
131
+ };
132
+ }
133
+ // 第一个标签前的纯文本
134
+ const firstMatch = matches[0];
135
+ const textBeforeFirstTag = normalized
136
+ .slice(0, firstMatch.index)
137
+ .replace(/\n{3,}/g, "\n\n")
138
+ .trim();
139
+ // 最后一个标签后的纯文本
140
+ const lastMatch = matches[matches.length - 1];
141
+ const lastMatchEnd = lastMatch.index + lastMatch[0].length;
142
+ const textAfterLastTag = normalized
143
+ .slice(lastMatchEnd)
144
+ .replace(/\n{3,}/g, "\n\n")
145
+ .trim();
146
+ // 构建媒体发送队列
147
+ const mediaQueue = [];
148
+ let lastIndex = firstMatch.index;
149
+ for (const match of matches) {
150
+ // 标签前的文本(标签之间的间隔文本)
151
+ const textBetween = normalized
152
+ .slice(lastIndex, match.index)
153
+ .replace(/\n{3,}/g, "\n\n")
154
+ .trim();
155
+ if (textBetween && lastIndex !== firstMatch.index) {
156
+ // 只添加非首段的间隔文本(首段由 textBeforeFirstTag 覆盖)
157
+ mediaQueue.push({ type: "text", content: textBetween });
158
+ }
159
+ // 解析标签内容
160
+ const tagName = match[1].toLowerCase();
161
+ let mediaPath = match[2]?.trim() ?? "";
162
+ // 剥离 MEDIA: 前缀
163
+ if (mediaPath.startsWith("MEDIA:")) {
164
+ mediaPath = mediaPath.slice("MEDIA:".length);
165
+ }
166
+ mediaPath = normalizePath(mediaPath);
167
+ // 修复路径编码问题
168
+ mediaPath = fixPathEncoding(mediaPath, log);
169
+ // 根据标签类型加入队列
170
+ const typeMap = {
171
+ qqimg: "image",
172
+ qqvoice: "voice",
173
+ qqvideo: "video",
174
+ qqfile: "file",
175
+ qqmedia: "media",
176
+ };
177
+ const itemType = typeMap[tagName] ?? "image";
178
+ if (mediaPath) {
179
+ mediaQueue.push({ type: itemType, content: mediaPath });
180
+ log?.info?.(`Found ${itemType} in <${tagName}>: ${mediaPath.slice(0, 80)}`);
181
+ }
182
+ lastIndex = match.index + match[0].length;
183
+ }
184
+ return {
185
+ hasMediaTags: true,
186
+ textBeforeFirstTag,
187
+ textAfterLastTag,
188
+ mediaQueue,
189
+ };
190
+ }
191
+ /**
192
+ * 从文本中解析出完整的发送队列(含标签前后的纯文本)
193
+ *
194
+ * 与 splitByMediaTags 的区别:
195
+ * - splitByMediaTags 分为 before / queue / after 三段(供流式模式的中断-恢复)
196
+ * - parseMediaTagsToSendQueue 返回一个扁平的完整队列(供普通模式按顺序发送)
197
+ *
198
+ * 适用于 gateway.ts deliver 回调和 outbound.ts sendText。
199
+ */
200
+ export function parseMediaTagsToSendQueue(text, log) {
201
+ const split = splitByMediaTags(text, log);
202
+ if (!split.hasMediaTags) {
203
+ return { hasMediaTags: false, sendQueue: [] };
204
+ }
205
+ const sendQueue = [];
206
+ // 标签前的文本
207
+ if (split.textBeforeFirstTag) {
208
+ sendQueue.push({ type: "text", content: split.textBeforeFirstTag });
209
+ }
210
+ // 媒体队列(含标签间文本)
211
+ sendQueue.push(...split.mediaQueue);
212
+ // 标签后的文本
213
+ if (split.textAfterLastTag) {
214
+ sendQueue.push({ type: "text", content: split.textAfterLastTag });
215
+ }
216
+ return { hasMediaTags: true, sendQueue };
217
+ }
218
+ // ============ 发送队列执行 ============
219
+ /**
220
+ * 统一执行发送队列
221
+ *
222
+ * 遍历 sendQueue,按类型调用对应的发送函数。
223
+ * 文本项通过 onSendText 回调处理(不同场景的文本发送方式不同)。
224
+ */
225
+ export async function executeSendQueue(queue, ctx, options = {}) {
226
+ const { mediaTarget, qualifiedTarget, account, replyToId, log } = ctx;
227
+ const prefix = mediaTarget.logPrefix ?? `[qqbot:${account.accountId}]`;
228
+ for (const item of queue) {
229
+ try {
230
+ if (item.type === "text") {
231
+ if (options.skipInterTagText) {
232
+ log?.info(`${prefix} executeSendQueue: skipping inter-tag text (${item.content.length} chars)`);
233
+ continue;
234
+ }
235
+ if (options.onSendText) {
236
+ await options.onSendText(item.content);
237
+ }
238
+ else {
239
+ log?.info(`${prefix} executeSendQueue: no onSendText handler, skipping text`);
240
+ }
241
+ continue;
242
+ }
243
+ log?.info(`${prefix} executeSendQueue: sending ${item.type}: ${item.content.slice(0, 80)}...`);
244
+ if (item.type === "image") {
245
+ const result = await sendPhoto(mediaTarget, item.content);
246
+ if (result.error) {
247
+ log?.error(`${prefix} sendPhoto error: ${result.error}`);
248
+ }
249
+ }
250
+ else if (item.type === "voice") {
251
+ const uploadFormats = account.config?.audioFormatPolicy?.uploadDirectFormats ??
252
+ account.config?.voiceDirectUploadFormats;
253
+ const transcodeEnabled = account.config?.audioFormatPolicy?.transcodeEnabled !== false;
254
+ const voiceTimeout = 45000; // 45s
255
+ try {
256
+ const result = await Promise.race([
257
+ sendVoice(mediaTarget, item.content, uploadFormats, transcodeEnabled),
258
+ new Promise((resolve) => setTimeout(() => resolve({ channel: "qqbot", error: "语音发送超时,已跳过" }), voiceTimeout)),
259
+ ]);
260
+ if (result.error) {
261
+ log?.error(`${prefix} sendVoice error: ${result.error}`);
262
+ }
263
+ }
264
+ catch (err) {
265
+ log?.error(`${prefix} sendVoice unexpected error: ${err}`);
266
+ }
267
+ }
268
+ else if (item.type === "video") {
269
+ const result = await sendVideoMsg(mediaTarget, item.content);
270
+ if (result.error) {
271
+ log?.error(`${prefix} sendVideoMsg error: ${result.error}`);
272
+ }
273
+ }
274
+ else if (item.type === "file") {
275
+ const result = await sendDocument(mediaTarget, item.content);
276
+ if (result.error) {
277
+ log?.error(`${prefix} sendDocument error: ${result.error}`);
278
+ }
279
+ }
280
+ else if (item.type === "media") {
281
+ const result = await sendMediaAuto({
282
+ to: qualifiedTarget,
283
+ text: "",
284
+ mediaUrl: item.content,
285
+ accountId: account.accountId,
286
+ replyToId,
287
+ account,
288
+ });
289
+ if (result.error) {
290
+ log?.error(`${prefix} sendMedia(auto) error: ${result.error}`);
291
+ }
292
+ }
293
+ }
294
+ catch (err) {
295
+ log?.error(`${prefix} executeSendQueue: failed to send ${item.type}: ${err}`);
296
+ }
297
+ }
298
+ }
299
+ /**
300
+ * 从文本中剥离所有媒体标签(用于最终显示)
301
+ */
302
+ export function stripMediaTags(text) {
303
+ const regex = createMediaTagRegex();
304
+ return text.replace(regex, "").replace(/\n{3,}/g, "\n\n").trim();
305
+ }
306
+ /**
307
+ * 检测文本中是否有未闭合的媒体标签,如果有则截断到安全位置。
308
+ *
309
+ * 流式输出中 LLM 逐 token 吐出媒体标签,中间态不应直接发给用户。
310
+ * 只检查最后一行,从右到左扫描 `<`,找到第一个有意义的媒体标签片段并判断是否完整。
311
+ *
312
+ * 核心原则:截断只能截到**开标签**前面;闭合标签前缀若找不到对应开标签则原样返回。
313
+ */
314
+ export function stripIncompleteMediaTag(text) {
315
+ if (!text)
316
+ return [text, false];
317
+ const lastNL = text.lastIndexOf("\n");
318
+ const lastLine = lastNL === -1 ? text : text.slice(lastNL + 1);
319
+ if (!lastLine)
320
+ return [text, false]; // 以换行结尾,安全
321
+ const lineStart = lastNL === -1 ? 0 : lastNL + 1;
322
+ // ---- 媒体标签名判断 ----
323
+ const MEDIA_NAMES = [
324
+ "qq", "img", "image", "pic", "photo", "voice", "audio", "video",
325
+ "file", "doc", "media", "attach", "send", "document", "picture",
326
+ "qqvoice", "qqaudio", "qqvideo", "qqimg", "qqimage", "qqfile",
327
+ "qqpic", "qqphoto", "qqmedia", "qqattach", "qqsend", "qqdocument", "qqpicture",
328
+ ];
329
+ const isMedia = (n) => MEDIA_NAMES.includes(n.toLowerCase());
330
+ const couldBeMedia = (n) => { const l = n.toLowerCase(); return MEDIA_NAMES.some(m => m.startsWith(l)); };
331
+ /** 截断到 lastLine 中位置 pos 之前,返回 [safe, true] */
332
+ const cutAt = (pos) => [
333
+ text.slice(0, lineStart + pos).trimEnd(),
334
+ true,
335
+ ];
336
+ /** 检查 lastLine 中位置 pos 处的媒体开标签后面是否有完整闭合标签 */
337
+ const hasClosingAfter = (pos, name) => {
338
+ const rest = lastLine.slice(pos + 1); // < 之后
339
+ const gt = rest.search(/[>>]/);
340
+ if (gt < 0)
341
+ return false;
342
+ const after = rest.slice(gt + 1);
343
+ return new RegExp(`[<\uFF1C]/${name}\\s*[>\uFF1E]`, "i").test(after);
344
+ };
345
+ // ---- 回溯状态 ----
346
+ // 遇到不完整的闭合标签/孤立 < 时,记录并继续往左找对应的开标签
347
+ let searchTag = null; // 要找的开标签名,"*" = 来自孤立 <
348
+ let searchIsClosing = false; // 触发回溯的是闭合类(</、</tag)还是开类(<)
349
+ let fallbackPos = -1; // 最右边触发回溯的 < 的位置
350
+ for (let i = lastLine.length - 1; i >= 0; i--) {
351
+ const ch = lastLine[i];
352
+ if (ch !== "<" && ch !== "\uFF1C")
353
+ continue;
354
+ const after = lastLine.slice(i + 1);
355
+ const isClosing = after.startsWith("/");
356
+ const nameStr = isClosing ? after.slice(1) : after;
357
+ const nameMatch = nameStr.match(/^(\w+)/);
358
+ // ======== 回溯模式:正在找对应的开标签 ========
359
+ if (searchTag) {
360
+ if (!nameMatch || isClosing)
361
+ continue;
362
+ const cand = nameMatch[1].toLowerCase();
363
+ if (!isMedia(cand))
364
+ continue;
365
+ // 跳过已有完整闭合对的开标签
366
+ if (hasClosingAfter(i, cand))
367
+ continue;
368
+ if (searchTag === "*") {
369
+ return cutAt(i); // 通配:任何未闭合的媒体开标签都匹配
370
+ }
371
+ // 精确/前缀匹配(闭合标签名可能不完整,如 </qq 对 <qqvoice)
372
+ const t = searchTag.toLowerCase();
373
+ if (cand === t || cand.startsWith(t))
374
+ return cutAt(i);
375
+ continue;
376
+ }
377
+ // ======== 正常扫描 ========
378
+ // --- 无标签名:孤立 < 或 </ ---
379
+ if (!nameMatch) {
380
+ if (!after) {
381
+ // 孤立 <:可能是新开标签,往左找未闭合的媒体开标签
382
+ if (fallbackPos < 0)
383
+ fallbackPos = i;
384
+ searchTag = "*";
385
+ searchIsClosing = false;
386
+ }
387
+ else if (after === "/") {
388
+ // 孤立 </:闭合标签开始,找不到开标签时原样返回
389
+ if (fallbackPos < 0)
390
+ fallbackPos = i;
391
+ searchTag = "*";
392
+ searchIsClosing = true;
393
+ }
394
+ // 其他(如 "< 3"):非标签,跳过
395
+ continue;
396
+ }
397
+ const tag = nameMatch[1];
398
+ const restAfterName = nameStr.slice(tag.length);
399
+ const hasGT = /[>>]/.test(restAfterName);
400
+ // --- 不是媒体标签(也不是前缀) ---
401
+ if (!isMedia(tag) && !(couldBeMedia(tag) && !hasGT))
402
+ continue;
403
+ // --- 标签未闭合(无 >),还在输入中 ---
404
+ if (!hasGT) {
405
+ if (isClosing) {
406
+ // 不完整闭合标签(如 </voice、</i)→ 回溯找开标签
407
+ if (fallbackPos < 0)
408
+ fallbackPos = i;
409
+ searchTag = tag;
410
+ searchIsClosing = true;
411
+ continue;
412
+ }
413
+ // 不完整开标签(如 <img、<i)→ 截断
414
+ return cutAt(i);
415
+ }
416
+ // --- 标签有 >,是完整的 ---
417
+ if (isClosing)
418
+ return [text, false]; // 完整闭合标签 </tag> → 安全
419
+ // 完整开标签 <tag...>,检查后面有无对应 </tag>
420
+ if (hasClosingAfter(i, tag))
421
+ return [text, false];
422
+ return cutAt(i); // 无闭合 → 截断
423
+ }
424
+ // ---- 循环结束,处理回溯未命中 ----
425
+ if (searchTag) {
426
+ if (!searchIsClosing) {
427
+ // 来自孤立 <,前面没有媒体开标签 → 截断到那个 < 前面
428
+ return cutAt(fallbackPos);
429
+ }
430
+ // 来自闭合类(</、</tag),前面找不到对应开标签 → 不可能是有效媒体标签,原样返回
431
+ return [text, true];
432
+ }
433
+ return [text, false]; // 最后一行无任何 < → 安全
434
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * 从 import.meta.url 向上遍历目录树查找 package.json 并读取 version。
3
+ * 不依赖硬编码的 "../" 层级,无论编译输出结构如何变化都能可靠找到。
4
+ */
5
+ export declare function getPackageVersion(metaUrl?: string): string;
@@ -0,0 +1,51 @@
1
+ /**
2
+ * 从 import.meta.url 向上遍历目录树查找 package.json 并读取 version。
3
+ * 不依赖硬编码的 "../" 层级,无论编译输出结构如何变化都能可靠找到。
4
+ */
5
+ import { fileURLToPath } from "node:url";
6
+ import { createRequire } from "node:module";
7
+ import path from "node:path";
8
+ import fs from "node:fs";
9
+ let _cached = null;
10
+ export function getPackageVersion(metaUrl) {
11
+ if (_cached !== null)
12
+ return _cached;
13
+ // Strategy 1: 从调用者的 import.meta.url(或本模块)向上遍历找 package.json
14
+ const startFile = metaUrl ? fileURLToPath(metaUrl) : fileURLToPath(import.meta.url);
15
+ let dir = path.dirname(startFile);
16
+ const root = path.parse(dir).root;
17
+ while (dir !== root) {
18
+ const candidate = path.join(dir, "package.json");
19
+ try {
20
+ if (fs.existsSync(candidate)) {
21
+ const pkg = JSON.parse(fs.readFileSync(candidate, "utf8"));
22
+ // 确认是我们自己的包(避免找到其他 package.json)
23
+ if (pkg.name === "@tencent-connect/openclaw-qqbot" && pkg.version) {
24
+ _cached = pkg.version;
25
+ return _cached;
26
+ }
27
+ }
28
+ }
29
+ catch {
30
+ // ignore and try parent
31
+ }
32
+ dir = path.dirname(dir);
33
+ }
34
+ // Strategy 2: fallback 用 createRequire 尝试常见相对路径
35
+ try {
36
+ const require = createRequire(metaUrl ?? import.meta.url);
37
+ for (const rel of ["../../package.json", "../package.json", "./package.json"]) {
38
+ try {
39
+ const pkg = require(rel);
40
+ if (pkg?.version) {
41
+ _cached = pkg.version;
42
+ return _cached;
43
+ }
44
+ }
45
+ catch { /* next */ }
46
+ }
47
+ }
48
+ catch { /* fallback */ }
49
+ _cached = "unknown";
50
+ return _cached;
51
+ }