@gakr-gakr/qqbot 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) hide show
  1. package/api.ts +56 -0
  2. package/autobot.plugin.json +167 -0
  3. package/channel-plugin-api.ts +1 -0
  4. package/index.ts +33 -0
  5. package/package.json +64 -0
  6. package/runtime-api.ts +9 -0
  7. package/secret-contract-api.ts +5 -0
  8. package/setup-entry.ts +13 -0
  9. package/setup-plugin-api.ts +3 -0
  10. package/skills/qqbot-channel/SKILL.md +262 -0
  11. package/skills/qqbot-channel/references/api_references.md +521 -0
  12. package/skills/qqbot-media/SKILL.md +37 -0
  13. package/skills/qqbot-remind/SKILL.md +153 -0
  14. package/src/bridge/approval/capability.ts +225 -0
  15. package/src/bridge/approval/handler-runtime.ts +204 -0
  16. package/src/bridge/bootstrap.ts +135 -0
  17. package/src/bridge/channel-entry.ts +18 -0
  18. package/src/bridge/commands/framework-context-adapter.ts +60 -0
  19. package/src/bridge/commands/framework-registration.ts +66 -0
  20. package/src/bridge/commands/from-parser.ts +60 -0
  21. package/src/bridge/commands/result-dispatcher.ts +76 -0
  22. package/src/bridge/config-shared.ts +132 -0
  23. package/src/bridge/config.ts +176 -0
  24. package/src/bridge/gateway.ts +178 -0
  25. package/src/bridge/logger.ts +31 -0
  26. package/src/bridge/narrowing.ts +31 -0
  27. package/src/bridge/plugin-version.ts +102 -0
  28. package/src/bridge/runtime.ts +25 -0
  29. package/src/bridge/sdk-adapter.ts +164 -0
  30. package/src/bridge/setup/finalize.ts +144 -0
  31. package/src/bridge/setup/surface.ts +34 -0
  32. package/src/bridge/tools/channel.ts +58 -0
  33. package/src/bridge/tools/index.ts +15 -0
  34. package/src/bridge/tools/remind.ts +91 -0
  35. package/src/channel.setup.ts +33 -0
  36. package/src/channel.ts +399 -0
  37. package/src/config-schema.ts +84 -0
  38. package/src/engine/access/index.ts +2 -0
  39. package/src/engine/access/resolve-policy.ts +30 -0
  40. package/src/engine/access/sender-match.ts +55 -0
  41. package/src/engine/access/types.ts +2 -0
  42. package/src/engine/adapter/audio.port.ts +27 -0
  43. package/src/engine/adapter/commands.port.ts +22 -0
  44. package/src/engine/adapter/history.port.ts +52 -0
  45. package/src/engine/adapter/index.ts +76 -0
  46. package/src/engine/adapter/mention-gate.port.ts +50 -0
  47. package/src/engine/adapter/types.ts +38 -0
  48. package/src/engine/api/api-client.ts +212 -0
  49. package/src/engine/api/media-chunked.ts +644 -0
  50. package/src/engine/api/media.ts +218 -0
  51. package/src/engine/api/messages.ts +293 -0
  52. package/src/engine/api/retry.ts +217 -0
  53. package/src/engine/api/routes.ts +95 -0
  54. package/src/engine/api/token.ts +277 -0
  55. package/src/engine/approval/index.ts +224 -0
  56. package/src/engine/commands/builtin/log-helpers.ts +341 -0
  57. package/src/engine/commands/builtin/register-all.ts +17 -0
  58. package/src/engine/commands/builtin/register-approve.ts +201 -0
  59. package/src/engine/commands/builtin/register-basic.ts +95 -0
  60. package/src/engine/commands/builtin/register-clear-storage.ts +187 -0
  61. package/src/engine/commands/builtin/register-logs.ts +20 -0
  62. package/src/engine/commands/builtin/register-streaming.ts +138 -0
  63. package/src/engine/commands/builtin/state.ts +31 -0
  64. package/src/engine/commands/slash-command-auth.ts +88 -0
  65. package/src/engine/commands/slash-command-handler.ts +168 -0
  66. package/src/engine/commands/slash-command-test-support.ts +39 -0
  67. package/src/engine/commands/slash-commands-impl.ts +61 -0
  68. package/src/engine/commands/slash-commands.ts +202 -0
  69. package/src/engine/config/credential-backup.ts +108 -0
  70. package/src/engine/config/credentials.ts +76 -0
  71. package/src/engine/config/group.ts +227 -0
  72. package/src/engine/config/resolve.ts +283 -0
  73. package/src/engine/config/setup-logic.ts +84 -0
  74. package/src/engine/gateway/active-cfg.ts +52 -0
  75. package/src/engine/gateway/codec.ts +47 -0
  76. package/src/engine/gateway/constants.ts +117 -0
  77. package/src/engine/gateway/event-dispatcher.ts +177 -0
  78. package/src/engine/gateway/gateway-connection.ts +356 -0
  79. package/src/engine/gateway/gateway.ts +267 -0
  80. package/src/engine/gateway/inbound-attachments.ts +360 -0
  81. package/src/engine/gateway/inbound-context.ts +82 -0
  82. package/src/engine/gateway/inbound-pipeline.ts +171 -0
  83. package/src/engine/gateway/interaction-handler.ts +345 -0
  84. package/src/engine/gateway/message-queue.ts +404 -0
  85. package/src/engine/gateway/outbound-dispatch.ts +590 -0
  86. package/src/engine/gateway/reconnect.ts +199 -0
  87. package/src/engine/gateway/stages/access-stage.ts +99 -0
  88. package/src/engine/gateway/stages/assembly-stage.ts +156 -0
  89. package/src/engine/gateway/stages/content-stage.ts +77 -0
  90. package/src/engine/gateway/stages/envelope-stage.ts +144 -0
  91. package/src/engine/gateway/stages/group-gate-stage.ts +223 -0
  92. package/src/engine/gateway/stages/index.ts +18 -0
  93. package/src/engine/gateway/stages/quote-stage.ts +113 -0
  94. package/src/engine/gateway/stages/refidx-stage.ts +62 -0
  95. package/src/engine/gateway/stages/stub-contexts.ts +77 -0
  96. package/src/engine/gateway/types.ts +230 -0
  97. package/src/engine/gateway/typing-keepalive.ts +102 -0
  98. package/src/engine/gateway/ws-client.ts +16 -0
  99. package/src/engine/group/activation.ts +88 -0
  100. package/src/engine/group/history.ts +321 -0
  101. package/src/engine/group/mention.ts +114 -0
  102. package/src/engine/group/message-gating.ts +108 -0
  103. package/src/engine/messaging/decode-media-path.ts +82 -0
  104. package/src/engine/messaging/media-source.ts +210 -0
  105. package/src/engine/messaging/media-type-detect.ts +27 -0
  106. package/src/engine/messaging/outbound-audio-port.ts +38 -0
  107. package/src/engine/messaging/outbound-deliver.ts +810 -0
  108. package/src/engine/messaging/outbound-media-send.ts +658 -0
  109. package/src/engine/messaging/outbound-reply.ts +27 -0
  110. package/src/engine/messaging/outbound-result-helpers.ts +54 -0
  111. package/src/engine/messaging/outbound-types.ts +47 -0
  112. package/src/engine/messaging/outbound.ts +485 -0
  113. package/src/engine/messaging/reply-dispatcher.ts +597 -0
  114. package/src/engine/messaging/reply-limiter.ts +164 -0
  115. package/src/engine/messaging/sender.ts +741 -0
  116. package/src/engine/messaging/streaming-c2c.ts +1192 -0
  117. package/src/engine/messaging/streaming-media-send.ts +544 -0
  118. package/src/engine/messaging/target-parser.ts +104 -0
  119. package/src/engine/ref/format-message-ref.ts +142 -0
  120. package/src/engine/ref/format-ref-entry.ts +27 -0
  121. package/src/engine/ref/store.ts +211 -0
  122. package/src/engine/ref/types.ts +27 -0
  123. package/src/engine/session/known-users.ts +138 -0
  124. package/src/engine/session/session-store.ts +207 -0
  125. package/src/engine/tools/channel-api.ts +244 -0
  126. package/src/engine/tools/remind-logic.ts +377 -0
  127. package/src/engine/types.ts +313 -0
  128. package/src/engine/utils/attachment-tags.ts +174 -0
  129. package/src/engine/utils/audio.ts +525 -0
  130. package/src/engine/utils/data-paths.ts +38 -0
  131. package/src/engine/utils/diagnostics.ts +93 -0
  132. package/src/engine/utils/file-utils.ts +215 -0
  133. package/src/engine/utils/format.ts +70 -0
  134. package/src/engine/utils/image-size.ts +249 -0
  135. package/src/engine/utils/log.ts +77 -0
  136. package/src/engine/utils/media-tags.ts +177 -0
  137. package/src/engine/utils/payload.ts +157 -0
  138. package/src/engine/utils/platform.ts +265 -0
  139. package/src/engine/utils/request-context.ts +60 -0
  140. package/src/engine/utils/string-normalize.ts +91 -0
  141. package/src/engine/utils/stt.ts +103 -0
  142. package/src/engine/utils/text-parsing.ts +155 -0
  143. package/src/engine/utils/upload-cache.ts +96 -0
  144. package/src/engine/utils/voice-text.ts +15 -0
  145. package/src/exec-approvals.ts +237 -0
  146. package/src/qqbot-test-support.ts +29 -0
  147. package/src/secret-contract.ts +82 -0
  148. package/src/types.ts +210 -0
  149. package/tsconfig.json +16 -0
