@cxyhhhhh/openclaw-qqbot 1.6.7-alpha.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 (218) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +470 -0
  3. package/README.zh.md +465 -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 +264 -0
  10. package/dist/src/api.js +777 -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 +2021 -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 +86 -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 +926 -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 +70 -0
  42. package/dist/src/ref-index-store.js +250 -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 +18 -0
  46. package/dist/src/request-context.js +30 -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 +1461 -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 +250 -0
  56. package/dist/src/streaming.js +914 -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 +248 -0
  63. package/dist/src/types.d.ts +364 -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 +34 -0
  68. package/dist/src/update-checker.js +160 -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 +59 -0
  72. package/dist/src/utils/chunked-upload.js +289 -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 +148 -0
  78. package/dist/src/utils/media-send.js +456 -0
  79. package/dist/src/utils/media-tags.d.ts +14 -0
  80. package/dist/src/utils/media-tags.js +164 -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 +51 -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 +32 -0
  90. package/dist/src/utils/text-parsing.js +69 -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 +13 -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 +69 -0
  156. package/node_modules/ws/wrapper.mjs +8 -0
  157. package/openclaw.plugin.json +17 -0
  158. package/package.json +67 -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 +113 -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 +451 -0
  168. package/scripts/upgrade-via-npm.sh +528 -0
  169. package/scripts/upgrade-via-source.sh +916 -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 +149 -0
  174. package/src/admin-resolver.ts +181 -0
  175. package/src/api.ts +1138 -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 +2257 -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 +349 -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 +1119 -0
  191. package/src/proactive.ts +530 -0
  192. package/src/ref-index-store.ts +335 -0
  193. package/src/reply-dispatcher.ts +334 -0
  194. package/src/request-context.ts +39 -0
  195. package/src/runtime.ts +14 -0
  196. package/src/session-store.ts +303 -0
  197. package/src/slash-commands.ts +1615 -0
  198. package/src/startup-greeting.ts +120 -0
  199. package/src/streaming.ts +1102 -0
  200. package/src/stt.ts +86 -0
  201. package/src/tools/channel.ts +281 -0
  202. package/src/tools/remind.ts +300 -0
  203. package/src/types.ts +386 -0
  204. package/src/typing-keepalive.ts +59 -0
  205. package/src/update-checker.ts +174 -0
  206. package/src/utils/audio-convert.ts +859 -0
  207. package/src/utils/chunked-upload.ts +419 -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 +585 -0
  211. package/src/utils/media-tags.ts +182 -0
  212. package/src/utils/payload.ts +265 -0
  213. package/src/utils/pkg-version.ts +54 -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 +75 -0
  217. package/src/utils/upload-cache.ts +128 -0
  218. package/tsconfig.json +16 -0
