@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,190 @@
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
+
13
+ // ────────────────────── Types ──────────────────────
14
+
15
+ export type MentionGateResult = {
16
+ effectiveWasMentioned: boolean;
17
+ shouldSkip: boolean;
18
+ };
19
+
20
+ export type MentionGateWithBypassResult = MentionGateResult & {
21
+ shouldBypassMention: boolean;
22
+ };
23
+
24
+ export type GroupMessageGateAction =
25
+ /** @了其他人但未 @bot,丢弃并记录历史 */
26
+ | "drop_other_mention"
27
+ /** 未授权控制命令,静默拦截 */
28
+ | "block_unauthorized_command"
29
+ /** 非 @bot 消息,记录历史后跳过 AI */
30
+ | "skip_no_mention"
31
+ /** 正常放行,交给 AI */
32
+ | "pass";
33
+
34
+ export type GroupMessageGateResult = {
35
+ action: GroupMessageGateAction;
36
+ /** 仅 action=pass|skip_no_mention 时有值 */
37
+ effectiveWasMentioned: boolean;
38
+ shouldBypassMention: boolean;
39
+ };
40
+
41
+ export type GroupMessageGateParams = {
42
+ // ── ignoreOtherMentions 层 ──
43
+ ignoreOtherMentions: boolean;
44
+ hasAnyMention: boolean;
45
+ wasMentioned: boolean;
46
+ implicitMention: boolean;
47
+
48
+ // ── shouldBlock 层 ──
49
+ allowTextCommands: boolean;
50
+ isControlCommand: boolean;
51
+ commandAuthorized: boolean;
52
+
53
+ // ── mentionGating 层 ──
54
+ requireMention: boolean;
55
+ canDetectMention: boolean;
56
+ };
57
+
58
+ // ────────────────────── Core Logic ──────────────────────
59
+
60
+ /**
61
+ * 基础 mention 门控纯函数。
62
+ * effectiveWasMentioned = wasMentioned || implicitMention || shouldBypassMention
63
+ * shouldSkip = requireMention && canDetectMention && !effectiveWasMentioned
64
+ */
65
+ function resolveMentionGating(params: {
66
+ requireMention: boolean;
67
+ canDetectMention: boolean;
68
+ wasMentioned: boolean;
69
+ implicitMention?: boolean;
70
+ shouldBypassMention?: boolean;
71
+ }): MentionGateResult {
72
+ const implicit = params.implicitMention === true;
73
+ const bypass = params.shouldBypassMention === true;
74
+ const effectiveWasMentioned = params.wasMentioned || implicit || bypass;
75
+ const shouldSkip = params.requireMention && params.canDetectMention && !effectiveWasMentioned;
76
+ return { effectiveWasMentioned, shouldSkip };
77
+ }
78
+
79
+ /**
80
+ * 带命令旁路的 mention 门控。
81
+ *
82
+ * shouldBypassMention 条件(全部满足时才旁路):
83
+ * 1. requireMention — 开启了 mention 要求
84
+ * 2. !wasMentioned — 没有被直接 mention
85
+ * 3. !hasAnyMention — 消息中没有任何 @(防止 @ 其他人的消息误 bypass)
86
+ * 4. allowTextCommands — 文本命令已启用
87
+ * 5. commandAuthorized — 发送者有命令权限
88
+ * 6. hasControlCommand — 消息是合法控制命令
89
+ */
90
+ function resolveMentionGatingWithBypass(params: {
91
+ requireMention: boolean;
92
+ canDetectMention: boolean;
93
+ wasMentioned: boolean;
94
+ implicitMention?: boolean;
95
+ hasAnyMention?: boolean;
96
+ allowTextCommands: boolean;
97
+ hasControlCommand: boolean;
98
+ commandAuthorized: boolean;
99
+ }): MentionGateWithBypassResult {
100
+ const shouldBypassMention =
101
+ params.requireMention &&
102
+ !params.wasMentioned &&
103
+ !(params.hasAnyMention ?? false) &&
104
+ params.allowTextCommands &&
105
+ params.commandAuthorized &&
106
+ params.hasControlCommand;
107
+ return {
108
+ ...resolveMentionGating({
109
+ requireMention: params.requireMention,
110
+ canDetectMention: params.canDetectMention,
111
+ wasMentioned: params.wasMentioned,
112
+ implicitMention: params.implicitMention,
113
+ shouldBypassMention,
114
+ }),
115
+ shouldBypassMention,
116
+ };
117
+ }
118
+
119
+ // ────────────────────── Unified Gate ──────────────────────
120
+
121
+ /**
122
+ * 群消息统一门控,按优先级串行判定:
123
+ *
124
+ * 1. ignoreOtherMentions — @了其他人但未 @bot → drop_other_mention
125
+ * 2. shouldBlock — 未授权控制命令 → block_unauthorized_command
126
+ * 3. mentionGating — 未满足 @bot 条件 → skip_no_mention
127
+ * 4. 通过所有检查 → pass
128
+ */
129
+ export function resolveGroupMessageGate(params: GroupMessageGateParams): GroupMessageGateResult {
130
+ const {
131
+ ignoreOtherMentions,
132
+ hasAnyMention,
133
+ wasMentioned,
134
+ implicitMention,
135
+ allowTextCommands,
136
+ isControlCommand,
137
+ commandAuthorized,
138
+ requireMention,
139
+ canDetectMention,
140
+ } = params;
141
+
142
+ // ── Layer 1: ignoreOtherMentions ──
143
+ if (
144
+ ignoreOtherMentions &&
145
+ hasAnyMention &&
146
+ !wasMentioned &&
147
+ !implicitMention
148
+ ) {
149
+ return {
150
+ action: "drop_other_mention",
151
+ effectiveWasMentioned: false,
152
+ shouldBypassMention: false,
153
+ };
154
+ }
155
+
156
+ // ── Layer 2: shouldBlock 未授权控制命令 ──
157
+ if (allowTextCommands && isControlCommand && !commandAuthorized) {
158
+ return {
159
+ action: "block_unauthorized_command",
160
+ effectiveWasMentioned: false,
161
+ shouldBypassMention: false,
162
+ };
163
+ }
164
+
165
+ // ── Layer 3: mention 门控 + 命令旁路 ──
166
+ const mentionGate = resolveMentionGatingWithBypass({
167
+ requireMention,
168
+ canDetectMention,
169
+ wasMentioned,
170
+ implicitMention,
171
+ hasAnyMention,
172
+ allowTextCommands,
173
+ hasControlCommand: isControlCommand,
174
+ commandAuthorized,
175
+ });
176
+
177
+ if (mentionGate.shouldSkip) {
178
+ return {
179
+ action: "skip_no_mention",
180
+ effectiveWasMentioned: mentionGate.effectiveWasMentioned,
181
+ shouldBypassMention: mentionGate.shouldBypassMention,
182
+ };
183
+ }
184
+
185
+ return {
186
+ action: "pass",
187
+ effectiveWasMentioned: mentionGate.effectiveWasMentioned,
188
+ shouldBypassMention: mentionGate.shouldBypassMention,
189
+ };
190
+ }
@@ -0,0 +1,349 @@
1
+ import type { QueueSnapshot } from "./slash-commands.js";
2
+
3
+ // ── 消息队列默认配置 ──
4
+ const DEFAULT_GLOBAL_QUEUE_SIZE = 1000;
5
+ const DEFAULT_PER_PEER_QUEUE_SIZE = 20;
6
+ const DEFAULT_GROUP_QUEUE_SIZE = 50;
7
+ const DEFAULT_MAX_CONCURRENT_USERS = 10;
8
+
9
+ /**
10
+ * 消息队列项类型(用于异步处理消息,防止阻塞心跳)
11
+ */
12
+ export interface QueuedMessage {
13
+ type: "c2c" | "guild" | "dm" | "group";
14
+ senderId: string;
15
+ senderName?: string;
16
+ content: string;
17
+ messageId: string;
18
+ timestamp: string;
19
+ channelId?: string;
20
+ guildId?: string;
21
+ groupOpenid?: string;
22
+ attachments?: Array<{ content_type: string; url: string; filename?: string; voice_wav_url?: string; asr_refer_text?: string }>;
23
+ /** 被引用消息的 refIdx(用户引用了哪条历史消息) */
24
+ refMsgIdx?: string;
25
+ /** 当前消息自身的 refIdx(供将来被引用) */
26
+ msgIdx?: string;
27
+ /** 事件类型(如 GROUP_AT_MESSAGE_CREATE),用于群消息合并时判断是否有 @ */
28
+ eventType?: string;
29
+ /** 发送者是否为机器人 */
30
+ senderIsBot?: boolean;
31
+ /** @ 提及列表(群消息合并时需要去重合并) */
32
+ mentions?: Array<{ scope?: "all" | "single"; id?: string; user_openid?: string; member_openid?: string; username?: string; bot?: boolean; is_you?: boolean }>;
33
+ /** 消息场景(来源、扩展字段) */
34
+ messageScene?: { source?: string; ext?: string[] };
35
+ /** 群消息合并标记:记录合并了多少条原始消息 */
36
+ _mergedCount?: number;
37
+ /** 合并前的原始消息列表(用于 gateway 侧逐条格式化信封) */
38
+ _mergedMessages?: QueuedMessage[];
39
+ }
40
+
41
+ export interface MessageQueueContext {
42
+ accountId: string;
43
+ log?: {
44
+ info: (msg: string) => void;
45
+ error: (msg: string) => void;
46
+ debug?: (msg: string) => void;
47
+ };
48
+ /** 外部提供的 abort 状态检查 */
49
+ isAborted: () => boolean;
50
+ /** 群聊队列上限(默认 50) */
51
+ groupQueueSize?: number;
52
+ /** 私聊/DM 队列上限(默认 20) */
53
+ peerQueueSize?: number;
54
+ /** 全局队列总量上限(默认 1000) */
55
+ globalQueueSize?: number;
56
+ /** 最大并发处理用户数(默认 10) */
57
+ maxConcurrentUsers?: number;
58
+ }
59
+
60
+ export interface MessageQueue {
61
+ enqueue: (msg: QueuedMessage) => void;
62
+ startProcessor: (handleMessageFn: (msg: QueuedMessage) => Promise<void>) => void;
63
+ getSnapshot: (senderPeerId: string) => QueueSnapshot;
64
+ getMessagePeerId: (msg: QueuedMessage) => string;
65
+ /** 清空指定用户的排队消息,返回被丢弃的消息数 */
66
+ clearUserQueue: (peerId: string) => number;
67
+ /** 立即执行一条消息(绕过队列),用于紧急命令 */
68
+ executeImmediate: (msg: QueuedMessage) => void;
69
+ }
70
+
71
+ // ── 群消息合并工具函数 ──
72
+
73
+ /** 判断 peerId 是否属于群聊 */
74
+ const isGroupPeer = (peerId: string): boolean =>
75
+ peerId.startsWith("group:") || peerId.startsWith("guild:");
76
+
77
+ /**
78
+ * 将多条群消息合并为一条,用于群聊场景下排队消息的批量处理。
79
+ * - content 拼接为多行,每行带发送者前缀
80
+ * - 附件合并
81
+ * - messageId / msgIdx / timestamp 取最后一条(用于回复引用)
82
+ * - mentions 合并去重
83
+ * - 如果有任意一条 @了你(is_you),合并结果也标记 @你
84
+ * - senderIsBot 只要有一条不是 bot 就算非 bot
85
+ */
86
+ function mergeGroupMessages(batch: QueuedMessage[]): QueuedMessage {
87
+ if (batch.length === 1) return batch[0];
88
+
89
+ const last = batch[batch.length - 1];
90
+ const first = batch[0];
91
+
92
+ // 拼接内容:每条消息带发送者前缀
93
+ const mergedContent = batch
94
+ .map((m) => {
95
+ const name = m.senderName ?? m.senderId;
96
+ return `[${name}]: ${m.content}`;
97
+ })
98
+ .join("\n");
99
+
100
+ // 合并附件
101
+ const mergedAttachments: QueuedMessage["attachments"] = [];
102
+ for (const m of batch) {
103
+ if (m.attachments?.length) {
104
+ mergedAttachments.push(...m.attachments);
105
+ }
106
+ }
107
+
108
+ // 合并 mentions(去重 by member_openid/id)
109
+ const seenMentionIds = new Set<string>();
110
+ const mergedMentions: NonNullable<QueuedMessage["mentions"]> = [];
111
+ let hasAtYouEvent = false;
112
+ for (const m of batch) {
113
+ if (m.eventType === "GROUP_AT_MESSAGE_CREATE") {
114
+ hasAtYouEvent = true;
115
+ }
116
+ if (m.mentions) {
117
+ for (const mt of m.mentions) {
118
+ const key = mt.member_openid ?? mt.id ?? mt.user_openid ?? "";
119
+ if (key && seenMentionIds.has(key)) continue;
120
+ if (key) seenMentionIds.add(key);
121
+ mergedMentions.push(mt);
122
+ }
123
+ }
124
+ }
125
+
126
+ // senderIsBot: 只要有一条来自非 bot 用户,就算非 bot
127
+ const allFromBot = batch.every((m) => m.senderIsBot);
128
+
129
+ return {
130
+ type: last.type,
131
+ senderId: last.senderId,
132
+ senderName: last.senderName,
133
+ senderIsBot: allFromBot,
134
+ content: mergedContent,
135
+ messageId: last.messageId,
136
+ timestamp: last.timestamp,
137
+ channelId: last.channelId,
138
+ guildId: last.guildId,
139
+ groupOpenid: last.groupOpenid,
140
+ attachments: mergedAttachments.length > 0 ? mergedAttachments : undefined,
141
+ refMsgIdx: first.refMsgIdx,
142
+ msgIdx: last.msgIdx,
143
+ eventType: hasAtYouEvent ? "GROUP_AT_MESSAGE_CREATE" : last.eventType,
144
+ mentions: mergedMentions.length > 0 ? mergedMentions : undefined,
145
+ messageScene: last.messageScene,
146
+ _mergedCount: batch.length,
147
+ _mergedMessages: batch.length > 1 ? batch : undefined,
148
+ };
149
+ }
150
+
151
+ /**
152
+ * 创建按用户并发的消息队列(同用户串行,跨用户并行)
153
+ *
154
+ * 内置群消息增强:
155
+ * - 群聊 / 私聊使用不同队列上限
156
+ * - 群聊溢出时优先丢弃 bot 消息
157
+ * - drain 时自动合并群聊排队消息(斜杠命令单独处理)
158
+ */
159
+ export function createMessageQueue(ctx: MessageQueueContext): MessageQueue {
160
+ const { accountId, log } = ctx;
161
+ const globalQueueSize = ctx.globalQueueSize ?? DEFAULT_GLOBAL_QUEUE_SIZE;
162
+ const peerQueueSize = ctx.peerQueueSize ?? DEFAULT_PER_PEER_QUEUE_SIZE;
163
+ const groupQueueSize = ctx.groupQueueSize ?? DEFAULT_GROUP_QUEUE_SIZE;
164
+ const maxConcurrentUsers = ctx.maxConcurrentUsers ?? DEFAULT_MAX_CONCURRENT_USERS;
165
+
166
+ const userQueues = new Map<string, QueuedMessage[]>();
167
+ const activeUsers = new Set<string>();
168
+ let handleMessageFnRef: ((msg: QueuedMessage) => Promise<void>) | null = null;
169
+ let totalEnqueued = 0;
170
+
171
+ const getMessagePeerId = (msg: QueuedMessage): string => {
172
+ if (msg.type === "guild") return `guild:${msg.channelId ?? "unknown"}`;
173
+ if (msg.type === "group") return `group:${msg.groupOpenid ?? "unknown"}`;
174
+ return `dm:${msg.senderId}`;
175
+ };
176
+
177
+ /** 从满队列中淘汰一条消息(群聊优先丢弃 bot 消息,否则丢弃最旧) */
178
+ const evictOne = (queue: QueuedMessage[], isGroup: boolean): QueuedMessage | undefined => {
179
+ if (isGroup) {
180
+ const botIdx = queue.findIndex(m => m.senderIsBot);
181
+ if (botIdx >= 0) return queue.splice(botIdx, 1)[0];
182
+ }
183
+ return queue.shift();
184
+ };
185
+
186
+ /** 判断消息是否为斜杠指令 */
187
+ const isSlashCommand = (msg: QueuedMessage): boolean =>
188
+ (msg.content ?? "").trim().startsWith("/");
189
+
190
+ /** 处理单条消息,捕获异常并记录日志 */
191
+ const processOne = async (
192
+ msg: QueuedMessage,
193
+ peerId: string,
194
+ label: string,
195
+ ): Promise<boolean> => {
196
+ try {
197
+ await handleMessageFnRef!(msg);
198
+ return true;
199
+ } catch (err) {
200
+ log?.error(`[qqbot:${accountId}] ${label} error for ${peerId}: ${err}`);
201
+ return false;
202
+ }
203
+ };
204
+
205
+ /** 批量处理群聊排队消息:斜杠指令逐条处理,普通消息合并后处理 */
206
+ const drainGroupBatch = async (all: QueuedMessage[], peerId: string): Promise<void> => {
207
+ const commands: QueuedMessage[] = [];
208
+ const normal: QueuedMessage[] = [];
209
+ for (const m of all) {
210
+ (isSlashCommand(m) ? commands : normal).push(m);
211
+ }
212
+
213
+ // 指令消息逐条处理
214
+ for (const cmd of commands) {
215
+ log?.info(`[qqbot:${accountId}] Processing command independently for ${peerId}: ${(cmd.content ?? "").trim().slice(0, 50)}`);
216
+ await processOne(cmd, peerId, "Command processor");
217
+ }
218
+
219
+ // 普通消息合并后处理
220
+ if (normal.length > 0) {
221
+ const merged = mergeGroupMessages(normal);
222
+ if (normal.length > 1) {
223
+ log?.info(`[qqbot:${accountId}] Merged ${normal.length} queued group messages for ${peerId} into one`);
224
+ }
225
+ await processOne(merged, peerId, `Message processor (merged batch of ${normal.length})`);
226
+ }
227
+ };
228
+
229
+ /** 处理指定 peer 队列中的消息(串行) */
230
+ const drainUserQueue = async (peerId: string): Promise<void> => {
231
+ if (activeUsers.has(peerId)) return;
232
+ if (activeUsers.size >= maxConcurrentUsers) {
233
+ log?.info(`[qqbot:${accountId}] Max concurrent users (${maxConcurrentUsers}) reached, ${peerId} will wait`);
234
+ return;
235
+ }
236
+
237
+ const queue = userQueues.get(peerId);
238
+ if (!queue || queue.length === 0) {
239
+ userQueues.delete(peerId);
240
+ return;
241
+ }
242
+
243
+ activeUsers.add(peerId);
244
+ const isGroup = isGroupPeer(peerId);
245
+
246
+ try {
247
+ while (queue.length > 0 && !ctx.isAborted()) {
248
+ // 群聊排队 > 1 条:批量处理
249
+ if (isGroup && queue.length > 1 && handleMessageFnRef) {
250
+ const all = queue.splice(0, queue.length);
251
+ totalEnqueued = Math.max(0, totalEnqueued - all.length);
252
+ await drainGroupBatch(all, peerId);
253
+ continue;
254
+ }
255
+
256
+ // 非群聊 或 队列只剩 1 条:逐条处理
257
+ const msg = queue.shift()!;
258
+ totalEnqueued = Math.max(0, totalEnqueued - 1);
259
+ if (handleMessageFnRef) {
260
+ await processOne(msg, peerId, "Message processor");
261
+ }
262
+ }
263
+ } finally {
264
+ activeUsers.delete(peerId);
265
+ userQueues.delete(peerId);
266
+ // 尽量填满并发槽位
267
+ for (const [waitingPeerId, waitingQueue] of userQueues) {
268
+ if (activeUsers.size >= maxConcurrentUsers) break;
269
+ if (waitingQueue.length > 0 && !activeUsers.has(waitingPeerId)) {
270
+ drainUserQueue(waitingPeerId);
271
+ }
272
+ }
273
+ }
274
+ };
275
+
276
+ const enqueue = (msg: QueuedMessage): void => {
277
+ const peerId = getMessagePeerId(msg);
278
+ const isGroup = isGroupPeer(peerId);
279
+ let queue = userQueues.get(peerId);
280
+ if (!queue) {
281
+ queue = [];
282
+ userQueues.set(peerId, queue);
283
+ }
284
+
285
+ // 群聊和非群聊使用不同的队列上限
286
+ const maxSize = isGroup ? groupQueueSize : peerQueueSize;
287
+
288
+ // 队列溢出:淘汰一条旧消息
289
+ if (queue.length >= maxSize) {
290
+ const dropped = evictOne(queue, isGroup);
291
+ totalEnqueued = Math.max(0, totalEnqueued - 1);
292
+ if (isGroup && dropped?.senderIsBot) {
293
+ log?.info(`[qqbot:${accountId}] Queue full for ${peerId}, dropping bot message ${dropped.messageId}`);
294
+ } else {
295
+ log?.error(`[qqbot:${accountId}] Queue full for ${peerId}, dropping oldest message ${dropped?.messageId}`);
296
+ }
297
+ }
298
+
299
+ // 全局总量保护
300
+ totalEnqueued++;
301
+ if (totalEnqueued > globalQueueSize) {
302
+ log?.error(`[qqbot:${accountId}] Global queue limit reached (${totalEnqueued}), message from ${peerId} may be delayed`);
303
+ }
304
+
305
+ queue.push(msg);
306
+ log?.debug?.(`[qqbot:${accountId}] Message enqueued for ${peerId}, user queue: ${queue.length}, active users: ${activeUsers.size}`);
307
+
308
+ // 如果该用户没有正在处理的消息,立即启动处理
309
+ drainUserQueue(peerId);
310
+ };
311
+
312
+ const startProcessor = (handleMessageFn: (msg: QueuedMessage) => Promise<void>): void => {
313
+ handleMessageFnRef = handleMessageFn;
314
+ log?.info(`[qqbot:${accountId}] Message processor started (per-user concurrency, max ${maxConcurrentUsers} users)`);
315
+ };
316
+
317
+ const getSnapshot = (senderPeerId: string): QueueSnapshot => {
318
+ let totalPending = 0;
319
+ for (const [, q] of userQueues) {
320
+ totalPending += q.length;
321
+ }
322
+ const senderQueue = userQueues.get(senderPeerId);
323
+ return {
324
+ totalPending,
325
+ activeUsers: activeUsers.size,
326
+ maxConcurrentUsers,
327
+ senderPending: senderQueue ? senderQueue.length : 0,
328
+ };
329
+ };
330
+
331
+ const clearUserQueue = (peerId: string): number => {
332
+ const queue = userQueues.get(peerId);
333
+ if (!queue || queue.length === 0) return 0;
334
+ const droppedCount = queue.length;
335
+ queue.length = 0;
336
+ totalEnqueued = Math.max(0, totalEnqueued - droppedCount);
337
+ return droppedCount;
338
+ };
339
+
340
+ const executeImmediate = (msg: QueuedMessage): void => {
341
+ if (handleMessageFnRef) {
342
+ handleMessageFnRef(msg).catch(err => {
343
+ log?.error(`[qqbot:${accountId}] Immediate execution error: ${err}`);
344
+ });
345
+ }
346
+ };
347
+
348
+ return { enqueue, startProcessor, getSnapshot, getMessagePeerId, clearUserQueue, executeImmediate };
349
+ }