@openclaw/qqbot 2026.5.2 → 2026.5.3-beta.2

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 (203) hide show
  1. package/dist/api.js +646 -0
  2. package/dist/approval-cg0SVahb.js +94 -0
  3. package/dist/channel-N6Y_Rcjp.js +551 -0
  4. package/dist/channel-plugin-api.js +2 -0
  5. package/dist/channel.setup-ByCRAe5H.js +25 -0
  6. package/dist/config-D6545NkC.js +94 -0
  7. package/dist/config-schema-DFcjQw73.js +309 -0
  8. package/dist/exec-approvals-COUsM6wZ.js +138 -0
  9. package/dist/gateway-CmSUJKSt.js +6726 -0
  10. package/dist/handler-runtime-Bqm6N0WG.js +120 -0
  11. package/dist/index.js +29 -0
  12. package/dist/narrowing-BoieBTIU.js +25 -0
  13. package/dist/outbound-BJfhwrPg.js +1357 -0
  14. package/dist/request-context-DXtpwWui.js +921 -0
  15. package/dist/resolve-D_06fV6-.js +284 -0
  16. package/dist/runtime-BJAS3eXW.js +18 -0
  17. package/dist/runtime-api.js +2 -0
  18. package/dist/secret-contract-api.js +56 -0
  19. package/dist/sender-p-B14eLG.js +1987 -0
  20. package/dist/setup-entry.js +15 -0
  21. package/dist/setup-plugin-api.js +2 -0
  22. package/dist/string-normalize-Ci6NM5DE.js +98 -0
  23. package/dist/target-parser-Y0prnrXD.js +245 -0
  24. package/package.json +15 -6
  25. package/api.ts +0 -56
  26. package/channel-plugin-api.ts +0 -1
  27. package/index.ts +0 -29
  28. package/runtime-api.ts +0 -9
  29. package/setup-entry.ts +0 -9
  30. package/setup-plugin-api.ts +0 -3
  31. package/src/bridge/approval/capability.ts +0 -237
  32. package/src/bridge/approval/handler-runtime.ts +0 -204
  33. package/src/bridge/bootstrap.ts +0 -135
  34. package/src/bridge/channel-entry.ts +0 -18
  35. package/src/bridge/commands/framework-context-adapter.ts +0 -60
  36. package/src/bridge/commands/framework-registration.ts +0 -47
  37. package/src/bridge/commands/from-parser.test.ts +0 -86
  38. package/src/bridge/commands/from-parser.ts +0 -60
  39. package/src/bridge/commands/result-dispatcher.ts +0 -76
  40. package/src/bridge/config-shared.ts +0 -132
  41. package/src/bridge/config.ts +0 -111
  42. package/src/bridge/gateway.ts +0 -174
  43. package/src/bridge/logger.ts +0 -31
  44. package/src/bridge/narrowing.ts +0 -31
  45. package/src/bridge/plugin-version.test.ts +0 -146
  46. package/src/bridge/plugin-version.ts +0 -102
  47. package/src/bridge/runtime.ts +0 -25
  48. package/src/bridge/sdk-adapter.ts +0 -131
  49. package/src/bridge/setup/finalize.ts +0 -144
  50. package/src/bridge/setup/surface.ts +0 -34
  51. package/src/bridge/tools/channel.ts +0 -58
  52. package/src/bridge/tools/index.ts +0 -15
  53. package/src/bridge/tools/remind.test.ts +0 -124
  54. package/src/bridge/tools/remind.ts +0 -91
  55. package/src/channel.setup.ts +0 -33
  56. package/src/channel.ts +0 -288
  57. package/src/command-auth.test.ts +0 -62
  58. package/src/config-schema.ts +0 -84
  59. package/src/config.test.ts +0 -364
  60. package/src/engine/access/access-control.test.ts +0 -198
  61. package/src/engine/access/access-control.ts +0 -226
  62. package/src/engine/access/index.ts +0 -16
  63. package/src/engine/access/resolve-policy.test.ts +0 -59
  64. package/src/engine/access/resolve-policy.ts +0 -57
  65. package/src/engine/access/sender-match.test.ts +0 -60
  66. package/src/engine/access/sender-match.ts +0 -55
  67. package/src/engine/access/types.ts +0 -53
  68. package/src/engine/adapter/audio.port.ts +0 -27
  69. package/src/engine/adapter/commands.port.ts +0 -22
  70. package/src/engine/adapter/history.port.ts +0 -52
  71. package/src/engine/adapter/index.ts +0 -139
  72. package/src/engine/adapter/mention-gate.port.ts +0 -50
  73. package/src/engine/adapter/types.ts +0 -38
  74. package/src/engine/api/api-client.ts +0 -212
  75. package/src/engine/api/media-chunked.test.ts +0 -336
  76. package/src/engine/api/media-chunked.ts +0 -622
  77. package/src/engine/api/media.ts +0 -218
  78. package/src/engine/api/messages.ts +0 -293
  79. package/src/engine/api/retry.ts +0 -217
  80. package/src/engine/api/routes.ts +0 -95
  81. package/src/engine/api/token.ts +0 -271
  82. package/src/engine/approval/index.test.ts +0 -22
  83. package/src/engine/approval/index.ts +0 -224
  84. package/src/engine/commands/builtin/log-helpers.ts +0 -319
  85. package/src/engine/commands/builtin/register-all.ts +0 -17
  86. package/src/engine/commands/builtin/register-approve.ts +0 -201
  87. package/src/engine/commands/builtin/register-basic.ts +0 -95
  88. package/src/engine/commands/builtin/register-clear-storage.ts +0 -187
  89. package/src/engine/commands/builtin/register-logs.ts +0 -20
  90. package/src/engine/commands/builtin/register-streaming.ts +0 -137
  91. package/src/engine/commands/builtin/state.ts +0 -31
  92. package/src/engine/commands/slash-command-auth.ts +0 -48
  93. package/src/engine/commands/slash-command-handler.ts +0 -146
  94. package/src/engine/commands/slash-commands-impl.test.ts +0 -8
  95. package/src/engine/commands/slash-commands-impl.ts +0 -61
  96. package/src/engine/commands/slash-commands.ts +0 -199
  97. package/src/engine/config/credential-backup.test.ts +0 -88
  98. package/src/engine/config/credential-backup.ts +0 -107
  99. package/src/engine/config/credentials.ts +0 -76
  100. package/src/engine/config/group.test.ts +0 -234
  101. package/src/engine/config/group.ts +0 -299
  102. package/src/engine/config/resolve.test.ts +0 -152
  103. package/src/engine/config/resolve.ts +0 -283
  104. package/src/engine/config/setup-logic.ts +0 -84
  105. package/src/engine/engine-import-boundary.test.ts +0 -73
  106. package/src/engine/gateway/codec.ts +0 -47
  107. package/src/engine/gateway/constants.ts +0 -117
  108. package/src/engine/gateway/event-dispatcher.ts +0 -177
  109. package/src/engine/gateway/gateway-connection.ts +0 -371
  110. package/src/engine/gateway/gateway.ts +0 -291
  111. package/src/engine/gateway/inbound-attachments.test.ts +0 -126
  112. package/src/engine/gateway/inbound-attachments.ts +0 -360
  113. package/src/engine/gateway/inbound-context.ts +0 -195
  114. package/src/engine/gateway/inbound-pipeline.self-echo.test.ts +0 -218
  115. package/src/engine/gateway/inbound-pipeline.ts +0 -235
  116. package/src/engine/gateway/interaction-handler.ts +0 -220
  117. package/src/engine/gateway/message-queue.test.ts +0 -282
  118. package/src/engine/gateway/message-queue.ts +0 -499
  119. package/src/engine/gateway/outbound-dispatch.test.ts +0 -231
  120. package/src/engine/gateway/outbound-dispatch.ts +0 -575
  121. package/src/engine/gateway/reconnect.ts +0 -199
  122. package/src/engine/gateway/stages/access-stage.ts +0 -132
  123. package/src/engine/gateway/stages/assembly-stage.ts +0 -156
  124. package/src/engine/gateway/stages/content-stage.test.ts +0 -77
  125. package/src/engine/gateway/stages/content-stage.ts +0 -77
  126. package/src/engine/gateway/stages/envelope-stage.test.ts +0 -152
  127. package/src/engine/gateway/stages/envelope-stage.ts +0 -144
  128. package/src/engine/gateway/stages/group-gate-stage.ts +0 -292
  129. package/src/engine/gateway/stages/index.ts +0 -18
  130. package/src/engine/gateway/stages/quote-stage.ts +0 -113
  131. package/src/engine/gateway/stages/refidx-stage.ts +0 -62
  132. package/src/engine/gateway/stages/stub-contexts.ts +0 -116
  133. package/src/engine/gateway/types.ts +0 -264
  134. package/src/engine/gateway/typing-keepalive.ts +0 -79
  135. package/src/engine/group/activation.test.ts +0 -114
  136. package/src/engine/group/activation.ts +0 -147
  137. package/src/engine/group/history.test.ts +0 -314
  138. package/src/engine/group/history.ts +0 -321
  139. package/src/engine/group/mention.test.ts +0 -141
  140. package/src/engine/group/mention.ts +0 -197
  141. package/src/engine/group/message-gating.test.ts +0 -188
  142. package/src/engine/group/message-gating.ts +0 -216
  143. package/src/engine/messaging/decode-media-path.ts +0 -82
  144. package/src/engine/messaging/media-source.ts +0 -215
  145. package/src/engine/messaging/media-type-detect.ts +0 -37
  146. package/src/engine/messaging/outbound-audio-port.ts +0 -38
  147. package/src/engine/messaging/outbound-deliver.ts +0 -810
  148. package/src/engine/messaging/outbound-media-send.ts +0 -702
  149. package/src/engine/messaging/outbound-reply.ts +0 -27
  150. package/src/engine/messaging/outbound-result-helpers.ts +0 -54
  151. package/src/engine/messaging/outbound-types.ts +0 -45
  152. package/src/engine/messaging/outbound.ts +0 -485
  153. package/src/engine/messaging/reply-dispatcher.ts +0 -597
  154. package/src/engine/messaging/reply-limiter.ts +0 -164
  155. package/src/engine/messaging/sender.ts +0 -729
  156. package/src/engine/messaging/streaming-c2c.ts +0 -1192
  157. package/src/engine/messaging/streaming-media-send.ts +0 -544
  158. package/src/engine/messaging/target-parser.ts +0 -104
  159. package/src/engine/ref/format-message-ref.ts +0 -142
  160. package/src/engine/ref/format-ref-entry.test.ts +0 -60
  161. package/src/engine/ref/format-ref-entry.ts +0 -27
  162. package/src/engine/ref/store.ts +0 -208
  163. package/src/engine/ref/types.ts +0 -27
  164. package/src/engine/session/known-users.ts +0 -137
  165. package/src/engine/session/session-store.ts +0 -204
  166. package/src/engine/tools/channel-api.ts +0 -244
  167. package/src/engine/tools/remind-logic.test.ts +0 -280
  168. package/src/engine/tools/remind-logic.ts +0 -377
  169. package/src/engine/types.ts +0 -313
  170. package/src/engine/utils/attachment-tags.test.ts +0 -186
  171. package/src/engine/utils/attachment-tags.ts +0 -174
  172. package/src/engine/utils/audio.test.ts +0 -250
  173. package/src/engine/utils/audio.ts +0 -585
  174. package/src/engine/utils/data-paths.ts +0 -38
  175. package/src/engine/utils/diagnostics.ts +0 -109
  176. package/src/engine/utils/file-utils.test.ts +0 -72
  177. package/src/engine/utils/file-utils.ts +0 -225
  178. package/src/engine/utils/format.test.ts +0 -68
  179. package/src/engine/utils/format.ts +0 -70
  180. package/src/engine/utils/image-size.test.ts +0 -158
  181. package/src/engine/utils/image-size.ts +0 -249
  182. package/src/engine/utils/log.test.ts +0 -28
  183. package/src/engine/utils/log.ts +0 -61
  184. package/src/engine/utils/media-tags.test.ts +0 -32
  185. package/src/engine/utils/media-tags.ts +0 -177
  186. package/src/engine/utils/payload.test.ts +0 -68
  187. package/src/engine/utils/payload.ts +0 -145
  188. package/src/engine/utils/platform-storage-laziness.test.ts +0 -63
  189. package/src/engine/utils/platform.test.ts +0 -148
  190. package/src/engine/utils/platform.ts +0 -343
  191. package/src/engine/utils/request-context.ts +0 -60
  192. package/src/engine/utils/string-normalize.ts +0 -91
  193. package/src/engine/utils/stt.test.ts +0 -104
  194. package/src/engine/utils/stt.ts +0 -100
  195. package/src/engine/utils/text-parsing.test.ts +0 -29
  196. package/src/engine/utils/text-parsing.ts +0 -155
  197. package/src/engine/utils/upload-cache.ts +0 -96
  198. package/src/engine/utils/voice-text.ts +0 -15
  199. package/src/exec-approvals.ts +0 -218
  200. package/src/manifest-schema.test.ts +0 -56
  201. package/src/qqbot-test-support.ts +0 -29
  202. package/src/types.ts +0 -210
  203. package/tsconfig.json +0 -16
