@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.
- package/api.ts +56 -0
- package/autobot.plugin.json +167 -0
- package/channel-plugin-api.ts +1 -0
- package/index.ts +33 -0
- package/package.json +64 -0
- package/runtime-api.ts +9 -0
- package/secret-contract-api.ts +5 -0
- package/setup-entry.ts +13 -0
- package/setup-plugin-api.ts +3 -0
- package/skills/qqbot-channel/SKILL.md +262 -0
- package/skills/qqbot-channel/references/api_references.md +521 -0
- package/skills/qqbot-media/SKILL.md +37 -0
- package/skills/qqbot-remind/SKILL.md +153 -0
- package/src/bridge/approval/capability.ts +225 -0
- package/src/bridge/approval/handler-runtime.ts +204 -0
- package/src/bridge/bootstrap.ts +135 -0
- package/src/bridge/channel-entry.ts +18 -0
- package/src/bridge/commands/framework-context-adapter.ts +60 -0
- package/src/bridge/commands/framework-registration.ts +66 -0
- package/src/bridge/commands/from-parser.ts +60 -0
- package/src/bridge/commands/result-dispatcher.ts +76 -0
- package/src/bridge/config-shared.ts +132 -0
- package/src/bridge/config.ts +176 -0
- package/src/bridge/gateway.ts +178 -0
- package/src/bridge/logger.ts +31 -0
- package/src/bridge/narrowing.ts +31 -0
- package/src/bridge/plugin-version.ts +102 -0
- package/src/bridge/runtime.ts +25 -0
- package/src/bridge/sdk-adapter.ts +164 -0
- package/src/bridge/setup/finalize.ts +144 -0
- package/src/bridge/setup/surface.ts +34 -0
- package/src/bridge/tools/channel.ts +58 -0
- package/src/bridge/tools/index.ts +15 -0
- package/src/bridge/tools/remind.ts +91 -0
- package/src/channel.setup.ts +33 -0
- package/src/channel.ts +399 -0
- package/src/config-schema.ts +84 -0
- package/src/engine/access/index.ts +2 -0
- package/src/engine/access/resolve-policy.ts +30 -0
- package/src/engine/access/sender-match.ts +55 -0
- package/src/engine/access/types.ts +2 -0
- package/src/engine/adapter/audio.port.ts +27 -0
- package/src/engine/adapter/commands.port.ts +22 -0
- package/src/engine/adapter/history.port.ts +52 -0
- package/src/engine/adapter/index.ts +76 -0
- package/src/engine/adapter/mention-gate.port.ts +50 -0
- package/src/engine/adapter/types.ts +38 -0
- package/src/engine/api/api-client.ts +212 -0
- package/src/engine/api/media-chunked.ts +644 -0
- package/src/engine/api/media.ts +218 -0
- package/src/engine/api/messages.ts +293 -0
- package/src/engine/api/retry.ts +217 -0
- package/src/engine/api/routes.ts +95 -0
- package/src/engine/api/token.ts +277 -0
- package/src/engine/approval/index.ts +224 -0
- package/src/engine/commands/builtin/log-helpers.ts +341 -0
- package/src/engine/commands/builtin/register-all.ts +17 -0
- package/src/engine/commands/builtin/register-approve.ts +201 -0
- package/src/engine/commands/builtin/register-basic.ts +95 -0
- package/src/engine/commands/builtin/register-clear-storage.ts +187 -0
- package/src/engine/commands/builtin/register-logs.ts +20 -0
- package/src/engine/commands/builtin/register-streaming.ts +138 -0
- package/src/engine/commands/builtin/state.ts +31 -0
- package/src/engine/commands/slash-command-auth.ts +88 -0
- package/src/engine/commands/slash-command-handler.ts +168 -0
- package/src/engine/commands/slash-command-test-support.ts +39 -0
- package/src/engine/commands/slash-commands-impl.ts +61 -0
- package/src/engine/commands/slash-commands.ts +202 -0
- package/src/engine/config/credential-backup.ts +108 -0
- package/src/engine/config/credentials.ts +76 -0
- package/src/engine/config/group.ts +227 -0
- package/src/engine/config/resolve.ts +283 -0
- package/src/engine/config/setup-logic.ts +84 -0
- package/src/engine/gateway/active-cfg.ts +52 -0
- package/src/engine/gateway/codec.ts +47 -0
- package/src/engine/gateway/constants.ts +117 -0
- package/src/engine/gateway/event-dispatcher.ts +177 -0
- package/src/engine/gateway/gateway-connection.ts +356 -0
- package/src/engine/gateway/gateway.ts +267 -0
- package/src/engine/gateway/inbound-attachments.ts +360 -0
- package/src/engine/gateway/inbound-context.ts +82 -0
- package/src/engine/gateway/inbound-pipeline.ts +171 -0
- package/src/engine/gateway/interaction-handler.ts +345 -0
- package/src/engine/gateway/message-queue.ts +404 -0
- package/src/engine/gateway/outbound-dispatch.ts +590 -0
- package/src/engine/gateway/reconnect.ts +199 -0
- package/src/engine/gateway/stages/access-stage.ts +99 -0
- package/src/engine/gateway/stages/assembly-stage.ts +156 -0
- package/src/engine/gateway/stages/content-stage.ts +77 -0
- package/src/engine/gateway/stages/envelope-stage.ts +144 -0
- package/src/engine/gateway/stages/group-gate-stage.ts +223 -0
- package/src/engine/gateway/stages/index.ts +18 -0
- package/src/engine/gateway/stages/quote-stage.ts +113 -0
- package/src/engine/gateway/stages/refidx-stage.ts +62 -0
- package/src/engine/gateway/stages/stub-contexts.ts +77 -0
- package/src/engine/gateway/types.ts +230 -0
- package/src/engine/gateway/typing-keepalive.ts +102 -0
- package/src/engine/gateway/ws-client.ts +16 -0
- package/src/engine/group/activation.ts +88 -0
- package/src/engine/group/history.ts +321 -0
- package/src/engine/group/mention.ts +114 -0
- package/src/engine/group/message-gating.ts +108 -0
- package/src/engine/messaging/decode-media-path.ts +82 -0
- package/src/engine/messaging/media-source.ts +210 -0
- package/src/engine/messaging/media-type-detect.ts +27 -0
- package/src/engine/messaging/outbound-audio-port.ts +38 -0
- package/src/engine/messaging/outbound-deliver.ts +810 -0
- package/src/engine/messaging/outbound-media-send.ts +658 -0
- package/src/engine/messaging/outbound-reply.ts +27 -0
- package/src/engine/messaging/outbound-result-helpers.ts +54 -0
- package/src/engine/messaging/outbound-types.ts +47 -0
- package/src/engine/messaging/outbound.ts +485 -0
- package/src/engine/messaging/reply-dispatcher.ts +597 -0
- package/src/engine/messaging/reply-limiter.ts +164 -0
- package/src/engine/messaging/sender.ts +741 -0
- package/src/engine/messaging/streaming-c2c.ts +1192 -0
- package/src/engine/messaging/streaming-media-send.ts +544 -0
- package/src/engine/messaging/target-parser.ts +104 -0
- package/src/engine/ref/format-message-ref.ts +142 -0
- package/src/engine/ref/format-ref-entry.ts +27 -0
- package/src/engine/ref/store.ts +211 -0
- package/src/engine/ref/types.ts +27 -0
- package/src/engine/session/known-users.ts +138 -0
- package/src/engine/session/session-store.ts +207 -0
- package/src/engine/tools/channel-api.ts +244 -0
- package/src/engine/tools/remind-logic.ts +377 -0
- package/src/engine/types.ts +313 -0
- package/src/engine/utils/attachment-tags.ts +174 -0
- package/src/engine/utils/audio.ts +525 -0
- package/src/engine/utils/data-paths.ts +38 -0
- package/src/engine/utils/diagnostics.ts +93 -0
- package/src/engine/utils/file-utils.ts +215 -0
- package/src/engine/utils/format.ts +70 -0
- package/src/engine/utils/image-size.ts +249 -0
- package/src/engine/utils/log.ts +77 -0
- package/src/engine/utils/media-tags.ts +177 -0
- package/src/engine/utils/payload.ts +157 -0
- package/src/engine/utils/platform.ts +265 -0
- package/src/engine/utils/request-context.ts +60 -0
- package/src/engine/utils/string-normalize.ts +91 -0
- package/src/engine/utils/stt.ts +103 -0
- package/src/engine/utils/text-parsing.ts +155 -0
- package/src/engine/utils/upload-cache.ts +96 -0
- package/src/engine/utils/voice-text.ts +15 -0
- package/src/exec-approvals.ts +237 -0
- package/src/qqbot-test-support.ts +29 -0
- package/src/secret-contract.ts +82 -0
- package/src/types.ts +210 -0
- 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
|
+
}
|