@@ -0,0 +1,107 @@
1
+ /**
2
+ * 群消息门控 — 统一入口。
3
+ *
4
+ * 将 ignoreOtherMentions / shouldBlock / mentionGating 三层判断收敛到
5
+ * 一个纯函数 resolveGroupMessageGate() 中,让 gateway 主流程只关心一个结果。
6
+ *
7
+ * 按优先级串行检查:
8
+ * 1. ignoreOtherMentions — @了其他人但未 @bot → 丢弃(记历史)
9
+ * 2. shouldBlock — 未授权控制命令静默拦截
10
+ * 3. mentionGating — requireMention 门控 + 命令旁路
11
+ */
12
+ // ────────────────────── Core Logic ──────────────────────
13
+ /**
14
+ * 基础 mention 门控纯函数。
15
+ * effectiveWasMentioned = wasMentioned || implicitMention || shouldBypassMention
16
+ * shouldSkip = requireMention && canDetectMention && !effectiveWasMentioned
17
+ */
18
+ function resolveMentionGating(params) {
19
+ const implicit = params.implicitMention === true;
20
+ const bypass = params.shouldBypassMention === true;
21
+ const effectiveWasMentioned = params.wasMentioned || implicit || bypass;
22
+ const shouldSkip = params.requireMention && params.canDetectMention && !effectiveWasMentioned;
23
+ return { effectiveWasMentioned, shouldSkip };
24
+ }
25
+ /**
26
+ * 带命令旁路的 mention 门控。
27
+ *
28
+ * shouldBypassMention 条件(全部满足时才旁路):
29
+ * 1. requireMention — 开启了 mention 要求
30
+ * 2. !wasMentioned — 没有被直接 mention
31
+ * 3. !hasAnyMention — 消息中没有任何 @(防止 @ 其他人的消息误 bypass)
32
+ * 4. allowTextCommands — 文本命令已启用
33
+ * 5. commandAuthorized — 发送者有命令权限
34
+ * 6. hasControlCommand — 消息是合法控制命令
35
+ */
36
+ function resolveMentionGatingWithBypass(params) {
37
+ const shouldBypassMention = params.requireMention &&
38
+ !params.wasMentioned &&
39
+ !(params.hasAnyMention ?? false) &&
40
+ params.allowTextCommands &&
41
+ params.commandAuthorized &&
42
+ params.hasControlCommand;
43
+ return {
44
+ ...resolveMentionGating({
45
+ requireMention: params.requireMention,
46
+ canDetectMention: params.canDetectMention,
47
+ wasMentioned: params.wasMentioned,
48
+ implicitMention: params.implicitMention,
49
+ shouldBypassMention,
50
+ }),
51
+ shouldBypassMention,
52
+ };
53
+ }
54
+ // ────────────────────── Unified Gate ──────────────────────
55
+ /**
56
+ * 群消息统一门控,按优先级串行判定:
57
+ *
58
+ * 1. ignoreOtherMentions — @了其他人但未 @bot → drop_other_mention
59
+ * 2. shouldBlock — 未授权控制命令 → block_unauthorized_command
60
+ * 3. mentionGating — 未满足 @bot 条件 → skip_no_mention
61
+ * 4. 通过所有检查 → pass
62
+ */
63
+ export function resolveGroupMessageGate(params) {
64
+ const { ignoreOtherMentions, hasAnyMention, wasMentioned, implicitMention, allowTextCommands, isControlCommand, commandAuthorized, requireMention, canDetectMention, } = params;
65
+ // ── Layer 1: ignoreOtherMentions ──
66
+ if (ignoreOtherMentions &&
67
+ hasAnyMention &&
68
+ !wasMentioned &&
69
+ !implicitMention) {
70
+ return {
71
+ action: "drop_other_mention",
72
+ effectiveWasMentioned: false,
73
+ shouldBypassMention: false,
74
+ };
75
+ }
76
+ // ── Layer 2: shouldBlock 未授权控制命令 ──
77
+ if (allowTextCommands && isControlCommand && !commandAuthorized) {
78
+ return {
79
+ action: "block_unauthorized_command",
80
+ effectiveWasMentioned: false,
81
+ shouldBypassMention: false,
82
+ };
83
+ }
84
+ // ── Layer 3: mention 门控 + 命令旁路 ──
85
+ const mentionGate = resolveMentionGatingWithBypass({
86
+ requireMention,
87
+ canDetectMention,
88
+ wasMentioned,
89
+ implicitMention,
90
+ hasAnyMention,
91
+ allowTextCommands,
92
+ hasControlCommand: isControlCommand,
93
+ commandAuthorized,
94
+ });
95
+ if (mentionGate.shouldSkip) {
96
+ return {
97
+ action: "skip_no_mention",
98
+ effectiveWasMentioned: mentionGate.effectiveWasMentioned,
99
+ shouldBypassMention: mentionGate.shouldBypassMention,
100
+ };
101
+ }
102
+ return {
103
+ action: "pass",
104
+ effectiveWasMentioned: mentionGate.effectiveWasMentioned,
105
+ shouldBypassMention: mentionGate.shouldBypassMention,
106
+ };
107
+ }
@@ -0,0 +1,86 @@
1
+ import type { QueueSnapshot } from "./slash-commands.js";
2
+ /**
3
+ * 消息队列项类型(用于异步处理消息,防止阻塞心跳)
4
+ */
5
+ export interface QueuedMessage {
6
+ type: "c2c" | "guild" | "dm" | "group";
7
+ senderId: string;
8
+ senderName?: string;
9
+ content: string;
10
+ messageId: string;
11
+ timestamp: string;
12
+ channelId?: string;
13
+ guildId?: string;
14
+ groupOpenid?: string;
15
+ attachments?: Array<{
16
+ content_type: string;
17
+ url: string;
18
+ filename?: string;
19
+ voice_wav_url?: string;
20
+ asr_refer_text?: string;
21
+ }>;
22
+ /** 被引用消息的 refIdx(用户引用了哪条历史消息) */
23
+ refMsgIdx?: string;
24
+ /** 当前消息自身的 refIdx(供将来被引用) */
25
+ msgIdx?: string;
26
+ /** 事件类型(如 GROUP_AT_MESSAGE_CREATE),用于群消息合并时判断是否有 @ */
27
+ eventType?: string;
28
+ /** 发送者是否为机器人 */
29
+ senderIsBot?: boolean;
30
+ /** @ 提及列表(群消息合并时需要去重合并) */
31
+ mentions?: Array<{
32
+ scope?: "all" | "single";
33
+ id?: string;
34
+ user_openid?: string;
35
+ member_openid?: string;
36
+ username?: string;
37
+ bot?: boolean;
38
+ is_you?: boolean;
39
+ }>;
40
+ /** 消息场景(来源、扩展字段) */
41
+ messageScene?: {
42
+ source?: string;
43
+ ext?: string[];
44
+ };
45
+ /** 群消息合并标记:记录合并了多少条原始消息 */
46
+ _mergedCount?: number;
47
+ /** 合并前的原始消息列表(用于 gateway 侧逐条格式化信封) */
48
+ _mergedMessages?: QueuedMessage[];
49
+ }
50
+ export interface MessageQueueContext {
51
+ accountId: string;
52
+ log?: {
53
+ info: (msg: string) => void;
54
+ error: (msg: string) => void;
55
+ debug?: (msg: string) => void;
56
+ };
57
+ /** 外部提供的 abort 状态检查 */
58
+ isAborted: () => boolean;
59
+ /** 群聊队列上限(默认 50) */
60
+ groupQueueSize?: number;
61
+ /** 私聊/DM 队列上限(默认 20) */
62
+ peerQueueSize?: number;
63
+ /** 全局队列总量上限(默认 1000) */
64
+ globalQueueSize?: number;
65
+ /** 最大并发处理用户数(默认 10) */
66
+ maxConcurrentUsers?: number;
67
+ }
68
+ export interface MessageQueue {
69
+ enqueue: (msg: QueuedMessage) => void;
70
+ startProcessor: (handleMessageFn: (msg: QueuedMessage) => Promise<void>) => void;
71
+ getSnapshot: (senderPeerId: string) => QueueSnapshot;
72
+ getMessagePeerId: (msg: QueuedMessage) => string;
73
+ /** 清空指定用户的排队消息,返回被丢弃的消息数 */
74
+ clearUserQueue: (peerId: string) => number;
75
+ /** 立即执行一条消息(绕过队列),用于紧急命令 */
76
+ executeImmediate: (msg: QueuedMessage) => void;
77
+ }
78
+ /**
79
+ * 创建按用户并发的消息队列(同用户串行,跨用户并行)
80
+ *
81
+ * 内置群消息增强:
82
+ * - 群聊 / 私聊使用不同队列上限
83
+ * - 群聊溢出时优先丢弃 bot 消息
84
+ * - drain 时自动合并群聊排队消息(斜杠命令单独处理)
85
+ */
86
+ export declare function createMessageQueue(ctx: MessageQueueContext): MessageQueue;
@@ -0,0 +1,257 @@
1
+ // ── 消息队列默认配置 ──
2
+ const DEFAULT_GLOBAL_QUEUE_SIZE = 1000;
3
+ const DEFAULT_PER_PEER_QUEUE_SIZE = 20;
4
+ const DEFAULT_GROUP_QUEUE_SIZE = 50;
5
+ const DEFAULT_MAX_CONCURRENT_USERS = 10;
6
+ // ── 群消息合并工具函数 ──
7
+ /** 判断 peerId 是否属于群聊 */
8
+ const isGroupPeer = (peerId) => peerId.startsWith("group:") || peerId.startsWith("guild:");
9
+ /**
10
+ * 将多条群消息合并为一条,用于群聊场景下排队消息的批量处理。
11
+ * - content 拼接为多行,每行带发送者前缀
12
+ * - 附件合并
13
+ * - messageId / msgIdx / timestamp 取最后一条(用于回复引用)
14
+ * - mentions 合并去重
15
+ * - 如果有任意一条 @了你(is_you),合并结果也标记 @你
16
+ * - senderIsBot 只要有一条不是 bot 就算非 bot
17
+ */
18
+ function mergeGroupMessages(batch) {
19
+ if (batch.length === 1)
20
+ return batch[0];
21
+ const last = batch[batch.length - 1];
22
+ const first = batch[0];
23
+ // 拼接内容:每条消息带发送者前缀
24
+ const mergedContent = batch
25
+ .map((m) => {
26
+ const name = m.senderName ?? m.senderId;
27
+ return `[${name}]: ${m.content}`;
28
+ })
29
+ .join("\n");
30
+ // 合并附件
31
+ const mergedAttachments = [];
32
+ for (const m of batch) {
33
+ if (m.attachments?.length) {
34
+ mergedAttachments.push(...m.attachments);
35
+ }
36
+ }
37
+ // 合并 mentions(去重 by member_openid/id)
38
+ const seenMentionIds = new Set();
39
+ const mergedMentions = [];
40
+ let hasAtYouEvent = false;
41
+ for (const m of batch) {
42
+ if (m.eventType === "GROUP_AT_MESSAGE_CREATE") {
43
+ hasAtYouEvent = true;
44
+ }
45
+ if (m.mentions) {
46
+ for (const mt of m.mentions) {
47
+ const key = mt.member_openid ?? mt.id ?? mt.user_openid ?? "";
48
+ if (key && seenMentionIds.has(key))
49
+ continue;
50
+ if (key)
51
+ seenMentionIds.add(key);
52
+ mergedMentions.push(mt);
53
+ }
54
+ }
55
+ }
56
+ // senderIsBot: 只要有一条来自非 bot 用户,就算非 bot
57
+ const allFromBot = batch.every((m) => m.senderIsBot);
58
+ return {
59
+ type: last.type,
60
+ senderId: last.senderId,
61
+ senderName: last.senderName,
62
+ senderIsBot: allFromBot,
63
+ content: mergedContent,
64
+ messageId: last.messageId,
65
+ timestamp: last.timestamp,
66
+ channelId: last.channelId,
67
+ guildId: last.guildId,
68
+ groupOpenid: last.groupOpenid,
69
+ attachments: mergedAttachments.length > 0 ? mergedAttachments : undefined,
70
+ refMsgIdx: first.refMsgIdx,
71
+ msgIdx: last.msgIdx,
72
+ eventType: hasAtYouEvent ? "GROUP_AT_MESSAGE_CREATE" : last.eventType,
73
+ mentions: mergedMentions.length > 0 ? mergedMentions : undefined,
74
+ messageScene: last.messageScene,
75
+ _mergedCount: batch.length,
76
+ _mergedMessages: batch.length > 1 ? batch : undefined,
77
+ };
78
+ }
79
+ /**
80
+ * 创建按用户并发的消息队列(同用户串行,跨用户并行)
81
+ *
82
+ * 内置群消息增强:
83
+ * - 群聊 / 私聊使用不同队列上限
84
+ * - 群聊溢出时优先丢弃 bot 消息
85
+ * - drain 时自动合并群聊排队消息(斜杠命令单独处理)
86
+ */
87
+ export function createMessageQueue(ctx) {
88
+ const { accountId, log } = ctx;
89
+ const globalQueueSize = ctx.globalQueueSize ?? DEFAULT_GLOBAL_QUEUE_SIZE;
90
+ const peerQueueSize = ctx.peerQueueSize ?? DEFAULT_PER_PEER_QUEUE_SIZE;
91
+ const groupQueueSize = ctx.groupQueueSize ?? DEFAULT_GROUP_QUEUE_SIZE;
92
+ const maxConcurrentUsers = ctx.maxConcurrentUsers ?? DEFAULT_MAX_CONCURRENT_USERS;
93
+ const userQueues = new Map();
94
+ const activeUsers = new Set();
95
+ let handleMessageFnRef = null;
96
+ let totalEnqueued = 0;
97
+ const getMessagePeerId = (msg) => {
98
+ if (msg.type === "guild")
99
+ return `guild:${msg.channelId ?? "unknown"}`;
100
+ if (msg.type === "group")
101
+ return `group:${msg.groupOpenid ?? "unknown"}`;
102
+ return `dm:${msg.senderId}`;
103
+ };
104
+ /** 从满队列中淘汰一条消息(群聊优先丢弃 bot 消息,否则丢弃最旧) */
105
+ const evictOne = (queue, isGroup) => {
106
+ if (isGroup) {
107
+ const botIdx = queue.findIndex(m => m.senderIsBot);
108
+ if (botIdx >= 0)
109
+ return queue.splice(botIdx, 1)[0];
110
+ }
111
+ return queue.shift();
112
+ };
113
+ /** 判断消息是否为斜杠指令 */
114
+ const isSlashCommand = (msg) => (msg.content ?? "").trim().startsWith("/");
115
+ /** 处理单条消息,捕获异常并记录日志 */
116
+ const processOne = async (msg, peerId, label) => {
117
+ try {
118
+ await handleMessageFnRef(msg);
119
+ return true;
120
+ }
121
+ catch (err) {
122
+ log?.error(`[qqbot:${accountId}] ${label} error for ${peerId}: ${err}`);
123
+ return false;
124
+ }
125
+ };
126
+ /** 批量处理群聊排队消息:斜杠指令逐条处理,普通消息合并后处理 */
127
+ const drainGroupBatch = async (all, peerId) => {
128
+ const commands = [];
129
+ const normal = [];
130
+ for (const m of all) {
131
+ (isSlashCommand(m) ? commands : normal).push(m);
132
+ }
133
+ // 指令消息逐条处理
134
+ for (const cmd of commands) {
135
+ log?.info(`[qqbot:${accountId}] Processing command independently for ${peerId}: ${(cmd.content ?? "").trim().slice(0, 50)}`);
136
+ await processOne(cmd, peerId, "Command processor");
137
+ }
138
+ // 普通消息合并后处理
139
+ if (normal.length > 0) {
140
+ const merged = mergeGroupMessages(normal);
141
+ if (normal.length > 1) {
142
+ log?.info(`[qqbot:${accountId}] Merged ${normal.length} queued group messages for ${peerId} into one`);
143
+ }
144
+ await processOne(merged, peerId, `Message processor (merged batch of ${normal.length})`);
145
+ }
146
+ };
147
+ /** 处理指定 peer 队列中的消息(串行) */
148
+ const drainUserQueue = async (peerId) => {
149
+ if (activeUsers.has(peerId))
150
+ return;
151
+ if (activeUsers.size >= maxConcurrentUsers) {
152
+ log?.info(`[qqbot:${accountId}] Max concurrent users (${maxConcurrentUsers}) reached, ${peerId} will wait`);
153
+ return;
154
+ }
155
+ const queue = userQueues.get(peerId);
156
+ if (!queue || queue.length === 0) {
157
+ userQueues.delete(peerId);
158
+ return;
159
+ }
160
+ activeUsers.add(peerId);
161
+ const isGroup = isGroupPeer(peerId);
162
+ try {
163
+ while (queue.length > 0 && !ctx.isAborted()) {
164
+ // 群聊排队 > 1 条:批量处理
165
+ if (isGroup && queue.length > 1 && handleMessageFnRef) {
166
+ const all = queue.splice(0, queue.length);
167
+ totalEnqueued = Math.max(0, totalEnqueued - all.length);
168
+ await drainGroupBatch(all, peerId);
169
+ continue;
170
+ }
171
+ // 非群聊 或 队列只剩 1 条:逐条处理
172
+ const msg = queue.shift();
173
+ totalEnqueued = Math.max(0, totalEnqueued - 1);
174
+ if (handleMessageFnRef) {
175
+ await processOne(msg, peerId, "Message processor");
176
+ }
177
+ }
178
+ }
179
+ finally {
180
+ activeUsers.delete(peerId);
181
+ userQueues.delete(peerId);
182
+ // 尽量填满并发槽位
183
+ for (const [waitingPeerId, waitingQueue] of userQueues) {
184
+ if (activeUsers.size >= maxConcurrentUsers)
185
+ break;
186
+ if (waitingQueue.length > 0 && !activeUsers.has(waitingPeerId)) {
187
+ drainUserQueue(waitingPeerId);
188
+ }
189
+ }
190
+ }
191
+ };
192
+ const enqueue = (msg) => {
193
+ const peerId = getMessagePeerId(msg);
194
+ const isGroup = isGroupPeer(peerId);
195
+ let queue = userQueues.get(peerId);
196
+ if (!queue) {
197
+ queue = [];
198
+ userQueues.set(peerId, queue);
199
+ }
200
+ // 群聊和非群聊使用不同的队列上限
201
+ const maxSize = isGroup ? groupQueueSize : peerQueueSize;
202
+ // 队列溢出:淘汰一条旧消息
203
+ if (queue.length >= maxSize) {
204
+ const dropped = evictOne(queue, isGroup);
205
+ totalEnqueued = Math.max(0, totalEnqueued - 1);
206
+ if (isGroup && dropped?.senderIsBot) {
207
+ log?.info(`[qqbot:${accountId}] Queue full for ${peerId}, dropping bot message ${dropped.messageId}`);
208
+ }
209
+ else {
210
+ log?.error(`[qqbot:${accountId}] Queue full for ${peerId}, dropping oldest message ${dropped?.messageId}`);
211
+ }
212
+ }
213
+ // 全局总量保护
214
+ totalEnqueued++;
215
+ if (totalEnqueued > globalQueueSize) {
216
+ log?.error(`[qqbot:${accountId}] Global queue limit reached (${totalEnqueued}), message from ${peerId} may be delayed`);
217
+ }
218
+ queue.push(msg);
219
+ log?.debug?.(`[qqbot:${accountId}] Message enqueued for ${peerId}, user queue: ${queue.length}, active users: ${activeUsers.size}`);
220
+ // 如果该用户没有正在处理的消息,立即启动处理
221
+ drainUserQueue(peerId);
222
+ };
223
+ const startProcessor = (handleMessageFn) => {
224
+ handleMessageFnRef = handleMessageFn;
225
+ log?.info(`[qqbot:${accountId}] Message processor started (per-user concurrency, max ${maxConcurrentUsers} users)`);
226
+ };
227
+ const getSnapshot = (senderPeerId) => {
228
+ let totalPending = 0;
229
+ for (const [, q] of userQueues) {
230
+ totalPending += q.length;
231
+ }
232
+ const senderQueue = userQueues.get(senderPeerId);
233
+ return {
234
+ totalPending,
235
+ activeUsers: activeUsers.size,
236
+ maxConcurrentUsers,
237
+ senderPending: senderQueue ? senderQueue.length : 0,
238
+ };
239
+ };
240
+ const clearUserQueue = (peerId) => {
241
+ const queue = userQueues.get(peerId);
242
+ if (!queue || queue.length === 0)
243
+ return 0;
244
+ const droppedCount = queue.length;
245
+ queue.length = 0;
246
+ totalEnqueued = Math.max(0, totalEnqueued - droppedCount);
247
+ return droppedCount;
248
+ };
249
+ const executeImmediate = (msg) => {
250
+ if (handleMessageFnRef) {
251
+ handleMessageFnRef(msg).catch(err => {
252
+ log?.error(`[qqbot:${accountId}] Immediate execution error: ${err}`);
253
+ });
254
+ }
255
+ };
256
+ return { enqueue, startProcessor, getSnapshot, getMessagePeerId, clearUserQueue, executeImmediate };
257
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * QQBot CLI Onboarding Adapter
3
+ *
4
+ * 提供 openclaw onboard 命令的交互式配置支持
5
+ */
6
+ import type { ChannelOnboardingAdapter } from "openclaw/plugin-sdk";
7
+ /**
8
+ * QQBot Onboarding Adapter
9
+ */
10
+ export declare const qqbotOnboardingAdapter: ChannelOnboardingAdapter;
@@ -0,0 +1,203 @@
1
+ import { DEFAULT_ACCOUNT_ID, listQQBotAccountIds, resolveQQBotAccount } from "./config.js";
2
+ /**
3
+ * 解析默认账户 ID
4
+ */
5
+ function resolveDefaultQQBotAccountId(cfg) {
6
+ const ids = listQQBotAccountIds(cfg);
7
+ return ids[0] ?? DEFAULT_ACCOUNT_ID;
8
+ }
9
+ /**
10
+ * QQBot Onboarding Adapter
11
+ */
12
+ export const qqbotOnboardingAdapter = {
13
+ channel: "qqbot",
14
+ getStatus: async (ctx) => {
15
+ const cfg = ctx.cfg;
16
+ const configured = listQQBotAccountIds(cfg).some((accountId) => {
17
+ const account = resolveQQBotAccount(cfg, accountId);
18
+ return Boolean(account.appId && account.clientSecret);
19
+ });
20
+ return {
21
+ channel: "qqbot",
22
+ configured,
23
+ statusLines: [`QQ Bot: ${configured ? "已配置" : "需要 AppID 和 ClientSecret"}`],
24
+ selectionHint: configured ? "已配置" : "支持 QQ 群聊和私聊(流式消息)",
25
+ quickstartScore: configured ? 1 : 20,
26
+ };
27
+ },
28
+ configure: async (ctx) => {
29
+ const cfg = ctx.cfg;
30
+ const prompter = ctx.prompter;
31
+ const accountOverrides = ctx.accountOverrides;
32
+ const shouldPromptAccountIds = ctx.shouldPromptAccountIds;
33
+ const qqbotOverride = accountOverrides?.qqbot?.trim();
34
+ const defaultAccountId = resolveDefaultQQBotAccountId(cfg);
35
+ let accountId = qqbotOverride ?? defaultAccountId;
36
+ // 是否需要提示选择账户
37
+ if (shouldPromptAccountIds && !qqbotOverride) {
38
+ const existingIds = listQQBotAccountIds(cfg);
39
+ if (existingIds.length > 1) {
40
+ accountId = await prompter.select({
41
+ message: "选择 QQBot 账户",
42
+ options: existingIds.map((id) => ({
43
+ value: id,
44
+ label: id === DEFAULT_ACCOUNT_ID ? "默认账户" : id,
45
+ })),
46
+ initialValue: accountId,
47
+ });
48
+ }
49
+ }
50
+ let next = cfg;
51
+ const resolvedAccount = resolveQQBotAccount(next, accountId);
52
+ const accountConfigured = Boolean(resolvedAccount.appId && resolvedAccount.clientSecret);
53
+ const allowEnv = accountId === DEFAULT_ACCOUNT_ID;
54
+ const envAppId = typeof process !== "undefined" ? process.env?.QQBOT_APP_ID?.trim() : undefined;
55
+ const envSecret = typeof process !== "undefined" ? process.env?.QQBOT_CLIENT_SECRET?.trim() : undefined;
56
+ const canUseEnv = allowEnv && Boolean(envAppId && envSecret);
57
+ const hasConfigCredentials = Boolean(resolvedAccount.config.appId && resolvedAccount.config.clientSecret);
58
+ let appId = null;
59
+ let clientSecret = null;
60
+ // 显示帮助
61
+ if (!accountConfigured) {
62
+ await prompter.note([
63
+ "1) 打开 QQ 开放平台: https://q.qq.com/",
64
+ "2) 创建机器人应用,获取 AppID 和 ClientSecret",
65
+ "3) 在「开发设置」中添加沙箱成员(测试阶段)",
66
+ "4) 你也可以设置环境变量 QQBOT_APP_ID 和 QQBOT_CLIENT_SECRET",
67
+ "",
68
+ "文档: https://bot.q.qq.com/wiki/",
69
+ "",
70
+ "此版本支持流式消息发送!",
71
+ ].join("\n"), "QQ Bot 配置");
72
+ }
73
+ // 检测环境变量
74
+ if (canUseEnv && !hasConfigCredentials) {
75
+ const keepEnv = await prompter.confirm({
76
+ message: "检测到环境变量 QQBOT_APP_ID 和 QQBOT_CLIENT_SECRET,是否使用?",
77
+ initialValue: true,
78
+ });
79
+ if (keepEnv) {
80
+ next = {
81
+ ...next,
82
+ channels: {
83
+ ...next.channels,
84
+ qqbot: {
85
+ ...(next.channels?.qqbot || {}),
86
+ enabled: true,
87
+ allowFrom: resolvedAccount.config?.allowFrom ?? ["*"],
88
+ },
89
+ },
90
+ };
91
+ }
92
+ else {
93
+ // 手动输入
94
+ appId = String(await prompter.text({
95
+ message: "请输入 QQ Bot AppID",
96
+ placeholder: "例如: 102146862",
97
+ initialValue: resolvedAccount.appId || undefined,
98
+ validate: (value) => (value?.trim() ? undefined : "AppID 不能为空"),
99
+ })).trim();
100
+ clientSecret = String(await prompter.text({
101
+ message: "请输入 QQ Bot ClientSecret",
102
+ placeholder: "你的 ClientSecret",
103
+ validate: (value) => (value?.trim() ? undefined : "ClientSecret 不能为空"),
104
+ })).trim();
105
+ }
106
+ }
107
+ else if (hasConfigCredentials) {
108
+ // 已有配置
109
+ const keep = await prompter.confirm({
110
+ message: "QQ Bot 已配置,是否保留当前配置?",
111
+ initialValue: true,
112
+ });
113
+ if (!keep) {
114
+ appId = String(await prompter.text({
115
+ message: "请输入 QQ Bot AppID",
116
+ placeholder: "例如: 102146862",
117
+ initialValue: resolvedAccount.appId || undefined,
118
+ validate: (value) => (value?.trim() ? undefined : "AppID 不能为空"),
119
+ })).trim();
120
+ clientSecret = String(await prompter.text({
121
+ message: "请输入 QQ Bot ClientSecret",
122
+ placeholder: "你的 ClientSecret",
123
+ validate: (value) => (value?.trim() ? undefined : "ClientSecret 不能为空"),
124
+ })).trim();
125
+ }
126
+ }
127
+ else {
128
+ // 没有配置,需要输入
129
+ appId = String(await prompter.text({
130
+ message: "请输入 QQ Bot AppID",
131
+ placeholder: "例如: 102146862",
132
+ initialValue: resolvedAccount.appId || undefined,
133
+ validate: (value) => (value?.trim() ? undefined : "AppID 不能为空"),
134
+ })).trim();
135
+ clientSecret = String(await prompter.text({
136
+ message: "请输入 QQ Bot ClientSecret",
137
+ placeholder: "你的 ClientSecret",
138
+ validate: (value) => (value?.trim() ? undefined : "ClientSecret 不能为空"),
139
+ })).trim();
140
+ }
141
+ // 默认允许所有人执行命令(用户无感知)
142
+ const allowFrom = resolvedAccount.config?.allowFrom ?? ["*"];
143
+ // 应用配置(markdownSupport 默认开启,如需关闭可用 set-markdown.sh)
144
+ if (appId && clientSecret) {
145
+ const existingQQBot = next.channels?.qqbot || {};
146
+ // 保留已有的 markdownSupport 设置,新装默认 true
147
+ const markdownSupport = existingQQBot.markdownSupport ?? true;
148
+ if (accountId === DEFAULT_ACCOUNT_ID) {
149
+ next = {
150
+ ...next,
151
+ channels: {
152
+ ...next.channels,
153
+ qqbot: {
154
+ ...existingQQBot,
155
+ enabled: true,
156
+ appId,
157
+ clientSecret,
158
+ markdownSupport,
159
+ allowFrom,
160
+ },
161
+ },
162
+ };
163
+ }
164
+ else {
165
+ const existingAccounts = (next.channels?.qqbot?.accounts || {});
166
+ const existingAccount = existingAccounts[accountId] || {};
167
+ const acctMarkdown = existingAccount.markdownSupport ?? true;
168
+ next = {
169
+ ...next,
170
+ channels: {
171
+ ...next.channels,
172
+ qqbot: {
173
+ ...existingQQBot,
174
+ enabled: true,
175
+ accounts: {
176
+ ...existingAccounts,
177
+ [accountId]: {
178
+ ...existingAccount,
179
+ enabled: true,
180
+ appId,
181
+ clientSecret,
182
+ markdownSupport: acctMarkdown,
183
+ allowFrom,
184
+ },
185
+ },
186
+ },
187
+ },
188
+ };
189
+ }
190
+ }
191
+ return { success: true, cfg: next, accountId };
192
+ },
193
+ disable: (cfg) => {
194
+ const config = cfg;
195
+ return {
196
+ ...config,
197
+ channels: {
198
+ ...config.channels,
199
+ qqbot: { ...(config.channels?.qqbot || {}), enabled: false },
200
+ },
201
+ };
202
+ },
203
+ };