@@ -1,321 +0,0 @@
1
- /**
2
- * Group history cache — buffer non-@ messages and inject them as context
3
- * the next time the bot is @-ed in the same group.
4
- *
5
- * Lifecycle (per group):
6
- * 1. `recordPendingHistoryEntry` — called for every non-@ message that
7
- * should be remembered (the gate returns `skip_no_mention` /
8
- * `drop_other_mention`).
9
- * 2. `buildPendingHistoryContext` — called when the bot IS @-ed; wraps
10
- * the cached entries in context tags and prepends them to the
11
- * current user message.
12
- * 3. `clearPendingHistory` — called after the reply has been attempted
13
- * (success, timeout, or error) so the next @ starts fresh.
14
- *
15
- * The cache itself is a simple `Map<groupOpenid, HistoryEntry[]>` with an
16
- * LRU eviction policy both on the number of keys and on the per-key
17
- * length. No I/O, no external dependencies — the module is pure and
18
- * portable between the built-in and standalone plugin builds.
19
- */
20
-
21
- import type { RefAttachmentSummary } from "../ref/types.js";
22
- import { formatAttachmentTags } from "../utils/attachment-tags.js";
23
- import { parseFaceTags } from "../utils/text-parsing.js";
24
- import { stripMentionText, type RawMention } from "./mention.js";
25
-
26
- // Re-export so existing `from "group/history.js"` imports keep working.
27
- export { formatAttachmentTags } from "../utils/attachment-tags.js";
28
-
29
- // ───────────────────────────── Constants ─────────────────────────────
30
-
31
- /**
32
- * Tags wrapping history injected on the bot's current turn.
33
- *
34
- * Kept in English so downstream LLMs (which are multilingual but follow
35
- * instructions more reliably in English) parse the block structure
36
- * unambiguously, regardless of the user/bot conversation language.
37
- */
38
- const HISTORY_CTX_START = "[Chat messages since your last reply — CONTEXT ONLY]";
39
- const HISTORY_CTX_END = "[CURRENT MESSAGE — reply to this]";
40
-
41
- /** Tags wrapping merged sub-messages from the queue. */
42
- const MERGED_CTX_START = "[Merged earlier messages — CONTEXT ONLY]";
43
- const MERGED_CTX_END = "[CURRENT MESSAGE — reply using the context above]";
44
-
45
- /**
46
- * Upper bound on the number of concurrent group histories the cache will
47
- * retain. Prevents the Map from growing without bound in long-running
48
- * multi-group deployments. LRU-evict the least-recently-touched key once
49
- * this limit is exceeded.
50
- */
51
- const MAX_HISTORY_KEYS = 1000;
52
-
53
- // ───────────────────────────── Types ─────────────────────────────
54
-
55
- /**
56
- * Attachment descriptor used inside history entries.
57
- *
58
- * Aligned with `RefAttachmentSummary` so the three places that describe
59
- * attachments (group history cache, ref-index store, and the dynamic
60
- * context block on the current message) all share a single shape.
61
- */
62
- type AttachmentSummary = RefAttachmentSummary;
63
-
64
- /** Raw attachment fields carried in a QQ event (the union we actually read). */
65
- interface RawAttachment {
66
- content_type: string;
67
- filename?: string;
68
- /** Pre-computed ASR transcription text provided by QQ's gateway. */
69
- asr_refer_text?: string;
70
- url?: string;
71
- }
72
-
73
- /** One cached history entry. */
74
- export interface HistoryEntry {
75
- /** Display label for the sender (e.g. "Nick (OPENID)"). */
76
- sender: string;
77
- /** Message body already stripped / formatted for the AI. */
78
- body: string;
79
- timestamp?: number;
80
- messageId?: string;
81
- /** Rich-media attachments to render inline on @-activation. */
82
- attachments?: AttachmentSummary[];
83
- }
84
-
85
- /** Parameters for {@link formatMessageContent}. */
86
- interface FormatMessageContentParams {
87
- content: string;
88
- /** Message channel — `stripMentionText` only fires for `"group"`. */
89
- chatType?: string;
90
- mentions?: RawMention[];
91
- attachments?: RawAttachment[];
92
- }
93
-
94
- // ───────────────────────────── Content formatting ─────────────────────────────
95
-
96
- /** Map a raw QQ content-type string onto the normalized attachment type. */
97
- export function inferAttachmentType(contentType?: string): AttachmentSummary["type"] {
98
- const ct = (contentType ?? "").toLowerCase();
99
- if (ct.startsWith("image/")) {
100
- return "image";
101
- }
102
- if (ct === "voice" || ct.startsWith("audio/") || ct.includes("silk") || ct.includes("amr")) {
103
- return "voice";
104
- }
105
- if (ct.startsWith("video/")) {
106
- return "video";
107
- }
108
- if (ct.startsWith("application/") || ct.startsWith("text/")) {
109
- return "file";
110
- }
111
- return "unknown";
112
- }
113
-
114
- /**
115
- * Convert raw QQ-event attachments into `AttachmentSummary` entries.
116
- *
117
- * When `localPaths` is provided (from `ProcessedAttachments.attachmentLocalPaths`),
118
- * each summary is enriched with the local file path so that history context
119
- * renders the downloaded path instead of the ephemeral QQ CDN URL.
120
- *
121
- * Returns `undefined` (rather than `[]`) when no attachments are provided
122
- * so that callers can omit the field from their result objects.
123
- */
124
- export function toAttachmentSummaries(
125
- attachments?: RawAttachment[],
126
- localPaths?: Array<string | null>,
127
- ): AttachmentSummary[] | undefined {
128
- if (!attachments?.length) {
129
- return undefined;
130
- }
131
- return attachments.map(
132
- (att, i): AttachmentSummary => ({
133
- type: inferAttachmentType(att.content_type),
134
- filename: att.filename,
135
- transcript: att.asr_refer_text || undefined,
136
- localPath: localPaths?.[i] || undefined,
137
- url: att.url || undefined,
138
- }),
139
- );
140
- }
141
-
142
- /**
143
- * Format one sub-message: emoji parsing → mention cleanup → attachment tags.
144
- *
145
- * Used for the merged-message path where several queued messages are
146
- * rendered together. `parseFaceTags` and `stripMentionText` are imported
147
- * directly — both are pure utilities inside the same engine and do not
148
- * warrant DI overhead.
149
- */
150
- export function formatMessageContent(params: FormatMessageContentParams): string {
151
- let msgContent = parseFaceTags(params.content);
152
-
153
- if (params.chatType === "group" && params.mentions?.length) {
154
- msgContent = stripMentionText(msgContent, params.mentions);
155
- }
156
-
157
- if (params.attachments?.length) {
158
- const attachmentDesc = formatAttachmentTags(toAttachmentSummaries(params.attachments));
159
- if (attachmentDesc) {
160
- msgContent = `${msgContent} ${attachmentDesc}`;
161
- }
162
- }
163
-
164
- return msgContent;
165
- }
166
-
167
- // ───────────────────────────── Attachment tags ─────────────────────────────
168
- //
169
- // `formatAttachmentTags` lives in `utils/attachment-tags.ts` (the single
170
- // source of truth shared with the ref-index renderer). It is re-exported
171
- // from the top of this file so existing `from "group/history.js"` imports
172
- // continue to work.
173
-
174
- // ───────────────────────────── Internal LRU helpers ─────────────────────────────
175
-
176
- /**
177
- * LRU-evict the least-recently-inserted keys so the map never exceeds
178
- * `maxKeys`. Since `Map` iteration order is insertion order, removing
179
- * from the front gives us an LRU by insertion point.
180
- */
181
- function evictOldHistoryKeys<T>(
182
- historyMap: Map<string, T[]>,
183
- maxKeys: number = MAX_HISTORY_KEYS,
184
- ): void {
185
- if (historyMap.size <= maxKeys) {
186
- return;
187
- }
188
- const keysToDelete = historyMap.size - maxKeys;
189
- const iterator = historyMap.keys();
190
- for (let i = 0; i < keysToDelete; i++) {
191
- const key = iterator.next().value;
192
- if (key !== undefined) {
193
- historyMap.delete(key);
194
- }
195
- }
196
- }
197
-
198
- /**
199
- * Append one entry to a group's history. When the group's buffer exceeds
200
- * `limit`, the oldest entry is shifted off the front. The group's key is
201
- * re-inserted into the map so its LRU position is refreshed.
202
- */
203
- function appendHistoryEntry(params: {
204
- historyMap: Map<string, HistoryEntry[]>;
205
- historyKey: string;
206
- entry: HistoryEntry;
207
- limit: number;
208
- }): HistoryEntry[] {
209
- const { historyMap, historyKey, entry, limit } = params;
210
- if (limit <= 0) {
211
- return [];
212
- }
213
-
214
- const history = historyMap.get(historyKey) ?? [];
215
- history.push(entry);
216
- while (history.length > limit) {
217
- history.shift();
218
- }
219
- // Refresh insertion order so this key becomes the most recent.
220
- if (historyMap.has(historyKey)) {
221
- historyMap.delete(historyKey);
222
- }
223
- historyMap.set(historyKey, history);
224
- evictOldHistoryKeys(historyMap);
225
- return history;
226
- }
227
-
228
- // ───────────────────────────── Public API ─────────────────────────────
229
-
230
- /**
231
- * Record a non-@ message so it can be replayed on the next @-activation.
232
- *
233
- * No-op when `limit <= 0` (history disabled) or when `entry` is missing.
234
- * Returns the updated history list for the group.
235
- */
236
- export function recordPendingHistoryEntry(params: {
237
- historyMap: Map<string, HistoryEntry[]>;
238
- historyKey: string;
239
- entry?: HistoryEntry | null;
240
- limit: number;
241
- }): HistoryEntry[] {
242
- if (!params.entry || params.limit <= 0) {
243
- return [];
244
- }
245
- return appendHistoryEntry({
246
- historyMap: params.historyMap,
247
- historyKey: params.historyKey,
248
- entry: params.entry,
249
- limit: params.limit,
250
- });
251
- }
252
-
253
- /**
254
- * Build the full user-message string when the bot is @-ed, prefixing the
255
- * buffered non-@ chatter for context.
256
- *
257
- * Returns `currentMessage` unchanged when no history exists, when the
258
- * limit is zero, or when the buffer is empty.
259
- */
260
- export function buildPendingHistoryContext(params: {
261
- historyMap: Map<string, HistoryEntry[]>;
262
- historyKey: string;
263
- limit: number;
264
- currentMessage: string;
265
- formatEntry: (entry: HistoryEntry) => string;
266
- lineBreak?: string;
267
- }): string {
268
- if (params.limit <= 0) {
269
- return params.currentMessage;
270
- }
271
-
272
- const entries = params.historyMap.get(params.historyKey) ?? [];
273
- if (entries.length === 0) {
274
- return params.currentMessage;
275
- }
276
-
277
- const lineBreak = params.lineBreak ?? "\n";
278
- const historyText = entries.map(params.formatEntry).join(lineBreak);
279
-
280
- return [HISTORY_CTX_START, historyText, "", HISTORY_CTX_END, params.currentMessage].join(
281
- lineBreak,
282
- );
283
- }
284
-
285
- /**
286
- * Wrap a batch of merged messages with begin/end tags and append the
287
- * current user turn at the bottom.
288
- *
289
- * When `precedingParts` is empty, `currentMessage` is returned unchanged.
290
- */
291
- export function buildMergedMessageContext(params: {
292
- precedingParts: string[];
293
- currentMessage: string;
294
- lineBreak?: string;
295
- }): string {
296
- const { precedingParts, currentMessage } = params;
297
- if (precedingParts.length === 0) {
298
- return currentMessage;
299
- }
300
-
301
- const lineBreak = params.lineBreak ?? "\n";
302
- return [MERGED_CTX_START, precedingParts.join(lineBreak), MERGED_CTX_END, currentMessage].join(
303
- lineBreak,
304
- );
305
- }
306
-
307
- /**
308
- * Clear a group's pending history after a reply has been attempted.
309
- *
310
- * No-op when the feature is disabled (`limit <= 0`).
311
- */
312
- export function clearPendingHistory(params: {
313
- historyMap: Map<string, HistoryEntry[]>;
314
- historyKey: string;
315
- limit: number;
316
- }): void {
317
- if (params.limit <= 0) {
318
- return;
319
- }
320
- params.historyMap.set(params.historyKey, []);
321
- }
@@ -1,141 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
- import {
3
- detectWasMentioned,
4
- hasAnyMention,
5
- resolveImplicitMention,
6
- stripMentionText,
7
- } from "./mention.js";
8
-
9
- describe("engine/group/mention", () => {
10
- describe("detectWasMentioned", () => {
11
- it("returns true when mentions contains is_you", () => {
12
- expect(detectWasMentioned({ mentions: [{ is_you: true }] })).toBe(true);
13
- });
14
-
15
- it("returns true for GROUP_AT_MESSAGE_CREATE even without mentions", () => {
16
- expect(detectWasMentioned({ eventType: "GROUP_AT_MESSAGE_CREATE" })).toBe(true);
17
- });
18
-
19
- it("matches by mentionPatterns regex", () => {
20
- expect(
21
- detectWasMentioned({ content: "@xiaoke help me", mentionPatterns: ["^@xiaoke"] }),
22
- ).toBe(true);
23
- });
24
-
25
- it("returns false when no signal matches", () => {
26
- expect(
27
- detectWasMentioned({
28
- eventType: "GROUP_MESSAGE_CREATE",
29
- mentions: [{ member_openid: "USER1" }],
30
- content: "hello",
31
- mentionPatterns: ["^@bot"],
32
- }),
33
- ).toBe(false);
34
- });
35
-
36
- it("ignores invalid regex patterns gracefully", () => {
37
- // "[" is an invalid regex; should not throw.
38
- expect(detectWasMentioned({ content: "hi", mentionPatterns: ["[", "@bot"] })).toBe(false);
39
- });
40
-
41
- it("matches case-insensitively", () => {
42
- expect(detectWasMentioned({ content: "Hello @Bot", mentionPatterns: ["@bot"] })).toBe(true);
43
- });
44
-
45
- it("skips empty patterns", () => {
46
- expect(detectWasMentioned({ content: "hi", mentionPatterns: ["", " "] })).toBe(false);
47
- });
48
-
49
- it("returns false when everything is empty", () => {
50
- expect(detectWasMentioned({})).toBe(false);
51
- });
52
- });
53
-
54
- describe("hasAnyMention", () => {
55
- it("detects mentions array", () => {
56
- expect(hasAnyMention({ mentions: [{ member_openid: "X" }] })).toBe(true);
57
- });
58
-
59
- it("detects mention tags in text", () => {
60
- expect(hasAnyMention({ content: "hi <@ABC123>" })).toBe(true);
61
- expect(hasAnyMention({ content: "hi <@!ABC123>" })).toBe(true);
62
- });
63
-
64
- it("returns false when nothing mentioned", () => {
65
- expect(hasAnyMention({ content: "just a normal message" })).toBe(false);
66
- expect(hasAnyMention({})).toBe(false);
67
- });
68
- });
69
-
70
- describe("stripMentionText", () => {
71
- it("removes self-mention tag", () => {
72
- expect(stripMentionText("<@BOTID> hello", [{ member_openid: "BOTID", is_you: true }])).toBe(
73
- "hello",
74
- );
75
- });
76
-
77
- it("replaces other-user tag with @nickname", () => {
78
- expect(stripMentionText("hi <@USER1>", [{ member_openid: "USER1", nickname: "Alice" }])).toBe(
79
- "hi @Alice",
80
- );
81
- });
82
-
83
- it("falls back to username when nickname missing", () => {
84
- expect(stripMentionText("hi <@USER1>", [{ member_openid: "USER1", username: "alice" }])).toBe(
85
- "hi @alice",
86
- );
87
- });
88
-
89
- it("leaves unknown mentions untouched", () => {
90
- // No display name, so the tag cannot be prettified — keep raw.
91
- expect(stripMentionText("hi <@USER1>", [{ member_openid: "USER1" }])).toBe("hi <@USER1>");
92
- });
93
-
94
- it("handles <@!openid> variant", () => {
95
- expect(stripMentionText("hi <@!USER1>", [{ member_openid: "USER1", nickname: "A" }])).toBe(
96
- "hi @A",
97
- );
98
- });
99
-
100
- it("returns the original text when no mentions array is provided", () => {
101
- expect(stripMentionText("hi <@X>", [])).toBe("hi <@X>");
102
- expect(stripMentionText("hi <@X>")).toBe("hi <@X>");
103
- });
104
-
105
- it("escapes regex meta-characters in openid", () => {
106
- // Defensive: even if QQ ever sends openids with unusual characters,
107
- // the function should not explode nor produce a bogus regex.
108
- expect(stripMentionText("see <@A.B+C>", [{ member_openid: "A.B+C", nickname: "X" }])).toBe(
109
- "see @X",
110
- );
111
- });
112
- });
113
-
114
- describe("resolveImplicitMention", () => {
115
- it("returns false when refMsgIdx is missing", () => {
116
- expect(resolveImplicitMention({ getRefEntry: () => null })).toBe(false);
117
- });
118
-
119
- it("returns true when the referenced entry is a bot message", () => {
120
- expect(
121
- resolveImplicitMention({
122
- refMsgIdx: "R1",
123
- getRefEntry: (id) => (id === "R1" ? { isBot: true } : null),
124
- }),
125
- ).toBe(true);
126
- });
127
-
128
- it("returns false when ref entry exists but is not a bot", () => {
129
- expect(
130
- resolveImplicitMention({
131
- refMsgIdx: "R1",
132
- getRefEntry: () => ({ isBot: false }),
133
- }),
134
- ).toBe(false);
135
- });
136
-
137
- it("returns false when ref entry is missing", () => {
138
- expect(resolveImplicitMention({ refMsgIdx: "R1", getRefEntry: () => null })).toBe(false);
139
- });
140
- });
141
- });
@@ -1,197 +0,0 @@
1
- /**
2
- * QQBot group @mention detection and text normalization.
3
- *
4
- * Pure functions extracted from the standalone build (`openclaw-qqbot/src/
5
- * channel.ts::detectWasMentioned` / `stripMentionText`) plus the helper
6
- * `hasAnyMention` that previously lived inline in `gateway.ts` and the
7
- * `resolveImplicitMention` predicate that decides whether a quoted-reply
8
- * should count as an implicit @bot.
9
- *
10
- * Keeping these helpers together makes it easier to test the group gating
11
- * pipeline and lets both the built-in and standalone builds share a
12
- * single mention-detection implementation.
13
- */
14
-
15
- // ============ Types ============
16
-
17
- /**
18
- * Raw mention entry shape used across QQ Bot group events.
19
- *
20
- * QQ's `mentions` array uses slightly different field names on different
21
- * event types (the bot's self-mention comes as `is_you: true`; user IDs
22
- * can appear in any of `member_openid` / `id` / `user_openid`). This type
23
- * captures the union so callers don't have to worry about which variant.
24
- */
25
- export interface RawMention {
26
- /** Whether this mention targets the bot itself. */
27
- is_you?: boolean;
28
- /** Whether the mention target is another bot. */
29
- bot?: boolean;
30
- /** Member openid in group chats. */
31
- member_openid?: string;
32
- /** Event-level id (guild context). */
33
- id?: string;
34
- /** User openid (C2C context). */
35
- user_openid?: string;
36
- /** Display name. */
37
- nickname?: string;
38
- /** Alternative display name. */
39
- username?: string;
40
- /** @all / @single scope (QQ guild events). */
41
- scope?: "all" | "single";
42
- }
43
-
44
- /** Input for {@link detectWasMentioned}. */
45
- interface DetectWasMentionedInput {
46
- /**
47
- * Raw event type. `"GROUP_AT_MESSAGE_CREATE"` unambiguously identifies
48
- * that the bot was @-ed, even when the mentions array is empty.
49
- */
50
- eventType?: string;
51
- mentions?: RawMention[];
52
- /** Raw message content — used as a regex fallback via `mentionPatterns`. */
53
- content?: string;
54
- /**
55
- * Regex patterns matched against `content` when neither `mentions.is_you`
56
- * nor `eventType` prove a bot mention. Invalid patterns are ignored.
57
- */
58
- mentionPatterns?: string[];
59
- }
60
-
61
- /** Input for {@link hasAnyMention}. */
62
- interface HasAnyMentionInput {
63
- mentions?: RawMention[];
64
- content?: string;
65
- }
66
-
67
- // ============ Constants ============
68
-
69
- /** Regex detecting `<@openid>` / `<@!openid>` mention tags in raw content. */
70
- const MENTION_TAG_RE = /<@!?\w+>/;
71
-
72
- // ============ Public API ============
73
-
74
- /**
75
- * Detect whether the inbound message explicitly targets the bot.
76
- *
77
- * Priority order:
78
- * 1. `mentions[].is_you === true` (most reliable)
79
- * 2. `eventType === "GROUP_AT_MESSAGE_CREATE"` (QQ-level @bot event)
80
- * 3. regex match on any of `mentionPatterns` (fallback, e.g. "@bot-name")
81
- *
82
- * Returns `false` for direct messages or when no signal is found.
83
- */
84
- export function detectWasMentioned(input: DetectWasMentionedInput): boolean {
85
- const { eventType, mentions, content, mentionPatterns } = input;
86
-
87
- if (mentions?.some((m) => m.is_you)) {
88
- return true;
89
- }
90
-
91
- if (eventType === "GROUP_AT_MESSAGE_CREATE") {
92
- return true;
93
- }
94
-
95
- if (mentionPatterns?.length && content) {
96
- for (const pattern of mentionPatterns) {
97
- if (!pattern) {
98
- continue;
99
- }
100
- try {
101
- if (new RegExp(pattern, "i").test(content)) {
102
- return true;
103
- }
104
- } catch {
105
- // Invalid regex — skip silently; bad patterns must not crash the pipeline.
106
- }
107
- }
108
- }
109
-
110
- return false;
111
- }
112
-
113
- /**
114
- * Report whether the message contains **any** @mention (not necessarily @bot).
115
- *
116
- * Used by the gating layer to decide whether to bypass mention requirements
117
- * for control commands. A control command like `/stop` that also @-s another
118
- * user should NOT bypass the mention gate — the `@other-user` prefix is a
119
- * strong signal that the command wasn't addressed to the bot.
120
- */
121
- export function hasAnyMention(input: HasAnyMentionInput): boolean {
122
- if (input.mentions && input.mentions.length > 0) {
123
- return true;
124
- }
125
- if (input.content && MENTION_TAG_RE.test(input.content)) {
126
- return true;
127
- }
128
- return false;
129
- }
130
-
131
- /**
132
- * Clean up `<@openid>` mention tags in raw QQ group content.
133
- *
134
- * - For the bot's own mention (`is_you === true`): the tag is removed
135
- * outright so prompts don't contain visible `<@BOTID>` garbage.
136
- * - For other mentioned users: the tag is replaced with `@nickname` (or
137
- * `@username`) for readability. Entries without a display name are left
138
- * as-is (rare in practice).
139
- *
140
- * Returns the original text unchanged when `text` or `mentions` is empty.
141
- */
142
- export function stripMentionText(text: string, mentions?: RawMention[]): string {
143
- if (!text || !mentions?.length) {
144
- return text;
145
- }
146
- let cleaned = text;
147
- for (const m of mentions) {
148
- const openid = m.member_openid ?? m.id ?? m.user_openid;
149
- if (!openid) {
150
- continue;
151
- }
152
- // RegExp: match both `<@openid>` and `<@!openid>` variants.
153
- const tagRe = new RegExp(`<@!?${escapeRegex(openid)}>`, "g");
154
- if (m.is_you) {
155
- cleaned = cleaned.replace(tagRe, "").trim();
156
- } else {
157
- const displayName = m.nickname ?? m.username;
158
- if (displayName) {
159
- cleaned = cleaned.replace(tagRe, `@${displayName}`);
160
- }
161
- }
162
- }
163
- return cleaned;
164
- }
165
-
166
- // ============ Internal helpers ============
167
-
168
- /** Escape characters that carry regex meaning. */
169
- function escapeRegex(str: string): string {
170
- return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
171
- }
172
-
173
- // ============ Implicit mention (quoted bot message) ============
174
-
175
- /**
176
- * Decide whether a quoted-reply should count as an implicit @bot.
177
- *
178
- * When the user quotes an earlier bot message, we treat the new message
179
- * as if it @-ed the bot, even without a literal mention. This lives in
180
- * the mention module (rather than with activation) because semantically
181
- * it answers the same question as `detectWasMentioned`:
182
- * "was the bot addressed by this message?".
183
- *
184
- * The `getRefEntry` callback is injected so this function does not
185
- * depend on the ref-index store implementation — any lookup that
186
- * returns `{ isBot?: boolean }` works.
187
- */
188
- export function resolveImplicitMention(params: {
189
- refMsgIdx?: string;
190
- getRefEntry: (idx: string) => { isBot?: boolean } | null;
191
- }): boolean {
192
- if (!params.refMsgIdx) {
193
- return false;
194
- }
195
- const refEntry = params.getRefEntry(params.refMsgIdx);
196
- return refEntry?.isBot === true;
197
- }