@openclaw/qqbot 2026.5.2 → 2026.5.3-beta.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.
- package/dist/api.js +646 -0
- package/dist/approval-cg0SVahb.js +94 -0
- package/dist/channel-N6Y_Rcjp.js +551 -0
- package/dist/channel-plugin-api.js +2 -0
- package/dist/channel.setup-ByCRAe5H.js +25 -0
- package/dist/config-D6545NkC.js +94 -0
- package/dist/config-schema-DFcjQw73.js +309 -0
- package/dist/exec-approvals-COUsM6wZ.js +138 -0
- package/dist/gateway-CmSUJKSt.js +6726 -0
- package/dist/handler-runtime-Bqm6N0WG.js +120 -0
- package/dist/index.js +29 -0
- package/dist/narrowing-BoieBTIU.js +25 -0
- package/dist/outbound-BJfhwrPg.js +1357 -0
- package/dist/request-context-DXtpwWui.js +921 -0
- package/dist/resolve-D_06fV6-.js +284 -0
- package/dist/runtime-BJAS3eXW.js +18 -0
- package/dist/runtime-api.js +2 -0
- package/dist/secret-contract-api.js +56 -0
- package/dist/sender-p-B14eLG.js +1987 -0
- package/dist/setup-entry.js +15 -0
- package/dist/setup-plugin-api.js +2 -0
- package/dist/string-normalize-Ci6NM5DE.js +98 -0
- package/dist/target-parser-Y0prnrXD.js +245 -0
- package/package.json +15 -6
- package/api.ts +0 -56
- package/channel-plugin-api.ts +0 -1
- package/index.ts +0 -29
- package/runtime-api.ts +0 -9
- package/setup-entry.ts +0 -9
- package/setup-plugin-api.ts +0 -3
- package/src/bridge/approval/capability.ts +0 -237
- package/src/bridge/approval/handler-runtime.ts +0 -204
- package/src/bridge/bootstrap.ts +0 -135
- package/src/bridge/channel-entry.ts +0 -18
- package/src/bridge/commands/framework-context-adapter.ts +0 -60
- package/src/bridge/commands/framework-registration.ts +0 -47
- package/src/bridge/commands/from-parser.test.ts +0 -86
- package/src/bridge/commands/from-parser.ts +0 -60
- package/src/bridge/commands/result-dispatcher.ts +0 -76
- package/src/bridge/config-shared.ts +0 -132
- package/src/bridge/config.ts +0 -111
- package/src/bridge/gateway.ts +0 -174
- package/src/bridge/logger.ts +0 -31
- package/src/bridge/narrowing.ts +0 -31
- package/src/bridge/plugin-version.test.ts +0 -146
- package/src/bridge/plugin-version.ts +0 -102
- package/src/bridge/runtime.ts +0 -25
- package/src/bridge/sdk-adapter.ts +0 -131
- package/src/bridge/setup/finalize.ts +0 -144
- package/src/bridge/setup/surface.ts +0 -34
- package/src/bridge/tools/channel.ts +0 -58
- package/src/bridge/tools/index.ts +0 -15
- package/src/bridge/tools/remind.test.ts +0 -124
- package/src/bridge/tools/remind.ts +0 -91
- package/src/channel.setup.ts +0 -33
- package/src/channel.ts +0 -288
- package/src/command-auth.test.ts +0 -62
- package/src/config-schema.ts +0 -84
- package/src/config.test.ts +0 -364
- package/src/engine/access/access-control.test.ts +0 -198
- package/src/engine/access/access-control.ts +0 -226
- package/src/engine/access/index.ts +0 -16
- package/src/engine/access/resolve-policy.test.ts +0 -59
- package/src/engine/access/resolve-policy.ts +0 -57
- package/src/engine/access/sender-match.test.ts +0 -60
- package/src/engine/access/sender-match.ts +0 -55
- package/src/engine/access/types.ts +0 -53
- package/src/engine/adapter/audio.port.ts +0 -27
- package/src/engine/adapter/commands.port.ts +0 -22
- package/src/engine/adapter/history.port.ts +0 -52
- package/src/engine/adapter/index.ts +0 -139
- package/src/engine/adapter/mention-gate.port.ts +0 -50
- package/src/engine/adapter/types.ts +0 -38
- package/src/engine/api/api-client.ts +0 -212
- package/src/engine/api/media-chunked.test.ts +0 -336
- package/src/engine/api/media-chunked.ts +0 -622
- package/src/engine/api/media.ts +0 -218
- package/src/engine/api/messages.ts +0 -293
- package/src/engine/api/retry.ts +0 -217
- package/src/engine/api/routes.ts +0 -95
- package/src/engine/api/token.ts +0 -271
- package/src/engine/approval/index.test.ts +0 -22
- package/src/engine/approval/index.ts +0 -224
- package/src/engine/commands/builtin/log-helpers.ts +0 -319
- package/src/engine/commands/builtin/register-all.ts +0 -17
- package/src/engine/commands/builtin/register-approve.ts +0 -201
- package/src/engine/commands/builtin/register-basic.ts +0 -95
- package/src/engine/commands/builtin/register-clear-storage.ts +0 -187
- package/src/engine/commands/builtin/register-logs.ts +0 -20
- package/src/engine/commands/builtin/register-streaming.ts +0 -137
- package/src/engine/commands/builtin/state.ts +0 -31
- package/src/engine/commands/slash-command-auth.ts +0 -48
- package/src/engine/commands/slash-command-handler.ts +0 -146
- package/src/engine/commands/slash-commands-impl.test.ts +0 -8
- package/src/engine/commands/slash-commands-impl.ts +0 -61
- package/src/engine/commands/slash-commands.ts +0 -199
- package/src/engine/config/credential-backup.test.ts +0 -88
- package/src/engine/config/credential-backup.ts +0 -107
- package/src/engine/config/credentials.ts +0 -76
- package/src/engine/config/group.test.ts +0 -234
- package/src/engine/config/group.ts +0 -299
- package/src/engine/config/resolve.test.ts +0 -152
- package/src/engine/config/resolve.ts +0 -283
- package/src/engine/config/setup-logic.ts +0 -84
- package/src/engine/engine-import-boundary.test.ts +0 -73
- package/src/engine/gateway/codec.ts +0 -47
- package/src/engine/gateway/constants.ts +0 -117
- package/src/engine/gateway/event-dispatcher.ts +0 -177
- package/src/engine/gateway/gateway-connection.ts +0 -371
- package/src/engine/gateway/gateway.ts +0 -291
- package/src/engine/gateway/inbound-attachments.test.ts +0 -126
- package/src/engine/gateway/inbound-attachments.ts +0 -360
- package/src/engine/gateway/inbound-context.ts +0 -195
- package/src/engine/gateway/inbound-pipeline.self-echo.test.ts +0 -218
- package/src/engine/gateway/inbound-pipeline.ts +0 -235
- package/src/engine/gateway/interaction-handler.ts +0 -220
- package/src/engine/gateway/message-queue.test.ts +0 -282
- package/src/engine/gateway/message-queue.ts +0 -499
- package/src/engine/gateway/outbound-dispatch.test.ts +0 -231
- package/src/engine/gateway/outbound-dispatch.ts +0 -575
- package/src/engine/gateway/reconnect.ts +0 -199
- package/src/engine/gateway/stages/access-stage.ts +0 -132
- package/src/engine/gateway/stages/assembly-stage.ts +0 -156
- package/src/engine/gateway/stages/content-stage.test.ts +0 -77
- package/src/engine/gateway/stages/content-stage.ts +0 -77
- package/src/engine/gateway/stages/envelope-stage.test.ts +0 -152
- package/src/engine/gateway/stages/envelope-stage.ts +0 -144
- package/src/engine/gateway/stages/group-gate-stage.ts +0 -292
- package/src/engine/gateway/stages/index.ts +0 -18
- package/src/engine/gateway/stages/quote-stage.ts +0 -113
- package/src/engine/gateway/stages/refidx-stage.ts +0 -62
- package/src/engine/gateway/stages/stub-contexts.ts +0 -116
- package/src/engine/gateway/types.ts +0 -264
- package/src/engine/gateway/typing-keepalive.ts +0 -79
- package/src/engine/group/activation.test.ts +0 -114
- package/src/engine/group/activation.ts +0 -147
- package/src/engine/group/history.test.ts +0 -314
- package/src/engine/group/history.ts +0 -321
- package/src/engine/group/mention.test.ts +0 -141
- package/src/engine/group/mention.ts +0 -197
- package/src/engine/group/message-gating.test.ts +0 -188
- package/src/engine/group/message-gating.ts +0 -216
- package/src/engine/messaging/decode-media-path.ts +0 -82
- package/src/engine/messaging/media-source.ts +0 -215
- package/src/engine/messaging/media-type-detect.ts +0 -37
- package/src/engine/messaging/outbound-audio-port.ts +0 -38
- package/src/engine/messaging/outbound-deliver.ts +0 -810
- package/src/engine/messaging/outbound-media-send.ts +0 -702
- package/src/engine/messaging/outbound-reply.ts +0 -27
- package/src/engine/messaging/outbound-result-helpers.ts +0 -54
- package/src/engine/messaging/outbound-types.ts +0 -45
- package/src/engine/messaging/outbound.ts +0 -485
- package/src/engine/messaging/reply-dispatcher.ts +0 -597
- package/src/engine/messaging/reply-limiter.ts +0 -164
- package/src/engine/messaging/sender.ts +0 -729
- package/src/engine/messaging/streaming-c2c.ts +0 -1192
- package/src/engine/messaging/streaming-media-send.ts +0 -544
- package/src/engine/messaging/target-parser.ts +0 -104
- package/src/engine/ref/format-message-ref.ts +0 -142
- package/src/engine/ref/format-ref-entry.test.ts +0 -60
- package/src/engine/ref/format-ref-entry.ts +0 -27
- package/src/engine/ref/store.ts +0 -208
- package/src/engine/ref/types.ts +0 -27
- package/src/engine/session/known-users.ts +0 -137
- package/src/engine/session/session-store.ts +0 -204
- package/src/engine/tools/channel-api.ts +0 -244
- package/src/engine/tools/remind-logic.test.ts +0 -280
- package/src/engine/tools/remind-logic.ts +0 -377
- package/src/engine/types.ts +0 -313
- package/src/engine/utils/attachment-tags.test.ts +0 -186
- package/src/engine/utils/attachment-tags.ts +0 -174
- package/src/engine/utils/audio.test.ts +0 -250
- package/src/engine/utils/audio.ts +0 -585
- package/src/engine/utils/data-paths.ts +0 -38
- package/src/engine/utils/diagnostics.ts +0 -109
- package/src/engine/utils/file-utils.test.ts +0 -72
- package/src/engine/utils/file-utils.ts +0 -225
- package/src/engine/utils/format.test.ts +0 -68
- package/src/engine/utils/format.ts +0 -70
- package/src/engine/utils/image-size.test.ts +0 -158
- package/src/engine/utils/image-size.ts +0 -249
- package/src/engine/utils/log.test.ts +0 -28
- package/src/engine/utils/log.ts +0 -61
- package/src/engine/utils/media-tags.test.ts +0 -32
- package/src/engine/utils/media-tags.ts +0 -177
- package/src/engine/utils/payload.test.ts +0 -68
- package/src/engine/utils/payload.ts +0 -145
- package/src/engine/utils/platform-storage-laziness.test.ts +0 -63
- package/src/engine/utils/platform.test.ts +0 -148
- package/src/engine/utils/platform.ts +0 -343
- package/src/engine/utils/request-context.ts +0 -60
- package/src/engine/utils/string-normalize.ts +0 -91
- package/src/engine/utils/stt.test.ts +0 -104
- package/src/engine/utils/stt.ts +0 -100
- package/src/engine/utils/text-parsing.test.ts +0 -29
- package/src/engine/utils/text-parsing.ts +0 -155
- package/src/engine/utils/upload-cache.ts +0 -96
- package/src/engine/utils/voice-text.ts +0 -15
- package/src/exec-approvals.ts +0 -218
- package/src/manifest-schema.test.ts +0 -56
- package/src/qqbot-test-support.ts +0 -29
- package/src/types.ts +0 -210
- 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
|
-
}
|