@@ -0,0 +1,321 @@
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
+ }
@@ -0,0 +1,114 @@
1
+ export interface RawMention {
2
+ is_you?: boolean;
3
+ bot?: boolean;
4
+ member_openid?: string;
5
+ id?: string;
6
+ user_openid?: string;
7
+ nickname?: string;
8
+ username?: string;
9
+ scope?: "all" | "single";
10
+ }
11
+
12
+ interface DetectWasMentionedInput {
13
+ eventType?: string;
14
+ mentions?: RawMention[];
15
+ content?: string;
16
+ mentionPatterns?: string[];
17
+ }
18
+
19
+ interface HasAnyMentionInput {
20
+ mentions?: RawMention[];
21
+ content?: string;
22
+ }
23
+
24
+ const MENTION_TAG_RE = /<@!?\w+>/;
25
+
26
+ export function detectWasMentioned(input: DetectWasMentionedInput): boolean {
27
+ const { eventType, mentions, content, mentionPatterns } = input;
28
+
29
+ if (mentions?.some((m) => m.is_you)) {
30
+ return true;
31
+ }
32
+
33
+ if (eventType === "GROUP_AT_MESSAGE_CREATE") {
34
+ return true;
35
+ }
36
+
37
+ if (mentionPatterns?.length && content) {
38
+ for (const pattern of mentionPatterns) {
39
+ if (!pattern) {
40
+ continue;
41
+ }
42
+ try {
43
+ if (new RegExp(pattern, "i").test(content)) {
44
+ return true;
45
+ }
46
+ } catch {}
47
+ }
48
+ }
49
+
50
+ return false;
51
+ }
52
+
53
+ export function hasAnyMention(input: HasAnyMentionInput): boolean {
54
+ if (input.mentions && input.mentions.length > 0) {
55
+ return true;
56
+ }
57
+ if (input.content && MENTION_TAG_RE.test(input.content)) {
58
+ return true;
59
+ }
60
+ return false;
61
+ }
62
+
63
+ export function stripMentionText(text: string, mentions?: RawMention[]): string {
64
+ if (!text || !mentions?.length) {
65
+ return text;
66
+ }
67
+ let cleaned = text;
68
+ for (const m of mentions) {
69
+ const openid = m.member_openid ?? m.id ?? m.user_openid;
70
+ if (!openid) {
71
+ continue;
72
+ }
73
+ const tagRe = new RegExp(`<@!?${escapeRegex(openid)}>`, "g");
74
+ if (m.is_you) {
75
+ cleaned = cleaned.replace(tagRe, "").trim();
76
+ } else {
77
+ const displayName = m.nickname ?? m.username;
78
+ if (displayName) {
79
+ cleaned = cleaned.replace(tagRe, `@${displayName}`);
80
+ }
81
+ }
82
+ }
83
+ return cleaned;
84
+ }
85
+
86
+ function escapeRegex(str: string): string {
87
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
88
+ }
89
+
90
+ // ============ Implicit mention (quoted bot message) ============
91
+
92
+ /**
93
+ * Decide whether a quoted-reply should count as an implicit @bot.
94
+ *
95
+ * When the user quotes an earlier bot message, we treat the new message
96
+ * as if it @-ed the bot, even without a literal mention. This lives in
97
+ * the mention module (rather than with activation) because semantically
98
+ * it answers the same question as `detectWasMentioned`:
99
+ * "was the bot addressed by this message?".
100
+ *
101
+ * The `getRefEntry` callback is injected so this function does not
102
+ * depend on the ref-index store implementation — any lookup that
103
+ * returns `{ isBot?: boolean }` works.
104
+ */
105
+ export function resolveImplicitMention(params: {
106
+ refMsgIdx?: string;
107
+ getRefEntry: (idx: string) => { isBot?: boolean } | null;
108
+ }): boolean {
109
+ if (!params.refMsgIdx) {
110
+ return false;
111
+ }
112
+ const refEntry = params.getRefEntry(params.refMsgIdx);
113
+ return refEntry?.isBot === true;
114
+ }
@@ -0,0 +1,108 @@
1
+ type GroupMessageGateAction =
2
+ | "drop_other_mention"
3
+ | "block_unauthorized_command"
4
+ | "skip_no_mention"
5
+ | "pass";
6
+
7
+ export interface GroupMessageGateResult {
8
+ action: GroupMessageGateAction;
9
+ effectiveWasMentioned: boolean;
10
+ shouldBypassMention: boolean;
11
+ }
12
+
13
+ export interface GroupMessageGateInput {
14
+ ignoreOtherMentions: boolean;
15
+ hasAnyMention: boolean;
16
+ wasMentioned: boolean;
17
+ implicitMention: boolean;
18
+ allowTextCommands: boolean;
19
+ isControlCommand: boolean;
20
+ commandAuthorized: boolean;
21
+ requireMention: boolean;
22
+ canDetectMention: boolean;
23
+ }
24
+
25
+ function resolveMentionGating(input: {
26
+ requireMention: boolean;
27
+ canDetectMention: boolean;
28
+ wasMentioned: boolean;
29
+ implicitMention: boolean;
30
+ shouldBypassMention: boolean;
31
+ }): { effectiveWasMentioned: boolean; shouldSkip: boolean } {
32
+ const effectiveWasMentioned =
33
+ input.wasMentioned || input.implicitMention || input.shouldBypassMention;
34
+ const shouldSkip = input.requireMention && input.canDetectMention && !effectiveWasMentioned;
35
+ return { effectiveWasMentioned, shouldSkip };
36
+ }
37
+
38
+ function resolveCommandBypass(input: {
39
+ requireMention: boolean;
40
+ wasMentioned: boolean;
41
+ hasAnyMention: boolean;
42
+ allowTextCommands: boolean;
43
+ commandAuthorized: boolean;
44
+ isControlCommand: boolean;
45
+ }): boolean {
46
+ return (
47
+ input.requireMention &&
48
+ !input.wasMentioned &&
49
+ !input.hasAnyMention &&
50
+ input.allowTextCommands &&
51
+ input.commandAuthorized &&
52
+ input.isControlCommand
53
+ );
54
+ }
55
+
56
+ export function resolveGroupMessageGate(input: GroupMessageGateInput): GroupMessageGateResult {
57
+ if (
58
+ input.ignoreOtherMentions &&
59
+ input.hasAnyMention &&
60
+ !input.wasMentioned &&
61
+ !input.implicitMention
62
+ ) {
63
+ return {
64
+ action: "drop_other_mention",
65
+ effectiveWasMentioned: false,
66
+ shouldBypassMention: false,
67
+ };
68
+ }
69
+
70
+ if (input.allowTextCommands && input.isControlCommand && !input.commandAuthorized) {
71
+ return {
72
+ action: "block_unauthorized_command",
73
+ effectiveWasMentioned: false,
74
+ shouldBypassMention: false,
75
+ };
76
+ }
77
+
78
+ const shouldBypassMention = resolveCommandBypass({
79
+ requireMention: input.requireMention,
80
+ wasMentioned: input.wasMentioned,
81
+ hasAnyMention: input.hasAnyMention,
82
+ allowTextCommands: input.allowTextCommands,
83
+ commandAuthorized: input.commandAuthorized,
84
+ isControlCommand: input.isControlCommand,
85
+ });
86
+
87
+ const mentionGate = resolveMentionGating({
88
+ requireMention: input.requireMention,
89
+ canDetectMention: input.canDetectMention,
90
+ wasMentioned: input.wasMentioned,
91
+ implicitMention: input.implicitMention,
92
+ shouldBypassMention,
93
+ });
94
+
95
+ if (mentionGate.shouldSkip) {
96
+ return {
97
+ action: "skip_no_mention",
98
+ effectiveWasMentioned: mentionGate.effectiveWasMentioned,
99
+ shouldBypassMention,
100
+ };
101
+ }
102
+
103
+ return {
104
+ action: "pass",
105
+ effectiveWasMentioned: mentionGate.effectiveWasMentioned,
106
+ shouldBypassMention,
107
+ };
108
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Media path decoding utility.
3
+ *
4
+ * Extracted from `outbound-deliver.ts` — handles the `MEDIA:` prefix stripping,
5
+ * tilde expansion, octal escape / UTF-8 byte-sequence decoding, and backslash
6
+ * unescaping that media tags require.
7
+ *
8
+ * Zero external dependencies.
9
+ */
10
+
11
+ import type { EngineLogger } from "../types.js";
12
+
13
+ /**
14
+ * Normalize a file path by expanding `~` to the home directory and trimming.
15
+ *
16
+ * This is a minimal re-implementation of `utils/platform.ts#normalizePath`
17
+ * so that `core/` remains self-contained.
18
+ */
19
+ function normalizePath(p: string): string {
20
+ let result = p.trim();
21
+ if (result.startsWith("~/") || result === "~") {
22
+ const home =
23
+ typeof process !== "undefined" ? (process.env.HOME ?? process.env.USERPROFILE) : undefined;
24
+ if (home) {
25
+ result = result === "~" ? home : `${home}${result.slice(1)}`;
26
+ }
27
+ }
28
+ return result;
29
+ }
30
+
31
+ /**
32
+ * Decode a media path by stripping `MEDIA:`, expanding `~`, and unescaping
33
+ * octal/UTF-8 byte sequences.
34
+ *
35
+ * @param raw - Raw path string from a media tag.
36
+ * @param log - Optional logger for decode diagnostics.
37
+ * @returns The decoded, normalized media path.
38
+ */
39
+ export function decodeMediaPath(raw: string, log?: EngineLogger): string {
40
+ let mediaPath = raw;
41
+ if (mediaPath.startsWith("MEDIA:")) {
42
+ mediaPath = mediaPath.slice("MEDIA:".length);
43
+ }
44
+ mediaPath = normalizePath(mediaPath);
45
+ mediaPath = mediaPath.replace(/\\\\/g, "\\");
46
+
47
+ // Skip octal escape decoding for Windows local paths (e.g. C:\Users\1\file.txt)
48
+ // where backslash-digit sequences like \1, \2 ... \7 are directory separators,
49
+ // not octal escape sequences.
50
+ const isWinLocal = /^[a-zA-Z]:[\\/]/.test(mediaPath) || mediaPath.startsWith("\\\\");
51
+ try {
52
+ const hasOctal = /\\[0-7]{1,3}/.test(mediaPath);
53
+ const hasNonASCII = /[\u0080-\u00FF]/.test(mediaPath);
54
+
55
+ if (!isWinLocal && (hasOctal || hasNonASCII)) {
56
+ log?.debug?.(`Decoding path with mixed encoding: ${mediaPath}`);
57
+ const decoded = mediaPath.replace(/\\([0-7]{1,3})/g, (_: string, octal: string) => {
58
+ return String.fromCharCode(Number.parseInt(octal, 8));
59
+ });
60
+ const bytes: number[] = [];
61
+ for (let i = 0; i < decoded.length; i++) {
62
+ const code = decoded.charCodeAt(i);
63
+ if (code <= 0xff) {
64
+ bytes.push(code);
65
+ } else {
66
+ const charBytes = Buffer.from(decoded[i], "utf8");
67
+ bytes.push(...charBytes);
68
+ }
69
+ }
70
+ const buffer = Buffer.from(bytes);
71
+ const utf8Decoded = buffer.toString("utf8");
72
+ if (!utf8Decoded.includes("\uFFFD") || utf8Decoded.length < decoded.length) {
73
+ mediaPath = utf8Decoded;
74
+ log?.debug?.(`Successfully decoded path: ${mediaPath}`);
75
+ }
76
+ }
77
+ } catch (decodeErr) {
78
+ log?.error(`Path decode error: ${String(decodeErr)}`);
79
+ }
80
+
81
+ return mediaPath;
82
+ }