@aalis/plugin-adapter-onebot 0.4.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/index.js ADDED
@@ -0,0 +1,1948 @@
1
+ import { AttachmentRefKind, formatAttachmentRef, getSenderLabel } from '@aalis/plugin-message-api';
2
+ import { createProcessGateway } from '@aalis/plugin-process-api';
3
+ import { createStorageGateway } from '@aalis/plugin-storage-api';
4
+ import WebSocket from 'ws';
5
+ import { cacheAttachmentBuffer, detectExtensionFromBuffer, loadAttachmentBuffer, transcodeAudioBufferToWav, } from './attachment-cache.js';
6
+ import { renderAttachmentsAsContentMarkers } from './attachments.js';
7
+ import { extractSentMessageId, SentMessageTracker } from './sent-messages.js';
8
+ import '@aalis/plugin-agent-api';
9
+ import { createForwardExpander, DEFAULT_FORWARD_SUMMARY_PROMPT } from './forward-expand.js';
10
+ import { segmentsToText } from './types.js';
11
+ import { OneBotV11 } from './v11.js';
12
+ /**
13
+ * 从原始配置对象中解析出 forward 子配置。
14
+ */
15
+ function parseForwardConfig(config) {
16
+ const fwdRaw = (config.forward ?? {});
17
+ return {
18
+ enabled: fwdRaw.enabled !== false,
19
+ maxDepth: typeof fwdRaw.maxDepth === 'number' ? Math.max(1, Math.floor(fwdRaw.maxDepth)) : 3,
20
+ maxNodesPerLevel: typeof fwdRaw.maxNodesPerLevel === 'number' ? Math.max(1, Math.floor(fwdRaw.maxNodesPerLevel)) : 30,
21
+ imageRecognition: fwdRaw.imageRecognition !== false,
22
+ imageRecognitionConcurrency: typeof fwdRaw.imageRecognitionConcurrency === 'number'
23
+ ? Math.max(1, Math.floor(fwdRaw.imageRecognitionConcurrency))
24
+ : 8,
25
+ summarize: fwdRaw.summarize !== false,
26
+ summaryLLM: fwdRaw.summaryLLM &&
27
+ typeof fwdRaw.summaryLLM === 'object' &&
28
+ fwdRaw.summaryLLM.provider &&
29
+ fwdRaw.summaryLLM.model
30
+ ? fwdRaw.summaryLLM
31
+ : undefined,
32
+ summaryMaxChars: typeof fwdRaw.summaryMaxChars === 'number' ? Math.max(80, Math.floor(fwdRaw.summaryMaxChars)) : 600,
33
+ summaryInputLimit: typeof fwdRaw.summaryInputLimit === 'number' ? Math.max(0, Math.floor(fwdRaw.summaryInputLimit)) : 8000,
34
+ summaryPrompt: typeof fwdRaw.summaryPrompt === 'string' ? fwdRaw.summaryPrompt : '',
35
+ };
36
+ }
37
+ import { OneBotV12 } from './v12.js';
38
+ // ===== 插件元数据 =====
39
+ export const name = '@aalis/plugin-adapter-onebot';
40
+ export const displayName = 'OneBot 适配器';
41
+ export const subsystem = 'platform';
42
+ export const inject = {
43
+ required: ['storage', 'process'],
44
+ optional: ['llm', 'commands', 'message-archive', 'persona', 'flow-control'],
45
+ };
46
+ export const provides = ['platform'];
47
+ export const reusable = true;
48
+ export const configSchema = {
49
+ connections: {
50
+ type: 'array',
51
+ label: '连接列表',
52
+ description: '配置一个或多个 OneBot WebSocket 连接',
53
+ items: {
54
+ url: { type: 'string', label: 'WebSocket 地址', required: true, description: '如 ws://127.0.0.1:8080' },
55
+ accessToken: { type: 'string', label: '鉴权 Token', secret: true, description: '可选,与 OneBot 实现端一致' },
56
+ selfId: { type: 'string', label: '机器人 ID', description: '可选,连接后自动获取' },
57
+ protocol: {
58
+ type: 'string',
59
+ label: '协议版本',
60
+ description: '选择 OneBot 协议版本:v11、v12 或 auto(自动检测)',
61
+ default: 'auto',
62
+ },
63
+ },
64
+ default: [],
65
+ },
66
+ splitMessage: {
67
+ label: '消息分条发送',
68
+ description: '启用后,文本将按选中的符号自动拆分为多条消息发送,模拟真人发送习惯',
69
+ fields: {
70
+ enabled: { type: 'boolean', label: '启用', description: '是否启用消息分条发送', default: false },
71
+ delayPerChar: {
72
+ type: 'number',
73
+ label: '每字延迟 (ms)',
74
+ description: '按下一条消息的字数计算延迟,单位毫秒/字',
75
+ default: 50,
76
+ },
77
+ maxDelay: {
78
+ type: 'number',
79
+ label: '最大延迟 (ms)',
80
+ description: '分条消息之间的最大延迟上限(毫秒)',
81
+ default: 3000,
82
+ },
83
+ patterns: {
84
+ type: 'multiselect',
85
+ label: '切割模式',
86
+ description: '在匹配到这些字符串的位置之后进行切割。每一项是一个完整的字符串:单字符(如 。)就在该字符后切;多字符(如 ". "、".\\n")则要整段匹配到才切。支持转义:\\n=换行,\\t=制表符,\\r=回车,\\\\=反斜杠。',
87
+ allowCustom: true,
88
+ options: [
89
+ { label: '。 中文句号', value: '。' },
90
+ { label: '! 中文感叹号', value: '!' },
91
+ { label: '? 中文问号', value: '?' },
92
+ { label: '; 中文分号', value: ';' },
93
+ { label: ', 中文逗号', value: ',' },
94
+ { label: '、 顿号', value: '、' },
95
+ { label: '. 英文句号', value: '.' },
96
+ { label: '. ␣ 英文句号+空格(避免小数点误拆)', value: '. ' },
97
+ { label: '! 英文感叹号', value: '!' },
98
+ { label: '? 英文问号', value: '?' },
99
+ { label: '; 英文分号', value: ';' },
100
+ { label: ', 英文逗号', value: ',' },
101
+ { label: ', ␣ 英文逗号+空格(避免 1,000 误拆)', value: ', ' },
102
+ { label: '↵ 换行 (\\n)', value: '\\n' },
103
+ { label: '⇥ 制表符 (\\t)', value: '\\t' },
104
+ { label: '␣ 空格', value: ' ' },
105
+ ],
106
+ default: ['。', '!', '?', '.', '!', '?', '\\n'],
107
+ },
108
+ },
109
+ },
110
+ forward: {
111
+ label: '合并转发处理',
112
+ description: '收到 <forward> 消息时如何展开、是否调用图像识别、是否调用 LLM 生成摘要',
113
+ fields: {
114
+ enabled: {
115
+ type: 'boolean',
116
+ label: '启用自动展开',
117
+ default: true,
118
+ description: '关闭后保留原始占位符,由 LLM 自行决定是否调工具读取。',
119
+ },
120
+ maxDepth: {
121
+ type: 'number',
122
+ label: '嵌套深度上限',
123
+ default: 3,
124
+ description: '递归展开嵌套合并转发的最大层数(顶层=1)。',
125
+ },
126
+ maxNodesPerLevel: {
127
+ type: 'number',
128
+ label: '单层节点上限',
129
+ default: 30,
130
+ description: '每层最多展开多少条节点。超过部分会被截断。',
131
+ },
132
+ imageRecognition: {
133
+ type: 'boolean',
134
+ label: '识别内部图片',
135
+ default: true,
136
+ description: '把转发内的图片送入 media 服务转写为文字描述。需要该服务可用。',
137
+ },
138
+ imageRecognitionConcurrency: {
139
+ type: 'number',
140
+ label: '图片识别并发上限',
141
+ default: 8,
142
+ description: '同一条合并转发内允许同时进行的图片识别任务数。过高可能压垮上游模型;过低会拖慢长转发展开。',
143
+ },
144
+ summarize: {
145
+ type: 'boolean',
146
+ label: '生成摘要',
147
+ default: true,
148
+ description: '展开后调用 LLM 生成一段摘要,作为消息正文进入对话/记忆/向量库;原文保留在缓存。',
149
+ },
150
+ summaryLLM: {
151
+ type: 'llm-ref',
152
+ label: '摘要模型',
153
+ description: '留空使用默认 LLM 服务;指定后按 (provider, model) 精确定位。建议挑便宜/快的模型。',
154
+ },
155
+ summaryMaxChars: {
156
+ type: 'number',
157
+ label: '摘要最大字数',
158
+ default: 600,
159
+ description: '提示给摘要模型的目标长度上限。模型被允许超出 10% 以保留多人互动结构。',
160
+ },
161
+ summaryInputLimit: {
162
+ type: 'number',
163
+ label: '摘要原文输入上限(字符)',
164
+ default: 8000,
165
+ description: '喂给摘要模型的原文输入上限;原文超过则前段截断。设为 0 表示不截断(注意超长文本会增加摘要成本)。',
166
+ },
167
+ summaryPrompt: {
168
+ type: 'textarea',
169
+ label: '摘要 system prompt(高级,留空使用内置)',
170
+ default: '',
171
+ description: `留空使用内置默认 prompt(专为保留多人互动结构调优过)。填入非空内容则完全覆盖默认 prompt。\n\n内置默认 prompt 如下,可作为撰写参考:\n\n${DEFAULT_FORWARD_SUMMARY_PROMPT}`,
172
+ },
173
+ },
174
+ },
175
+ reply: {
176
+ label: '引用消息处理',
177
+ description: '收到引用回复时如何展开被引用消息链',
178
+ fields: {
179
+ maxDepth: {
180
+ type: 'number',
181
+ label: '引用链深度上限',
182
+ default: 5,
183
+ description: '递归获取被引用消息的最大层数。1 = 只读取直接引用;2 = 继续读取直接引用所引用的消息,依此类推。',
184
+ },
185
+ },
186
+ },
187
+ attachmentCache: {
188
+ label: '附件本地缓存',
189
+ description: '入站 / 出站的 image / audio / video / file 统一缓存到 data/{kind}s/{session}/,与图片目录布局一致,便于人工归档与多轮工具复用',
190
+ fields: {
191
+ maxBytes: {
192
+ type: 'number',
193
+ label: '单文件大小上限 (Byte)',
194
+ default: 10 * 1024 * 1024,
195
+ description: '超过此尺寸的附件不落盘,保留原 URL(典型场景:长视频)。默认 10 MiB。',
196
+ },
197
+ },
198
+ },
199
+ };
200
+ export const defaultConfig = {
201
+ connections: [],
202
+ splitMessage: {
203
+ enabled: false,
204
+ delayPerChar: 50,
205
+ maxDelay: 3000,
206
+ patterns: ['。', '!', '?', '.', '!', '?', '\\n'],
207
+ },
208
+ forward: {
209
+ enabled: true,
210
+ maxDepth: 3,
211
+ maxNodesPerLevel: 30,
212
+ imageRecognition: true,
213
+ imageRecognitionConcurrency: 8,
214
+ summarize: true,
215
+ summaryMaxChars: 600,
216
+ summaryPrompt: '',
217
+ },
218
+ reply: {
219
+ maxDepth: 5,
220
+ },
221
+ attachmentCache: {
222
+ maxBytes: 10 * 1024 * 1024,
223
+ },
224
+ };
225
+ // ===== 聊天流控类型(已迁移)=====
226
+ //
227
+ // 旧的 ChatFlowConfig / FlowSessionState / 流控函数已抽出到独立插件:
228
+ // - @aalis/plugin-flow-control (计数 / 冷却 / 限速 / idle 调度)
229
+ // - @aalis/plugin-trigger-policy (@/名字检测 + 间隔/评分判定)
230
+ // 适配器只保留两个最小桥接:
231
+ // - 群禁言事件 → ctx.getService<FlowControlService>('flow-control').setMuted()
232
+ // - shut_up_timestamp 启动恢复 → 同上
233
+ // 其他路径全部走 inbound:command/flow/trigger/dispatch 生命周期相位。
234
+ // ===== 工具函数 =====
235
+ /** 生成 sessionId: onebot:{selfId}:{detailType}:{targetId} */
236
+ function makeSessionId(selfId, detailType, userId, groupId, guildId, channelId) {
237
+ let targetId;
238
+ if (detailType === 'private') {
239
+ targetId = userId ?? 'unknown';
240
+ }
241
+ else if (detailType === 'group') {
242
+ targetId = groupId ?? 'unknown';
243
+ }
244
+ else if (detailType === 'channel') {
245
+ targetId = `${guildId ?? 'unknown'}:${channelId ?? 'unknown'}`;
246
+ }
247
+ else {
248
+ targetId = 'unknown';
249
+ }
250
+ return `onebot:${selfId}:${detailType}:${targetId}`;
251
+ }
252
+ /** 解析 sessionId 回连接信息 */
253
+ function parseSessionId(sessionId) {
254
+ const parts = sessionId.split(':');
255
+ if (parts[0] !== 'onebot' || parts.length < 4)
256
+ return null;
257
+ return {
258
+ selfId: parts[1],
259
+ detailType: parts[2],
260
+ targetId: parts.slice(3).join(':'),
261
+ };
262
+ }
263
+ /** 生成唯一 echo ID */
264
+ let echoCounter = 0;
265
+ function nextEcho() {
266
+ return `aalis_${Date.now()}_${++echoCounter}`;
267
+ }
268
+ // ===== 附件统一缓存 =====
269
+ //
270
+ // 历史上只有 image 落盘到 data/images/,audio/video/file 直接传 URL;
271
+ // 现统一到 data/{kind}s/{session}/{hash}.{ext},便于人工归档、跨轮工具复用、
272
+ // 多模态历史回放。详见 ./attachment-cache.ts。
273
+ /** 占位符 → AttachmentRefKind 映射(与 v11/v12 segmentsToText 的输出对齐) */
274
+ const KIND_PLACEHOLDER = {
275
+ image: { placeholder: '[图片]', ref: AttachmentRefKind.Image },
276
+ audio: { placeholder: '[语音]', ref: AttachmentRefKind.Audio },
277
+ video: { placeholder: '[视频]', ref: AttachmentRefKind.Video },
278
+ file: { placeholder: '[文件]', ref: AttachmentRefKind.File },
279
+ };
280
+ /**
281
+ * 把单个附件下载(必要时转码)后落盘,返回相对路径。失败返回 null。
282
+ * - audio:先 ffmpeg → 16kHz mono WAV,再以 .wav 入库(用户期望转码后存储,
283
+ * 方便后续 LLM 反复分析;同时避免下游每次重转码)。
284
+ * - 其他 kind:原样落盘,扩展名按 magic header 推断。
285
+ */
286
+ async function cacheOneAttachment(storage, proc, kind, source, sessionId, maxBytes, logger) {
287
+ const buf = await loadAttachmentBuffer(storage, proc, source);
288
+ if (!buf)
289
+ return null;
290
+ if (kind === 'audio') {
291
+ const inExt = detectExtensionFromBuffer(buf, 'bin');
292
+ const wav = await transcodeAudioBufferToWav(proc, storage, buf, inExt);
293
+ if (!wav) {
294
+ logger.warn(`OneBot 音频转 WAV 失败 (in=${inExt}, size=${buf.byteLength}B),保留原 URL`);
295
+ return null;
296
+ }
297
+ return await cacheAttachmentBuffer(storage, wav, 'audio', sessionId, 'wav', maxBytes);
298
+ }
299
+ const ext = detectExtensionFromBuffer(buf, kind === 'image' ? 'jpg' : kind === 'video' ? 'mp4' : 'bin');
300
+ return await cacheAttachmentBuffer(storage, buf, kind, sessionId, ext, maxBytes);
301
+ }
302
+ /**
303
+ * 把 text 中所有 [图片]/[语音]/[视频]/[文件] 占位按 attachments 顺序替换为
304
+ * `[xxx | ref:data/...]`。落盘失败的占位保持原样。
305
+ */
306
+ function rewritePlaceholdersWithRefs(text, attachments, localPaths) {
307
+ // 按 kind 分桶维护游标
308
+ const cursors = { image: 0, audio: 0, video: 0, file: 0 };
309
+ const slotsByKind = {
310
+ image: [],
311
+ audio: [],
312
+ video: [],
313
+ file: [],
314
+ };
315
+ attachments.forEach((a, i) => {
316
+ slotsByKind[a.kind].push(localPaths[i]);
317
+ });
318
+ let out = text;
319
+ for (const kind of ['image', 'audio', 'video', 'file']) {
320
+ const { placeholder, ref } = KIND_PLACEHOLDER[kind];
321
+ const pattern = placeholder.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
322
+ out = out.replace(new RegExp(pattern, 'g'), () => {
323
+ const slot = slotsByKind[kind][cursors[kind]++];
324
+ return slot ? formatAttachmentRef({ kind: ref, ref: slot }) : placeholder;
325
+ });
326
+ // 占位符比 attachments 少时:把多出的 ref 追加到末尾,确保不丢
327
+ const remaining = slotsByKind[kind].slice(cursors[kind]);
328
+ for (const slot of remaining) {
329
+ if (slot)
330
+ out += formatAttachmentRef({ kind: ref, ref: slot });
331
+ }
332
+ }
333
+ return out;
334
+ }
335
+ // ===== 协议版本实例 =====
336
+ const protocolV11 = new OneBotV11();
337
+ const protocolV12 = new OneBotV12();
338
+ // ===== 重连配置 =====
339
+ const RECONNECT_INTERVAL = 5000;
340
+ const ACTION_TIMEOUT = 30000;
341
+ const INVITE_CARD_SUPPRESS_WINDOW = 2 * 60 * 1000;
342
+ const HEARTBEAT_INTERVAL = 30000;
343
+ const HEARTBEAT_TIMEOUT = 10000;
344
+ const CONNECT_TIMEOUT = 15000;
345
+ // ===== 消息分条逻辑 =====
346
+ /**
347
+ * 解析「切割模式列表」:每项是一个字符串,按 JS 风格解码转义序列
348
+ * (\\n→换行、\\t→制表符、\\r→回车、\\\\→反斜杠)。空串与重复项被忽略。
349
+ */
350
+ function resolveSplitPatterns(items) {
351
+ if (!Array.isArray(items))
352
+ return [];
353
+ const out = [];
354
+ for (const raw of items) {
355
+ if (typeof raw !== 'string' || raw.length === 0)
356
+ continue;
357
+ const decoded = raw.replace(/\\([nrt\\])/g, (_, c) => c === 'n' ? '\n' : c === 't' ? '\t' : c === 'r' ? '\r' : '\\');
358
+ if (decoded.length > 0 && !out.includes(decoded))
359
+ out.push(decoded);
360
+ }
361
+ return out;
362
+ }
363
+ /** 转义正则元字符,用于 lookbehind。 */
364
+ function escapeForRegex(s) {
365
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
366
+ }
367
+ /**
368
+ * 按配置的「切割模式列表」将文本拆分为多条消息。
369
+ * 每个模式作为一个原子 lookbehind 匹配——文本必须以该模式整体结尾才会切割。
370
+ * XML 标记(<at>、<image> 等)保持与相邻文本在一起,不在标记内部切割。
371
+ */
372
+ function splitMessageByPunctuation(content, patterns) {
373
+ if (content.length <= 10 || patterns.length === 0)
374
+ return [content];
375
+ // 识别 XML 标记位置,拆分时不切割它们
376
+ const xmlTagRegex = /<(?:at(?:\s+self)?)\s*>[^<]*<\/at>|<face\s+id=["'][^"']*["']\s*\/>|<image\s+url=["'][^"']*["']\s*\/>|<reply\s+id=["'][^"']*["']\s*\/>/g;
377
+ const tokens = [];
378
+ let lastIdx = 0;
379
+ let m = xmlTagRegex.exec(content);
380
+ while (m !== null) {
381
+ if (m.index > lastIdx)
382
+ tokens.push({ type: 'text', value: content.slice(lastIdx, m.index) });
383
+ tokens.push({ type: 'tag', value: m[0] });
384
+ lastIdx = m.index + m[0].length;
385
+ m = xmlTagRegex.exec(content);
386
+ }
387
+ if (lastIdx < content.length)
388
+ tokens.push({ type: 'text', value: content.slice(lastIdx) });
389
+ // 拆分正则:每个模式独立 lookbehind,用 | 合并
390
+ const splitRegex = new RegExp(patterns.map(p => `(?<=${escapeForRegex(p)})`).join('|'));
391
+ const pieces = [];
392
+ let current = '';
393
+ for (const token of tokens) {
394
+ if (token.type === 'tag') {
395
+ current += token.value;
396
+ continue;
397
+ }
398
+ const parts = token.value.split(splitRegex);
399
+ for (let i = 0; i < parts.length; i++) {
400
+ current += parts[i];
401
+ if (i < parts.length - 1 && current.trim()) {
402
+ pieces.push(current);
403
+ current = '';
404
+ }
405
+ }
406
+ }
407
+ if (current.trim())
408
+ pieces.push(current);
409
+ // 尾部清理:去除每段末尾匹配到的切割模式(按长度倒序匹配,优先去掉长模式)
410
+ const sortedPatterns = [...patterns].sort((a, b) => b.length - a.length);
411
+ const stripTrailing = (s) => {
412
+ let cur = s;
413
+ let changed = true;
414
+ while (changed) {
415
+ changed = false;
416
+ for (const p of sortedPatterns) {
417
+ if (cur.endsWith(p)) {
418
+ cur = cur.slice(0, -p.length);
419
+ changed = true;
420
+ }
421
+ }
422
+ const trimmed = cur.replace(/\s+$/, '');
423
+ if (trimmed !== cur) {
424
+ cur = trimmed;
425
+ changed = true;
426
+ }
427
+ }
428
+ return cur;
429
+ };
430
+ const result = [];
431
+ for (const piece of pieces) {
432
+ const cleaned = stripTrailing(piece).trim();
433
+ if (!cleaned)
434
+ continue;
435
+ // 纯文本过短则合并到上一条
436
+ const textOnly = cleaned.replace(/<[^>]+>/g, '').trim();
437
+ if (textOnly.length < 4 && result.length > 0) {
438
+ result[result.length - 1] += cleaned;
439
+ }
440
+ else {
441
+ result.push(cleaned);
442
+ }
443
+ }
444
+ return result.length > 0 ? result : [content];
445
+ }
446
+ /**
447
+ * 把一条消息中的 `<image url="..." />` 标签拆成独立片段,
448
+ * 让图片始终单条发送,避免与文字粘连显得"假"。
449
+ * 文本两侧留白会被 trim;如果结果为空则回退到原文。
450
+ */
451
+ function splitImageOut(content) {
452
+ const re = /<image\s+url=["'][^"']*["']\s*\/>/g;
453
+ const out = [];
454
+ let last = 0;
455
+ let m = re.exec(content);
456
+ while (m !== null) {
457
+ if (m.index > last) {
458
+ const text = content.slice(last, m.index).trim();
459
+ if (text)
460
+ out.push(text);
461
+ }
462
+ out.push(m[0]);
463
+ last = m.index + m[0].length;
464
+ m = re.exec(content);
465
+ }
466
+ if (last < content.length) {
467
+ const tail = content.slice(last).trim();
468
+ if (tail)
469
+ out.push(tail);
470
+ }
471
+ return out.length > 0 ? out : [content];
472
+ }
473
+ // ===== 插件入口 =====
474
+ export function apply(ctx, config) {
475
+ const storage = createStorageGateway(ctx);
476
+ const proc = createProcessGateway(ctx);
477
+ const connections = Array.isArray(config.connections)
478
+ ? config.connections
479
+ : [];
480
+ // 消息分条配置
481
+ const splitCfg = (config.splitMessage ?? {});
482
+ const splitEnabled = splitCfg.enabled === true;
483
+ const splitDelayPerChar = Math.max(0, splitCfg.delayPerChar ?? 50);
484
+ const splitMaxDelay = Math.max(0, splitCfg.maxDelay ?? 3000);
485
+ const splitPatterns = resolveSplitPatterns(splitCfg.patterns ?? ['。', '!', '?', '.', '!', '?', '\\n']);
486
+ // 聊天流控配置已迁移至 plugin-flow-control / plugin-trigger-policy。
487
+ // 合并转发处理配置。
488
+ const forwardCfg = parseForwardConfig(config);
489
+ // 引用消息处理配置
490
+ const replyRaw = (config.reply ?? {});
491
+ const replyCfg = {
492
+ maxDepth: typeof replyRaw.maxDepth === 'number' ? Math.max(1, Math.floor(replyRaw.maxDepth)) : 5,
493
+ };
494
+ // 附件落盘上限(image / audio / video / file 共用同一阈值)
495
+ const attCacheRaw = (config.attachmentCache ?? {});
496
+ const attachmentMaxBytes = typeof attCacheRaw.maxBytes === 'number' && attCacheRaw.maxBytes > 0 ? attCacheRaw.maxBytes : 10 * 1024 * 1024;
497
+ if (connections.length === 0) {
498
+ ctx.logger.info('OneBot 适配器未配置任何连接');
499
+ }
500
+ const states = [];
501
+ // ===== 用户昵称缓存(userId → nickname,从每条消息的 sender 信息累积) =====
502
+ const nicknameCache = new Map();
503
+ // ===== 待处理请求(好友/入群)=====
504
+ // key: userId(好友请求)或 `${userId}:${groupId}`(群请求)
505
+ const pendingFriendRequests = new Map();
506
+ const pendingGroupRequests = new Map();
507
+ const pendingInviteCardSuppressions = new Map();
508
+ function inviteCardSuppressionKey(selfId, userId) {
509
+ return `${selfId}:${userId}`;
510
+ }
511
+ function rememberInviteCardSuppression(req) {
512
+ if (req.requestType !== 'group' || req.subType !== 'invite')
513
+ return;
514
+ pendingInviteCardSuppressions.set(inviteCardSuppressionKey(req.selfId, req.userId), Date.now() + INVITE_CARD_SUPPRESS_WINDOW);
515
+ }
516
+ function shouldSuppressInviteCardMessage(event) {
517
+ if (event.detailType !== 'private' || !event.userId || event.text.trim() !== '[JSON卡片]')
518
+ return false;
519
+ const key = inviteCardSuppressionKey(event.selfId, event.userId);
520
+ const pending = pendingInviteCardSuppressions.get(key);
521
+ if (!pending)
522
+ return false;
523
+ if (pending < Date.now()) {
524
+ pendingInviteCardSuppressions.delete(key);
525
+ return false;
526
+ }
527
+ pendingInviteCardSuppressions.delete(key);
528
+ return true;
529
+ }
530
+ // ===== 桥接:会话元数据 + 平台 notice 入档 + 自禁言桥接 =====
531
+ //
532
+ // 流控/触发判定均已迁移;适配器只保留以下三类辅助:
533
+ // 1. sessionMeta —— advisor.listSessionCandidates 提供 hint
534
+ // 2. archivePlatformNotice —— 平台事件入档
535
+ // 3. recoverSelfMute / 群禁言 notice → flow-control.setMuted(由 flow-control 插件实际暂停触发)
536
+ /** 会话级元数据(仅用于 listSessionCandidates 时给 advisor 提供 hint) */
537
+ const sessionMeta = new Map();
538
+ function noteSessionMeta(sessionId, sessionType, opts) {
539
+ const prev = sessionMeta.get(sessionId);
540
+ sessionMeta.set(sessionId, {
541
+ sessionType,
542
+ groupName: opts?.groupName ?? prev?.groupName,
543
+ partnerNickname: opts?.partnerNickname ?? prev?.partnerNickname,
544
+ });
545
+ }
546
+ /** 自禁言记录(sessionId → untilTs,毫秒),用于 adapter.getSelfMutes() */
547
+ const selfMuted = new Map();
548
+ /** 已通过 shut_up_timestamp 完成恢复检查的会话集合 */
549
+ const muteRecoveryChecked = new Set();
550
+ /** 机器人自身近期发出消息记录,支撑 adapter.getSentMessages() / 撤回自己发的消息 */
551
+ const sentTracker = new SentMessageTracker();
552
+ function setSelfMute(sessionId, durationSec, platform = 'onebot') {
553
+ const flow = ctx.getService('flow-control');
554
+ if (durationSec > 0) {
555
+ selfMuted.set(sessionId, Date.now() + durationSec * 1000);
556
+ flow?.setMuted(sessionId, durationSec, platform);
557
+ }
558
+ else {
559
+ selfMuted.delete(sessionId);
560
+ flow?.setMuted(sessionId, 0);
561
+ }
562
+ }
563
+ // ── 平台 notice 入档 ──
564
+ async function archivePlatformNotice(opts) {
565
+ const archive = ctx.getService('message-archive');
566
+ if (!archive?.archiveNotice)
567
+ return;
568
+ try {
569
+ await archive.archiveNotice({
570
+ sessionId: opts.sessionId,
571
+ noticeType: opts.noticeType,
572
+ subType: opts.subType,
573
+ content: opts.content,
574
+ platform: 'onebot',
575
+ userId: opts.userId,
576
+ targetId: opts.targetId,
577
+ groupId: opts.groupId,
578
+ operatorId: opts.operatorId,
579
+ data: opts.data,
580
+ timestamp: Date.now(),
581
+ });
582
+ }
583
+ catch (err) {
584
+ ctx.logger.warn(`notice 入档失败 (${opts.noticeType}): ${err}`);
585
+ }
586
+ }
587
+ // ── 通过 selfId 反查连接 ──
588
+ function findStateBySelfId(selfId) {
589
+ if (!selfId)
590
+ return undefined;
591
+ const target = String(selfId);
592
+ return states.find(s => s.selfId != null && String(s.selfId) === target);
593
+ }
594
+ function parseGroupSessionId(sessionId) {
595
+ // 形如 onebot:{selfId}:group:{groupId}
596
+ const parts = sessionId.split(':');
597
+ if (parts.length >= 4 && parts[0] === 'onebot' && parts[2] === 'group') {
598
+ return { selfId: parts[1], groupId: parts.slice(3).join(':') };
599
+ }
600
+ return {};
601
+ }
602
+ /** 启动/重连后通过 get_group_member_info.shut_up_timestamp 恢复禁言状态(每会话一次) */
603
+ async function recoverSelfMuteIfNeeded(sessionId) {
604
+ if (muteRecoveryChecked.has(sessionId))
605
+ return;
606
+ muteRecoveryChecked.add(sessionId);
607
+ const existing = selfMuted.get(sessionId) ?? 0;
608
+ if (Date.now() < existing)
609
+ return;
610
+ const { selfId, groupId } = parseGroupSessionId(sessionId);
611
+ if (!selfId || !groupId)
612
+ return;
613
+ const state = findStateBySelfId(selfId);
614
+ if (!state || state.status !== 'online')
615
+ return;
616
+ try {
617
+ const data = (await sendAction(state, 'get_group_member_info', {
618
+ group_id: Number(groupId) || groupId,
619
+ user_id: Number(selfId) || selfId,
620
+ no_cache: true,
621
+ }));
622
+ const ts = Number(data.shut_up_timestamp ?? 0);
623
+ const nowSec = Math.floor(Date.now() / 1000);
624
+ if (ts > nowSec) {
625
+ const remainSec = ts - nowSec;
626
+ setSelfMute(sessionId, remainSec);
627
+ ctx.logger.info(`[禁言恢复] session=${sessionId} 检测到 shut_up_timestamp=${ts},剩余 ${remainSec}s,已恢复禁言状态`);
628
+ await archivePlatformNotice({
629
+ sessionId,
630
+ noticeType: 'group_ban',
631
+ subType: 'recovered',
632
+ content: `[notice/group_ban/recovered] 检测到我在该群仍处于禁言状态,剩余约 ${remainSec} 秒(重启/重连后从 shut_up_timestamp 恢复)`,
633
+ targetId: selfId,
634
+ groupId,
635
+ data: { duration: remainSec, until: ts * 1000 },
636
+ });
637
+ }
638
+ }
639
+ catch (err) {
640
+ // 静默失败:可能是协议端不支持或群已退出
641
+ ctx.logger.debug(`[禁言恢复] session=${sessionId} shut_up_timestamp 查询失败: ${err instanceof Error ? err.message : String(err)}`);
642
+ }
643
+ }
644
+ const groupInfoCache = new Map();
645
+ const GROUP_CACHE_TTL = 10 * 60 * 1000; // 10 分钟
646
+ /** 获取群信息(带缓存) */
647
+ async function getGroupInfo(state, groupId) {
648
+ const cached = groupInfoCache.get(groupId);
649
+ if (cached && Date.now() - cached.fetchedAt < GROUP_CACHE_TTL)
650
+ return cached;
651
+ try {
652
+ const data = (await sendAction(state, 'get_group_info', {
653
+ group_id: Number(groupId) || groupId,
654
+ }));
655
+ const info = {
656
+ name: String(data.group_name ?? ''),
657
+ memberCount: data.member_count != null ? Number(data.member_count) : undefined,
658
+ fetchedAt: Date.now(),
659
+ };
660
+ if (info.name)
661
+ groupInfoCache.set(groupId, info);
662
+ return info;
663
+ }
664
+ catch {
665
+ return null;
666
+ }
667
+ }
668
+ /** key = `${selfId}:${groupId}` */
669
+ const selfMemberInfoCache = new Map();
670
+ const SELF_MEMBER_CACHE_TTL = 10 * 60 * 1000; // 10 分钟
671
+ /**
672
+ * 获取 self 账号在指定群内的角色与头衔(带缓存)。
673
+ * 用于把"自己是管理员"等关键身份信息注入到 system prompt,
674
+ * 避免 LLM 误以为自己只是普通群员。
675
+ */
676
+ async function getSelfMemberInfo(state, selfId, groupId) {
677
+ const key = `${selfId}:${groupId}`;
678
+ const cached = selfMemberInfoCache.get(key);
679
+ if (cached && Date.now() - cached.fetchedAt < SELF_MEMBER_CACHE_TTL)
680
+ return cached;
681
+ try {
682
+ const data = (await sendAction(state, 'get_group_member_info', {
683
+ group_id: Number(groupId) || groupId,
684
+ user_id: Number(selfId) || selfId,
685
+ }));
686
+ const rawRole = data.role;
687
+ const role = rawRole === 'owner' || rawRole === 'admin' || rawRole === 'member' ? rawRole : undefined;
688
+ const rawTitle = data.title;
689
+ const title = typeof rawTitle === 'string' && rawTitle.length > 0 ? rawTitle : undefined;
690
+ const info = { role, title, fetchedAt: Date.now() };
691
+ selfMemberInfoCache.set(key, info);
692
+ return info;
693
+ }
694
+ catch (err) {
695
+ ctx.logger.debug(`获取 self 群身份失败 (selfId=${selfId} groupId=${groupId}): ${err instanceof Error ? err.message : String(err)}`);
696
+ return null;
697
+ }
698
+ }
699
+ /** 获取用户昵称(群聊优先取群名片,私聊取陌生人昵称) */
700
+ async function resolveNickname(state, userId, groupId) {
701
+ if (!userId)
702
+ return undefined;
703
+ try {
704
+ if (groupId) {
705
+ const data = (await sendAction(state, 'get_group_member_info', {
706
+ group_id: Number(groupId) || groupId,
707
+ user_id: Number(userId) || userId,
708
+ }));
709
+ return data.card || data.nickname || undefined;
710
+ }
711
+ const data = (await sendAction(state, 'get_stranger_info', {
712
+ user_id: Number(userId) || userId,
713
+ }));
714
+ return data.nickname || undefined;
715
+ }
716
+ catch {
717
+ return undefined;
718
+ }
719
+ }
720
+ /**
721
+ * 获取引用消息的内容并渲染为可读文本。
722
+ *
723
+ * 与主消息流程对称:
724
+ * - 复用 segmentsToText 完整渲染所有段(at / 图片 / forward 占位 / face / record / video …)
725
+ * - 嵌套 forward:调用 expandForwardsInText 展开为信封+摘要
726
+ * - 图片:仅在描述缓存命中时注入识别结果,未命中保留 `[图片]` 占位符
727
+ * (主消息流的图片识别会自动写缓存,因此先发图、后被引用的常见路径能复用)
728
+ */
729
+ function findReplySegmentId(segments) {
730
+ for (const seg of segments) {
731
+ if (seg.type !== 'reply')
732
+ continue;
733
+ const data = seg.data;
734
+ const id = data?.id ?? data?.message_id;
735
+ if (id != null)
736
+ return String(id);
737
+ }
738
+ return undefined;
739
+ }
740
+ async function fetchReplyMessage(state, messageId, sessionId, depth = 0, seen = new Set()) {
741
+ if (depth >= replyCfg.maxDepth || seen.has(messageId))
742
+ return null;
743
+ seen.add(messageId);
744
+ // 0. 优先查我们自己的归档:命中即可拿到已烘焙了图片描述、forward 摘要等的"富文本"原文,
745
+ // 避免再走 OneBot get_msg(且能跨 URL 鉴权失效)。
746
+ if (sessionId) {
747
+ const archive = ctx.getService('message-archive');
748
+ if (archive?.findByMessageId) {
749
+ try {
750
+ const archived = await archive.findByMessageId(sessionId, messageId);
751
+ if (archived) {
752
+ const meta = (archived.metadata ?? {});
753
+ const userId = meta.userId != null ? String(meta.userId) : undefined;
754
+ const nickname = meta.nickname != null ? String(meta.nickname) : undefined;
755
+ // 归档正文带 `[nick(id)]: ` 前缀(prefixSender 的产物),引用渲染层会再加发送者标签,
756
+ // 这里剥掉首行前缀避免重复。
757
+ let body = archived.content ?? '';
758
+ const label = getSenderLabel(nickname, userId);
759
+ if (label && body.startsWith(`[${label}]: `)) {
760
+ body = body.slice(`[${label}]: `.length);
761
+ }
762
+ return { content: body || undefined, userId, nickname };
763
+ }
764
+ }
765
+ catch (err) {
766
+ ctx.logger.debug(`引用消息归档反查失败: ${err}`);
767
+ }
768
+ }
769
+ }
770
+ try {
771
+ const data = (await sendAction(state, 'get_msg', {
772
+ message_id: Number(messageId) || messageId,
773
+ }));
774
+ const segments = Array.isArray(data.message) ? data.message : [];
775
+ const sender = data.sender;
776
+ const nickname = sender?.card || sender?.nickname || undefined;
777
+ // 1. 用与主流程同款渲染器把所有段转成可读文本
778
+ let content = segments.length > 0 ? segmentsToText(segments, state.selfId) : (data.raw_message ?? '');
779
+ const nestedReplyId = findReplySegmentId(segments);
780
+ if (nestedReplyId) {
781
+ const nested = await fetchReplyMessage(state, nestedReplyId, sessionId, depth + 1, seen);
782
+ if (nested?.content) {
783
+ const nestedLabel = nested.nickname || nested.userId || '?';
784
+ content += `\n[该引用消息又引用 ${nestedLabel} 的消息: ${nested.content}]`;
785
+ }
786
+ }
787
+ // 2. 嵌套合并转发:展开为信封 + 摘要(命中现有 forward 缓存即零开销)
788
+ if (content.includes('<forward id=')) {
789
+ try {
790
+ content = await expandForwardsInText(state, content, segments);
791
+ }
792
+ catch (err) {
793
+ ctx.logger.debug(`引用消息中的合并转发展开失败: ${err}`);
794
+ }
795
+ }
796
+ // 3. 引用消息中的图片:仅查描述缓存复用,不主动触发视觉模型
797
+ if (content.includes('[图片]')) {
798
+ const media = ctx.getService('media');
799
+ if (media?.lookupDescription) {
800
+ const imageUrls = [];
801
+ for (const seg of segments) {
802
+ const s = seg;
803
+ if (s.type === 'image') {
804
+ const url = s.data?.url;
805
+ if (typeof url === 'string')
806
+ imageUrls.push(url);
807
+ }
808
+ }
809
+ if (imageUrls.length > 0) {
810
+ let urlIdx = 0;
811
+ content = content.replace(/\[图片\]/g, () => {
812
+ const url = imageUrls[urlIdx++];
813
+ if (!url)
814
+ return '[图片]';
815
+ const desc = media.lookupDescription(url);
816
+ return desc ? `[图片: ${desc}]` : '[图片]';
817
+ });
818
+ }
819
+ }
820
+ }
821
+ return {
822
+ content: content || undefined,
823
+ userId: data.user_id != null ? String(data.user_id) : undefined,
824
+ nickname,
825
+ };
826
+ }
827
+ catch {
828
+ return null;
829
+ }
830
+ }
831
+ // ===== 合并转发自动展开(详见 ./forward-expand.ts)=====
832
+ const forwardExpander = createForwardExpander({
833
+ ctx,
834
+ forwardCfg,
835
+ sendAction,
836
+ });
837
+ const { getCachedForward, setCachedForward, loadPersistedForward, fetchForwardOnce, expandForwardsInText } = forwardExpander;
838
+ // ----- Action 发送 -----
839
+ function sendAction(state, action, params) {
840
+ return new Promise((resolve, reject) => {
841
+ if (!state.ws || state.ws.readyState !== WebSocket.OPEN) {
842
+ reject(new Error('WebSocket 未连接'));
843
+ return;
844
+ }
845
+ const echo = nextEcho();
846
+ const timer = setTimeout(() => {
847
+ state.pendingActions.delete(echo);
848
+ reject(new Error(`Action ${action} 超时`));
849
+ }, ACTION_TIMEOUT);
850
+ state.pendingActions.set(echo, { resolve, reject, timer });
851
+ const payload = JSON.stringify({ action, params, echo });
852
+ state.ws.send(payload);
853
+ });
854
+ }
855
+ // ----- 版本自动检测 -----
856
+ async function detectProtocol(state) {
857
+ // 策略: 先尝试 v11 的 get_version_info,成功则为 v11
858
+ // 失败则尝试 v12 的 get_version,成功则为 v12
859
+ // 都失败则默认 v11(更常见)
860
+ try {
861
+ const data = await sendAction(state, 'get_version_info', {});
862
+ const info = data;
863
+ const protoVer = String(info?.protocol_version ?? '');
864
+ ctx.logger.info(`OneBot 版本检测: get_version_info 成功 (protocol_version=${protoVer}, app=${info?.app_name ?? 'unknown'})`);
865
+ // 有些实现可能报 v12 但走的 v11 接口,以接口可用性为准
866
+ return protocolV11;
867
+ }
868
+ catch {
869
+ // get_version_info 不可用,尝试 v12
870
+ }
871
+ try {
872
+ const data = await sendAction(state, 'get_version', {});
873
+ const info = data;
874
+ ctx.logger.info(`OneBot 版本检测: get_version 成功 (impl=${info?.impl ?? 'unknown'}, onebot_version=${info?.onebot_version ?? '?'})`);
875
+ return protocolV12;
876
+ }
877
+ catch {
878
+ // 也不可用
879
+ }
880
+ ctx.logger.warn('OneBot 版本自动检测失败,默认使用 v11 协议');
881
+ return protocolV11;
882
+ }
883
+ // ----- PlatformAdapter 实现 -----
884
+ const adapter = {
885
+ adapterName: 'OneBot',
886
+ platform: 'onebot',
887
+ sessionTypes: ['group', 'private'],
888
+ getConnections() {
889
+ return states.map(s => ({
890
+ id: `onebot:${s.selfId ?? s.config.url}`,
891
+ platform: 'onebot',
892
+ selfId: s.selfId,
893
+ selfNickname: s.selfNickname,
894
+ status: s.status,
895
+ detail: {
896
+ url: s.config.url,
897
+ protocol: s.protocol?.version ?? 'unknown',
898
+ nickname: s.selfNickname,
899
+ },
900
+ }));
901
+ },
902
+ getSelfIdentity(sessionId) {
903
+ const parsed = sessionId ? parseSessionId(sessionId) : null;
904
+ const state = parsed
905
+ ? findStateBySelfId(parsed.selfId)
906
+ : states.find(s => s.status === 'online' && (s.selfId || s.selfNickname));
907
+ if (!state || (!state.selfId && !state.selfNickname))
908
+ return undefined;
909
+ return {
910
+ platform: 'onebot',
911
+ selfId: state.selfId,
912
+ nickname: state.selfNickname,
913
+ };
914
+ },
915
+ isReady() {
916
+ return states.some(s => s.status === 'online');
917
+ },
918
+ async sendMessage(sessionId, content, options) {
919
+ const parsed = parseSessionId(sessionId);
920
+ if (!parsed) {
921
+ ctx.logger.warn(`无法解析 sessionId: ${sessionId}`);
922
+ return;
923
+ }
924
+ const state = findStateBySelfId(parsed.selfId);
925
+ if (!state || state.status !== 'online' || !state.ws || !state.protocol) {
926
+ const knownIds = states.map(s => `${s.selfId ?? '?'}(${s.status})`).join(', ') || '无';
927
+ const reason = !state
928
+ ? `未找到对应连接(已知: ${knownIds})`
929
+ : state.status !== 'online'
930
+ ? `状态=${state.status}`
931
+ : !state.ws
932
+ ? 'ws 为空'
933
+ : '协议未初始化';
934
+ ctx.logger.warn(`OneBot 连接不可用: selfId=${parsed.selfId} (${reason})`);
935
+ return;
936
+ }
937
+ // 消息分条发送(指令回复等短消息可跳过)
938
+ const punctPieces = splitEnabled && !options?.skipSplit ? splitMessageByPunctuation(content, splitPatterns) : [content];
939
+ // 进一步拆出 <image .../>:图片始终独立成条,不与文本粘连。
940
+ const pieces = punctPieces.flatMap(p => splitImageOut(p));
941
+ for (let i = 0; i < pieces.length; i++) {
942
+ const piece = pieces[i].trim();
943
+ if (!piece)
944
+ continue;
945
+ const { action, params } = state.protocol.buildSendMessage({
946
+ detailType: parsed.detailType,
947
+ targetId: parsed.targetId,
948
+ content: piece,
949
+ });
950
+ const sendResult = await sendAction(state, action, params);
951
+ // 记录发出消息的 message_id,支撑「撤回自己发的消息」(每分条各自独立的 id)
952
+ const sentId = extractSentMessageId(sendResult);
953
+ if (sentId)
954
+ sentTracker.record(sessionId, sentId, piece, Date.now());
955
+ // 按下一条消息的字数计算延迟
956
+ if (i < pieces.length - 1) {
957
+ const nextPiece = pieces[i + 1];
958
+ const charCount = nextPiece ? nextPiece.replace(/<[^>]+>/g, '').length : 0;
959
+ const delay = Math.min(charCount * splitDelayPerChar, splitMaxDelay);
960
+ if (delay > 0) {
961
+ await new Promise(r => setTimeout(r, delay));
962
+ }
963
+ }
964
+ }
965
+ },
966
+ async callAction(sessionId, action, params) {
967
+ const parsed = parseSessionId(sessionId);
968
+ if (!parsed)
969
+ throw new Error(`无法解析 sessionId: ${sessionId}`);
970
+ const state = findStateBySelfId(parsed.selfId);
971
+ if (!state || state.status !== 'online' || !state.ws) {
972
+ throw new Error(`OneBot 连接不可用: selfId=${parsed.selfId}`);
973
+ }
974
+ // 合并转发:优先走缓存(消息到达时已抓过一次),失败再依次尝试多种参数键。
975
+ if (action === 'get_forward_msg') {
976
+ const id = String(params.id ?? params.message_id ?? params.res_id ?? params.m_resid ?? '');
977
+ if (id) {
978
+ // 内存缓存
979
+ let entry = getCachedForward(id);
980
+ // 持久化兜底(重启后场景)
981
+ if (!entry) {
982
+ const persisted = await loadPersistedForward(id);
983
+ if (persisted) {
984
+ setCachedForward(id, persisted);
985
+ entry = persisted;
986
+ }
987
+ }
988
+ if (entry) {
989
+ // 返回完整原文 + 摘要(如果有)。工具层会优先使用 fullText 渲染。
990
+ return {
991
+ __aalisForwardEntry: true,
992
+ fullText: entry.fullText,
993
+ summary: entry.summary,
994
+ count: entry.count,
995
+ participants: entry.participants,
996
+ };
997
+ }
998
+ const data = await fetchForwardOnce(state, id);
999
+ if (data)
1000
+ return data;
1001
+ throw new Error('get_forward_msg 失败:所有参数键(id/message_id/res_id/m_resid)均无法取得内容');
1002
+ }
1003
+ }
1004
+ return sendAction(state, action, params);
1005
+ },
1006
+ /**
1007
+ * 非标准扩展:主动发送消息前的限速校验 + 计数。
1008
+ *
1009
+ * 委托 plugin-flow-control 的 isRateLimited / recordReply。
1010
+ * 若 flow-control 未加载或会话不存在,默认放行(不限速)。
1011
+ */
1012
+ checkAndRecordProactiveSend(sessionId) {
1013
+ const flow = ctx.getService('flow-control');
1014
+ if (!flow)
1015
+ return { allowed: true };
1016
+ if (flow.isRateLimited(sessionId)) {
1017
+ return {
1018
+ allowed: false,
1019
+ reason: '已达限速上限(由 flow-control 决定)',
1020
+ };
1021
+ }
1022
+ flow.recordReply(sessionId, 'onebot');
1023
+ return { allowed: true };
1024
+ },
1025
+ /**
1026
+ * 非标准扩展:返回当前进程内已知"自身被禁言"的群快照。
1027
+ * 数据来自适配器自身的 selfMuted(由 group_ban notice 与 shut_up_timestamp 恢复维护)。
1028
+ */
1029
+ getSelfMutes() {
1030
+ const now = Date.now();
1031
+ const out = [];
1032
+ for (const [sid, untilTs] of selfMuted) {
1033
+ if (untilTs > now) {
1034
+ const { selfId, groupId } = parseGroupSessionId(sid);
1035
+ if (selfId && groupId) {
1036
+ out.push({ selfId, groupId, untilTs, remainingSec: Math.ceil((untilTs - now) / 1000) });
1037
+ }
1038
+ }
1039
+ else {
1040
+ selfMuted.delete(sid);
1041
+ }
1042
+ }
1043
+ return out;
1044
+ },
1045
+ /**
1046
+ * 非标准扩展:返回机器人自身在某会话近期发出的消息(新→旧),用于「撤回自己发的消息」。
1047
+ */
1048
+ getSentMessages(sessionId, limit = 10) {
1049
+ return sentTracker.recent(sessionId, limit, Date.now());
1050
+ },
1051
+ /** 非标准扩展:撤回成功后从记录中移除一条(使「撤回最近一条」可重复往前走)。 */
1052
+ forgetSentMessage(sessionId, messageId) {
1053
+ sentTracker.forget(sessionId, messageId);
1054
+ },
1055
+ /** 处理好友请求:approve=true 同意,remark 为备注(同意时有效) */
1056
+ async handleFriendRequest(userId, approve, remark) {
1057
+ const pending = pendingFriendRequests.get(userId);
1058
+ if (!pending)
1059
+ return `未找到来自 ${userId} 的好友申请(可能已过期或已处理)`;
1060
+ const state = findStateBySelfId(pending.selfId);
1061
+ if (!state || state.status !== 'online')
1062
+ return '连接不可用,无法处理请求';
1063
+ await sendAction(state, 'set_friend_add_request', {
1064
+ flag: pending.flag,
1065
+ approve,
1066
+ remark: remark ?? '',
1067
+ });
1068
+ pendingFriendRequests.delete(userId);
1069
+ return approve
1070
+ ? `已同意 ${userId} 的好友申请${remark ? `,备注: ${remark}` : ''}`
1071
+ : `已拒绝 ${userId} 的好友申请`;
1072
+ },
1073
+ /** 处理群请求(加群申请或入群邀请):approve=true 同意,reason 为拒绝理由 */
1074
+ async handleGroupRequest(userId, groupId, approve, reason) {
1075
+ const key = `${userId}:${groupId}`;
1076
+ const pending = pendingGroupRequests.get(key);
1077
+ if (!pending)
1078
+ return `未找到来自 ${userId} 关于群 ${groupId} 的请求(可能已过期或已处理)`;
1079
+ const state = findStateBySelfId(pending.selfId);
1080
+ if (!state || state.status !== 'online')
1081
+ return '连接不可用,无法处理请求';
1082
+ await sendAction(state, 'set_group_add_request', {
1083
+ flag: pending.flag,
1084
+ sub_type: pending.subType,
1085
+ approve,
1086
+ reason: reason ?? '',
1087
+ });
1088
+ pendingGroupRequests.delete(key);
1089
+ const typeLabel = pending.subType === 'invite' ? '入群邀请' : '加群申请';
1090
+ return approve
1091
+ ? `已同意 ${userId} 的${typeLabel}(群 ${groupId})`
1092
+ : `已拒绝 ${userId} 的${typeLabel}(群 ${groupId})${reason ? `,理由: ${reason}` : ''}`;
1093
+ },
1094
+ };
1095
+ ctx.provide('platform', adapter, {
1096
+ capabilities: ['onebot', 'text', 'image', 'voice', 'forward', 'group-chat', 'private-chat', 'call-action'],
1097
+ });
1098
+ // ----- 连接管理 -----
1099
+ function connectOne(connConfig) {
1100
+ const state = {
1101
+ config: connConfig,
1102
+ status: 'offline',
1103
+ selfId: connConfig.selfId != null ? String(connConfig.selfId) : undefined,
1104
+ lastPong: 0,
1105
+ pendingActions: new Map(),
1106
+ };
1107
+ // 根据配置预设协议版本
1108
+ const proto = connConfig.protocol ?? 'auto';
1109
+ if (proto === 'v11') {
1110
+ state.protocol = protocolV11;
1111
+ }
1112
+ else if (proto === 'v12') {
1113
+ state.protocol = protocolV12;
1114
+ }
1115
+ // 'auto' 时 state.protocol 在连接后检测设置
1116
+ states.push(state);
1117
+ doConnect(state);
1118
+ return state;
1119
+ }
1120
+ function stopHeartbeat(state) {
1121
+ if (state.heartbeatTimer) {
1122
+ clearInterval(state.heartbeatTimer);
1123
+ state.heartbeatTimer = undefined;
1124
+ }
1125
+ }
1126
+ function doConnect(state) {
1127
+ if (ctx.disposed)
1128
+ return;
1129
+ state.status = 'connecting';
1130
+ ctx.logger.info(`正在连接 OneBot: ${state.config.url} (协议: ${state.protocol?.version ?? '待检测'})`);
1131
+ const headers = {};
1132
+ if (state.config.accessToken) {
1133
+ headers.Authorization = `Bearer ${state.config.accessToken}`;
1134
+ }
1135
+ const ws = new WebSocket(state.config.url, { headers });
1136
+ state.ws = ws;
1137
+ // 诊断:捕获 unexpected-response(服务器返回非 101 时触发,且不会触发 error)
1138
+ ws.on('unexpected-response', (_req, res) => {
1139
+ ctx.logger.warn(`OneBot unexpected-response: status=${res.statusCode}, headers=${JSON.stringify(res.headers)}`);
1140
+ let body = '';
1141
+ res.on('data', (chunk) => {
1142
+ body += chunk.toString();
1143
+ });
1144
+ res.on('end', () => {
1145
+ ctx.logger.warn(`OneBot unexpected-response body: ${body}`);
1146
+ });
1147
+ });
1148
+ // 连接超时:如果 WS 握手在 CONNECT_TIMEOUT 内未完成,主动关闭并触发重连
1149
+ const connectTimer = setTimeout(() => {
1150
+ if (ws.readyState === WebSocket.CONNECTING) {
1151
+ ctx.logger.warn(`OneBot 连接超时 (${CONNECT_TIMEOUT / 1000}s): ${state.config.url}, readyState=${ws.readyState}`);
1152
+ ws.terminate();
1153
+ }
1154
+ }, CONNECT_TIMEOUT);
1155
+ ws.on('upgrade', res => {
1156
+ ctx.logger.debug(`OneBot WS upgrade: status=${res.statusCode}`);
1157
+ });
1158
+ ws.on('open', () => {
1159
+ clearTimeout(connectTimer);
1160
+ state.status = 'online';
1161
+ state.lastPong = Date.now();
1162
+ ctx.logger.info(`OneBot 已连接: ${state.config.url}`);
1163
+ // 客户端心跳:定期 ping,检测待机后的死连接
1164
+ stopHeartbeat(state);
1165
+ ws.on('pong', () => {
1166
+ state.lastPong = Date.now();
1167
+ });
1168
+ state.heartbeatTimer = setInterval(() => {
1169
+ if (!state.ws || state.ws.readyState !== WebSocket.OPEN)
1170
+ return;
1171
+ if (Date.now() - state.lastPong > HEARTBEAT_INTERVAL + HEARTBEAT_TIMEOUT) {
1172
+ ctx.logger.warn(`OneBot 心跳超时,主动断开: ${state.config.url}`);
1173
+ state.ws.terminate();
1174
+ return;
1175
+ }
1176
+ state.ws.ping();
1177
+ }, HEARTBEAT_INTERVAL);
1178
+ onConnected(state);
1179
+ });
1180
+ ws.on('message', raw => {
1181
+ // 收到任何消息即说明链路存活,重置心跳计时器
1182
+ state.lastPong = Date.now();
1183
+ try {
1184
+ const data = JSON.parse(raw.toString());
1185
+ // Action 响应 (有 echo 字段且不为空字符串)
1186
+ if ('echo' in data && typeof data.echo === 'string' && data.echo !== '') {
1187
+ const resp = data;
1188
+ const pending = state.pendingActions.get(resp.echo);
1189
+ if (pending) {
1190
+ clearTimeout(pending.timer);
1191
+ state.pendingActions.delete(resp.echo);
1192
+ if (resp.status === 'ok') {
1193
+ pending.resolve(resp.data);
1194
+ }
1195
+ else {
1196
+ pending.reject(new Error(`OneBot action 失败: ${resp.message ?? resp.retcode}`));
1197
+ }
1198
+ }
1199
+ return;
1200
+ }
1201
+ // 事件分发(需要协议已确定)
1202
+ if (!state.protocol)
1203
+ return;
1204
+ const event = data;
1205
+ const eventType = state.protocol.parseEventType(event);
1206
+ if (eventType === 'message') {
1207
+ handleMessageEvent(state, event);
1208
+ }
1209
+ else if (eventType === 'meta') {
1210
+ handleMetaEvent(state, event);
1211
+ }
1212
+ else if (eventType === 'notice') {
1213
+ handleNoticeEvent(state, event);
1214
+ }
1215
+ else if (eventType === 'request') {
1216
+ handleRequestEvent(state, event);
1217
+ }
1218
+ else {
1219
+ // 未识别事件类型(如 NapCat 的 post_type='message_sent' 自身消息回显、自定义 post_type 等)
1220
+ const ev = event;
1221
+ const ident = String(ev.post_type ?? ev.type ?? 'unknown');
1222
+ ctx.logger.debug(`OneBot[${state.protocol.version}] 跳过未识别事件类型: post_type=${ident}, keys=[${Object.keys(ev).slice(0, 12).join(',')}]`);
1223
+ }
1224
+ }
1225
+ catch (err) {
1226
+ ctx.logger.debug('OneBot 消息解析失败:', err);
1227
+ }
1228
+ });
1229
+ ws.on('close', () => {
1230
+ clearTimeout(connectTimer);
1231
+ state.status = 'offline';
1232
+ state.ws = undefined;
1233
+ stopHeartbeat(state);
1234
+ for (const [, pending] of state.pendingActions) {
1235
+ clearTimeout(pending.timer);
1236
+ pending.reject(new Error('连接已关闭'));
1237
+ }
1238
+ state.pendingActions.clear();
1239
+ ctx.logger.warn(`OneBot 连接断开: ${state.config.url},${RECONNECT_INTERVAL / 1000}s 后重连`);
1240
+ scheduleReconnect(state);
1241
+ });
1242
+ ws.on('error', err => {
1243
+ clearTimeout(connectTimer);
1244
+ ctx.logger.warn(`OneBot 连接错误: ${err.message}, code=${err.code}, readyState=${ws.readyState}`);
1245
+ });
1246
+ }
1247
+ async function onConnected(state) {
1248
+ // 1. 如果协议未确定(auto),先检测
1249
+ if (!state.protocol) {
1250
+ try {
1251
+ state.protocol = await detectProtocol(state);
1252
+ ctx.logger.info(`OneBot 协议版本: ${state.protocol.version} (${state.config.url})`);
1253
+ }
1254
+ catch (err) {
1255
+ ctx.logger.warn(`OneBot 协议检测异常: ${err},默认使用 v11`);
1256
+ state.protocol = protocolV11;
1257
+ }
1258
+ }
1259
+ // 2. 获取 self info(即使配置已给 selfId,也尝试补齐昵称)
1260
+ if (!state.selfId || !state.selfNickname) {
1261
+ try {
1262
+ const action = state.protocol.getSelfInfoAction();
1263
+ const data = await sendAction(state, action, {});
1264
+ const selfInfo = state.protocol.parseSelfInfo(data);
1265
+ if (selfInfo.userId)
1266
+ state.selfId = selfInfo.userId;
1267
+ if (selfInfo.nickname)
1268
+ state.selfNickname = selfInfo.nickname;
1269
+ if (selfInfo.userId || selfInfo.nickname) {
1270
+ const namePart = state.selfNickname ? `, nickname=${state.selfNickname}` : '';
1271
+ ctx.logger.info(`OneBot self_id: ${state.selfId ?? '?'}${namePart} (via ${action})`);
1272
+ }
1273
+ }
1274
+ catch (err) {
1275
+ ctx.logger.debug(`获取 self info 失败: ${err}`);
1276
+ }
1277
+ }
1278
+ // 3. 重置该 selfId 下所有群会话的「自禁言恢复检查」标记,
1279
+ // 确保重连后下一条消息会重新通过 shut_up_timestamp 校验当前禁言状态
1280
+ if (state.selfId) {
1281
+ const prefix = `onebot:${state.selfId}:group:`;
1282
+ for (const sid of Array.from(muteRecoveryChecked)) {
1283
+ if (sid.startsWith(prefix))
1284
+ muteRecoveryChecked.delete(sid);
1285
+ }
1286
+ }
1287
+ }
1288
+ function scheduleReconnect(state) {
1289
+ if (ctx.disposed)
1290
+ return;
1291
+ if (state.reconnectTimer)
1292
+ clearTimeout(state.reconnectTimer);
1293
+ ctx.logger.info(`OneBot 将在 ${RECONNECT_INTERVAL / 1000}s 后尝试重连: ${state.config.url}`);
1294
+ state.reconnectTimer = setTimeout(() => {
1295
+ state.reconnectTimer = undefined;
1296
+ ctx.logger.info(`OneBot 正在重试连接: ${state.config.url}`);
1297
+ doConnect(state);
1298
+ }, RECONNECT_INTERVAL);
1299
+ }
1300
+ // ----- 事件处理 -----
1301
+ function handleMessageEvent(state, raw) {
1302
+ if (!state.protocol)
1303
+ return;
1304
+ // 从 sender 信息累积昵称缓存(在解析消息前,以便 at 标签能查到昵称)
1305
+ const sender = (raw.sender ?? raw.user);
1306
+ const rawUserId = raw.user_id != null ? String(raw.user_id) : undefined;
1307
+ if (rawUserId && sender) {
1308
+ const nick = sender.card || sender.nickname;
1309
+ if (nick)
1310
+ nicknameCache.set(rawUserId, nick);
1311
+ }
1312
+ const fallbackSelfId = state.selfId ?? 'unknown';
1313
+ const event = state.protocol.parseMessageEvent(raw, fallbackSelfId, nicknameCache);
1314
+ if (!event) {
1315
+ // 解析器把消息丢弃(通常是文本为空),打日志暴露原始段类型以便诊断
1316
+ const segs = Array.isArray(raw.message)
1317
+ ? raw.message.map(s => String(s?.type ?? '?')).filter(Boolean)
1318
+ : [];
1319
+ const rawText = typeof raw.raw_message === 'string' ? raw.raw_message : '';
1320
+ ctx.logger.debug(`OneBot[${state.protocol.version}] 消息事件被解析器丢弃(text 为空): post_type=${String(raw.post_type ?? raw.type ?? '?')}, message_type=${String(raw.message_type ?? raw.detail_type ?? '?')}, segments=[${segs.join(',')}], raw_message=${rawText}`);
1321
+ return;
1322
+ }
1323
+ // 更新 selfId
1324
+ if (event.selfId !== 'unknown' && !state.selfId) {
1325
+ state.selfId = event.selfId;
1326
+ }
1327
+ if (shouldSuppressInviteCardMessage(event)) {
1328
+ ctx.logger.debug(`OneBot[${state.protocol.version}] 忽略重复入群邀请 JSON 卡片: userId=${event.userId}`);
1329
+ return;
1330
+ }
1331
+ const sessionId = makeSessionId(event.selfId, event.detailType, event.userId, event.groupId, event.guildId, event.channelId);
1332
+ ctx.logger.debug(`OneBot[${state.protocol.version}] 收到消息 [${event.detailType}] ${event.userId ?? '?'}: ${event.text}`);
1333
+ // 注:指令解析已迁移到 plugin-commands 的 inbound:command 相位;
1334
+ // 适配器只负责将原始消息送入 inbound:message 总线,由 gateway 链路统一拦截。
1335
+ const sessionType = event.detailType === 'group'
1336
+ ? 'group'
1337
+ : event.detailType === 'private'
1338
+ ? 'private'
1339
+ : event.detailType === 'channel'
1340
+ ? 'channel'
1341
+ : undefined;
1342
+ // 异步获取群信息、引用消息,并执行流控判定
1343
+ (async () => {
1344
+ let groupName;
1345
+ let replyTo;
1346
+ // ----- 统一附件落盘 -----
1347
+ // 所有 image / audio / video / file 统一缓存到 data/{kind}s/{session}/,
1348
+ // 让历史回放、跨轮工具调用、人工归档使用同一份目录布局。
1349
+ //
1350
+ // 特别地:
1351
+ // - audio:先调 OneBot get_record 把 QQ silk 转成可解码格式(NapCat 等
1352
+ // 实现常忽略 out_format 参数,返回 amr 原文),随后本地 ffmpeg
1353
+ // 强制转 16kHz mono WAV 再落盘,下游 LLM 直接消费。
1354
+ // - 落盘失败的项保留原 URL,文本占位也维持 [图片]/[语音]/... 原样。
1355
+ const atts = event.attachments ?? [];
1356
+ const localPaths = await Promise.all(atts.map(async (att) => {
1357
+ try {
1358
+ if (att.kind === 'audio' && state) {
1359
+ // get_record 把 silk → mp3/amr,返回 base64 / url / file 任一
1360
+ const fileRef = att.name ?? att.url;
1361
+ let source = att.url;
1362
+ if (fileRef) {
1363
+ try {
1364
+ const data = (await sendAction(state, 'get_record', {
1365
+ file: fileRef,
1366
+ out_format: 'mp3',
1367
+ }));
1368
+ if (data?.base64)
1369
+ source = `data:audio/mpeg;base64,${data.base64}`;
1370
+ else if (data?.url)
1371
+ source = String(data.url);
1372
+ else if (data?.file)
1373
+ source = `file://${data.file}`;
1374
+ ctx.logger.debug(`OneBot get_record 完成 (file=${fileRef}, ` +
1375
+ `kind=${data?.base64 ? 'base64' : data?.url ? 'url' : data?.file ? 'file' : 'empty'})`);
1376
+ }
1377
+ catch (err) {
1378
+ ctx.logger.debug(`OneBot get_record 转换失败 (file=${fileRef}): ${err}`);
1379
+ }
1380
+ }
1381
+ return await cacheOneAttachment(storage, proc, 'audio', source, sessionId, attachmentMaxBytes, ctx.logger);
1382
+ }
1383
+ return await cacheOneAttachment(storage, proc, att.kind, att.url, sessionId, attachmentMaxBytes, ctx.logger);
1384
+ }
1385
+ catch (err) {
1386
+ ctx.logger.debug(`OneBot 附件缓存异常 [${att.kind}]: ${err}`);
1387
+ return null;
1388
+ }
1389
+ }));
1390
+ // 把 [图片]/[语音]/[视频]/[文件] 占位重写为 [xxx | ref:data/...]
1391
+ if (atts.length > 0)
1392
+ event.text = rewritePlaceholdersWithRefs(event.text, atts, localPaths);
1393
+ // 主动展开合并转发:把 <forward id="X">[合并转发消息]</forward> 替换为可读文本,
1394
+ // 这样 LLM 不必再调用工具,且展开后的内容会随 inbound:message 进入历史归档。
1395
+ if (event.text.includes('<forward id=')) {
1396
+ try {
1397
+ event.text = await expandForwardsInText(state, event.text, event.message);
1398
+ }
1399
+ catch (err) {
1400
+ ctx.logger.debug(`合并转发自动展开失败: ${err}`);
1401
+ }
1402
+ }
1403
+ // 获取群名
1404
+ if (event.detailType === 'group' && event.groupId) {
1405
+ const info = await getGroupInfo(state, event.groupId);
1406
+ if (info?.name)
1407
+ groupName = info.name;
1408
+ }
1409
+ // 获取引用消息内容
1410
+ if (event.replyToMessageId) {
1411
+ const reply = await fetchReplyMessage(state, event.replyToMessageId, sessionId);
1412
+ replyTo = {
1413
+ messageId: event.replyToMessageId,
1414
+ content: reply?.content,
1415
+ userId: reply?.userId,
1416
+ nickname: reply?.nickname,
1417
+ };
1418
+ }
1419
+ // 适配器不再做流控/触发判定 —— 一律送入 inbound:message,
1420
+ // 由 plugin-flow-control / plugin-trigger-policy 在 inbound:flow / inbound:trigger 相位
1421
+ // 决定是否吞噬、归档、或继续派发给 agent。
1422
+ // 启动后/重连后通过 shut_up_timestamp 懒查询恢复禁言状态(每会话一次)
1423
+ if (sessionType === 'group') {
1424
+ void recoverSelfMuteIfNeeded(sessionId);
1425
+ }
1426
+ // 记录会话元数据(advisor.listSessionCandidates 用)
1427
+ if (sessionType) {
1428
+ noteSessionMeta(sessionId, sessionType, {
1429
+ groupName,
1430
+ partnerNickname: sessionType === 'private' ? event.nickname : undefined,
1431
+ });
1432
+ }
1433
+ // 群消息:拉取 self 在该群的角色/头衔(带缓存),让 agent 正确认知自身权限
1434
+ let selfRole;
1435
+ let selfTitle;
1436
+ if (sessionType === 'group' && event.groupId && state.selfId) {
1437
+ const selfInfo = await getSelfMemberInfo(state, state.selfId, event.groupId);
1438
+ if (selfInfo) {
1439
+ selfRole = selfInfo.role;
1440
+ selfTitle = selfInfo.title;
1441
+ }
1442
+ }
1443
+ ctx.emit('inbound:message', {
1444
+ content: event.text,
1445
+ sessionId,
1446
+ platform: 'onebot',
1447
+ messageId: event.messageId,
1448
+ userId: event.userId,
1449
+ nickname: event.nickname,
1450
+ attachments: event.attachments?.map((a, i) => {
1451
+ // 落盘成功 → data 用本地路径(下游 ffmpeg / vision / audio LLM
1452
+ // 都按本地文件读取,避免远程 URL 反复下载与 QQ 鉴权过期)。
1453
+ // 落盘失败 → 退回原 URL,下游各自尝试。
1454
+ const local = localPaths[i];
1455
+ return {
1456
+ kind: a.kind,
1457
+ data: local ?? a.url,
1458
+ // 落盘后的音频统一是 16kHz mono WAV
1459
+ mimeType: local && a.kind === 'audio' ? 'audio/wav' : a.mimeType,
1460
+ name: a.name,
1461
+ };
1462
+ }),
1463
+ sessionType,
1464
+ groupName,
1465
+ groupId: event.groupId,
1466
+ senderRole: event.senderRole,
1467
+ senderTitle: event.senderTitle,
1468
+ selfRole,
1469
+ selfTitle,
1470
+ replyTo,
1471
+ // triggerType 由 trigger-policy 在 inbound:trigger 相位中填充
1472
+ });
1473
+ })().catch(err => {
1474
+ ctx.logger.warn(`OneBot 消息处理异常: ${err}`);
1475
+ });
1476
+ }
1477
+ // ===== 请求事件处理(加好友 / 加群 / 邀请入群)=====
1478
+ function handleRequestEvent(state, raw) {
1479
+ if (!state.protocol)
1480
+ return;
1481
+ const fallbackSelfId = state.selfId ?? 'unknown';
1482
+ const req = state.protocol.parseRequestEvent(raw, fallbackSelfId);
1483
+ if (!req) {
1484
+ ctx.logger.debug(`OneBot[${state.protocol.version}] 跳过未识别 request: request_type=${String(raw.request_type ?? raw.detail_type ?? '?')}, sub_type=${String(raw.sub_type ?? '?')}`);
1485
+ return;
1486
+ }
1487
+ const requestLabel = req.requestType === 'group' ? `${req.requestType}/${req.subType}` : req.requestType;
1488
+ const requestGroupId = req.requestType === 'group' ? req.groupId : '-';
1489
+ ctx.logger.info(`OneBot[${state.protocol.version}] 请求事件: ${requestLabel}, userId=${req.userId}, groupId=${requestGroupId}`);
1490
+ if (req.requestType === 'friend') {
1491
+ // 存储待处理的好友请求 flag
1492
+ pendingFriendRequests.set(req.userId, { flag: req.flag, selfId: req.selfId });
1493
+ // 将请求包装为合成消息,交由 agent 决策(以私聊会话形式发送)
1494
+ const sessionId = makeSessionId(req.selfId, 'private', req.userId);
1495
+ const commentPart = req.comment ? `,验证信息:"${req.comment}"` : '';
1496
+ const content = `[系统通知] 用户 ${req.userId} 向我发出了好友申请${commentPart}。请决定是否同意,调用 onebot_handle_friend_request 工具处理(user_id="${req.userId}")。`;
1497
+ ctx
1498
+ .emit('inbound:message', {
1499
+ content,
1500
+ sessionId,
1501
+ platform: 'onebot',
1502
+ userId: req.userId,
1503
+ sessionType: 'private',
1504
+ })
1505
+ .catch((err) => ctx.logger.warn(`请求事件处理失败: ${err}`));
1506
+ }
1507
+ else if (req.requestType === 'group') {
1508
+ const key = `${req.userId}:${req.groupId}`;
1509
+ pendingGroupRequests.set(key, { flag: req.flag, subType: req.subType, selfId: req.selfId });
1510
+ rememberInviteCardSuppression(req);
1511
+ // 被邀请入群:以私聊形式通知(bot 还没在群里,无法发群消息)
1512
+ const sessionId = makeSessionId(req.selfId, 'private', req.userId);
1513
+ const groupPart = `群 ${req.groupId}`;
1514
+ const commentPart = req.comment ? `,备注:"${req.comment}"` : '';
1515
+ let content;
1516
+ if (req.subType === 'invite') {
1517
+ content = `[系统通知] 用户 ${req.userId} 邀请我加入${groupPart}${commentPart}。请决定是否接受邀请,调用 onebot_handle_group_invite 工具处理(user_id="${req.userId}", group_id="${req.groupId}")。`;
1518
+ }
1519
+ else {
1520
+ // sub_type === 'add': 有人申请加入 bot 管理的群(bot 是管理员)
1521
+ const gsId = makeSessionId(req.selfId, 'group', undefined, req.groupId);
1522
+ content = `[系统通知] 用户 ${req.userId} 申请加入${groupPart}${commentPart}。请决定是否同意,调用 onebot_approve_join_request 工具处理(user_id="${req.userId}", group_id="${req.groupId}")。`;
1523
+ ctx
1524
+ .emit('inbound:message', {
1525
+ content,
1526
+ sessionId: gsId,
1527
+ platform: 'onebot',
1528
+ userId: req.userId,
1529
+ sessionType: 'group',
1530
+ groupId: req.groupId,
1531
+ })
1532
+ .catch((err) => ctx.logger.warn(`群申请事件处理失败: ${err}`));
1533
+ return;
1534
+ }
1535
+ ctx
1536
+ .emit('inbound:message', {
1537
+ content,
1538
+ sessionId,
1539
+ platform: 'onebot',
1540
+ userId: req.userId,
1541
+ sessionType: 'private',
1542
+ })
1543
+ .catch((err) => ctx.logger.warn(`邀请事件处理失败: ${err}`));
1544
+ }
1545
+ }
1546
+ function handleNoticeEvent(state, raw) {
1547
+ if (!state.protocol)
1548
+ return;
1549
+ const fallbackSelfId = state.selfId ?? 'unknown';
1550
+ const notice = state.protocol.parseNoticeEvent(raw, fallbackSelfId);
1551
+ if (!notice) {
1552
+ ctx.logger.debug(`OneBot[${state.protocol.version}] 跳过未识别 notice: notice_type=${String(raw.notice_type ?? raw.detail_type ?? '?')}, sub_type=${String(raw.sub_type ?? '?')}`);
1553
+ return;
1554
+ }
1555
+ // 过滤高频无用通知(输入状态等)
1556
+ if (notice.noticeType === 'notify' && notice.subType === 'input_status')
1557
+ return;
1558
+ ctx.logger.debug(`OneBot[${state.protocol.version}] 通知事件: ${notice.noticeType}${notice.subType ? `/${notice.subType}` : ''}`);
1559
+ // 戳一戳 → 仅在目标是 bot 时触发 agent 回复
1560
+ if (notice.noticeType === 'poke') {
1561
+ const selfId = notice.selfId;
1562
+ const targetIsBot = notice.targetId === selfId;
1563
+ if (notice.groupId) {
1564
+ // 群聊 poke:只有被戳的是 bot 才回复
1565
+ if (!targetIsBot) {
1566
+ ctx.logger.debug(`群聊戳一戳: ${notice.userId} → ${notice.targetId}(非 bot,忽略)`);
1567
+ return;
1568
+ }
1569
+ (async () => {
1570
+ const nick = await resolveNickname(state, notice.userId, notice.groupId);
1571
+ const who = nick ? `${nick}(${notice.userId})` : notice.userId;
1572
+ const content = `[戳一戳: ${who} 戳了你]`;
1573
+ const sessionId = makeSessionId(selfId, 'group', notice.userId, notice.groupId);
1574
+ ctx.emit('inbound:message', {
1575
+ content,
1576
+ sessionId,
1577
+ platform: 'onebot',
1578
+ userId: notice.userId,
1579
+ nickname: nick,
1580
+ sessionType: 'group',
1581
+ groupId: notice.groupId,
1582
+ noticeType: 'poke',
1583
+ });
1584
+ })().catch(err => ctx.logger.warn(`poke 处理异常: ${err}`));
1585
+ }
1586
+ else if (notice.userId) {
1587
+ // 私聊 poke:始终回复
1588
+ (async () => {
1589
+ const nick = await resolveNickname(state, notice.userId);
1590
+ const who = nick ? `${nick}(${notice.userId})` : notice.userId;
1591
+ const content = `[戳一戳: ${who} 戳了你]`;
1592
+ const sessionId = makeSessionId(selfId, 'private', notice.userId);
1593
+ ctx.emit('inbound:message', {
1594
+ content,
1595
+ sessionId,
1596
+ platform: 'onebot',
1597
+ userId: notice.userId,
1598
+ nickname: nick,
1599
+ sessionType: 'private',
1600
+ noticeType: 'poke',
1601
+ });
1602
+ })().catch(err => ctx.logger.warn(`poke 处理异常: ${err}`));
1603
+ }
1604
+ return;
1605
+ }
1606
+ // 群禁言(v11: group_ban / v12: group_member_ban|group_member_unban)
1607
+ // 当被禁言的是 bot 自己时,将该群的流控会话静默掉,避免无意义的回复尝试
1608
+ if (notice.noticeType === 'group_ban' ||
1609
+ notice.noticeType === 'group_member_ban' ||
1610
+ notice.noticeType === 'group_member_unban') {
1611
+ if (notice.groupId) {
1612
+ const isLift = notice.noticeType === 'group_member_unban' || notice.subType === 'lift_ban';
1613
+ const isSelf = notice.userId != null && notice.userId === notice.selfId;
1614
+ const sessionId = makeSessionId(notice.selfId, 'group', undefined, notice.groupId);
1615
+ const duration = Number(notice.data?.duration ?? 0);
1616
+ const operatorId = notice.data?.operatorId;
1617
+ // 自己被禁言/解禁:通过 flow-control.setMuted 桥接(无 flow-control 时仅维护本地 selfMuted)
1618
+ if (isSelf) {
1619
+ if (isLift) {
1620
+ setSelfMute(sessionId, 0);
1621
+ ctx.logger.info(`[禁言解除] session=${sessionId} 操作者=${operatorId ?? 'unknown'}`);
1622
+ }
1623
+ else {
1624
+ // 时长未知时按 60s 兜底(旧 flowCfg.muteTimeSeconds 默认值)
1625
+ const dur = duration > 0 ? duration : 60;
1626
+ setSelfMute(sessionId, dur);
1627
+ ctx.logger.info(`[被禁言] session=${sessionId} 时长=${dur}s 操作者=${operatorId ?? 'unknown'},` +
1628
+ `已通知 flow-control 暂停该群触发`);
1629
+ }
1630
+ }
1631
+ // notice 入档(自己 / 他人都记录,便于 agent 感知群内动态)
1632
+ (async () => {
1633
+ const opNick = await resolveNickname(state, operatorId, notice.groupId);
1634
+ const targetNick = isSelf ? '我' : await resolveNickname(state, notice.userId, notice.groupId);
1635
+ const opLabel = opNick ? `${opNick}(${operatorId})` : (operatorId ?? '管理员');
1636
+ const targetLabel = isSelf
1637
+ ? '我'
1638
+ : targetNick
1639
+ ? `${targetNick}(${notice.userId})`
1640
+ : (notice.userId ?? '某人');
1641
+ const verb = isLift ? '解除了禁言' : `禁言了 ${duration > 0 ? `${duration} 秒` : '若干时间'}`;
1642
+ const untilTs = isLift ? 0 : duration > 0 ? Date.now() + duration * 1000 : 0;
1643
+ const untilLabel = untilTs > 0 ? `(解禁于 ${new Date(untilTs).toISOString()})` : '';
1644
+ const content = `[notice/group_ban${isLift ? '/lift' : ''}] ${opLabel} 把 ${targetLabel} ${verb}${untilLabel}`;
1645
+ await archivePlatformNotice({
1646
+ sessionId,
1647
+ noticeType: notice.noticeType,
1648
+ subType: notice.subType,
1649
+ content,
1650
+ userId: notice.userId,
1651
+ targetId: notice.userId,
1652
+ groupId: notice.groupId,
1653
+ operatorId,
1654
+ data: { duration, isSelf, isLift, untilTs },
1655
+ });
1656
+ })().catch(err => ctx.logger.warn(`group_ban 入档异常: ${err}`));
1657
+ }
1658
+ return;
1659
+ }
1660
+ // 消息撤回
1661
+ // v11: group_recall / friend_recall
1662
+ // v12: group_message_delete (sub_type: recall|delete)
1663
+ if (notice.noticeType === 'group_recall' ||
1664
+ notice.noticeType === 'friend_recall' ||
1665
+ notice.noticeType === 'group_message_delete') {
1666
+ const isGroup = notice.noticeType !== 'friend_recall';
1667
+ const sessionId = isGroup
1668
+ ? makeSessionId(notice.selfId, 'group', undefined, notice.groupId)
1669
+ : makeSessionId(notice.selfId, 'private', notice.userId);
1670
+ const operatorId = notice.data?.operatorId;
1671
+ const messageId = notice.data?.messageId;
1672
+ (async () => {
1673
+ const userNick = await resolveNickname(state, notice.userId, notice.groupId);
1674
+ const opNick = isGroup ? await resolveNickname(state, operatorId, notice.groupId) : undefined;
1675
+ const userLabel = userNick ? `${userNick}(${notice.userId})` : (notice.userId ?? '某人');
1676
+ const opLabel = opNick ? `${opNick}(${operatorId})` : operatorId;
1677
+ const content = isGroup
1678
+ ? `[notice/group_recall] ${opLabel && opLabel !== userLabel ? `${opLabel} 撤回了 ${userLabel} 的消息` : `${userLabel} 撤回了一条消息`}${messageId ? `(msg=${messageId})` : ''}`
1679
+ : `[notice/friend_recall] ${userLabel} 撤回了一条私聊消息${messageId ? `(msg=${messageId})` : ''}`;
1680
+ await archivePlatformNotice({
1681
+ sessionId,
1682
+ noticeType: notice.noticeType,
1683
+ content,
1684
+ userId: notice.userId,
1685
+ groupId: notice.groupId,
1686
+ operatorId,
1687
+ data: { messageId },
1688
+ });
1689
+ })().catch(err => ctx.logger.warn(`recall 入档异常: ${err}`));
1690
+ return;
1691
+ }
1692
+ // 群成员增减
1693
+ if (notice.noticeType === 'group_increase' ||
1694
+ notice.noticeType === 'group_decrease' ||
1695
+ notice.noticeType === 'group_member_increase' ||
1696
+ notice.noticeType === 'group_member_decrease') {
1697
+ if (!notice.groupId)
1698
+ return;
1699
+ const isJoin = notice.noticeType === 'group_increase' || notice.noticeType === 'group_member_increase';
1700
+ const sessionId = makeSessionId(notice.selfId, 'group', undefined, notice.groupId);
1701
+ const operatorId = notice.data?.operatorId;
1702
+ const isSelf = notice.userId != null && notice.userId === notice.selfId;
1703
+ (async () => {
1704
+ const userNick = await resolveNickname(state, notice.userId, notice.groupId);
1705
+ const opNick = await resolveNickname(state, operatorId, notice.groupId);
1706
+ const userLabel = isSelf ? '我' : userNick ? `${userNick}(${notice.userId})` : (notice.userId ?? '某人');
1707
+ const opLabel = opNick ? `${opNick}(${operatorId})` : operatorId;
1708
+ let action;
1709
+ if (isJoin) {
1710
+ action = notice.subType === 'invite' && opLabel ? `被 ${opLabel} 邀请加入了群` : '加入了群';
1711
+ }
1712
+ else {
1713
+ if (notice.subType === 'kick' && opLabel)
1714
+ action = `被 ${opLabel} 移出群聊`;
1715
+ else if (notice.subType === 'kick_me')
1716
+ action = `被 ${opLabel ?? '管理员'} 移出群聊`;
1717
+ else
1718
+ action = '退出了群聊';
1719
+ }
1720
+ const content = `[notice/${notice.noticeType}${notice.subType ? `/${notice.subType}` : ''}] ${userLabel} ${action}`;
1721
+ await archivePlatformNotice({
1722
+ sessionId,
1723
+ noticeType: notice.noticeType,
1724
+ subType: notice.subType,
1725
+ content,
1726
+ userId: notice.userId,
1727
+ groupId: notice.groupId,
1728
+ operatorId,
1729
+ data: { isSelf },
1730
+ });
1731
+ })().catch(err => ctx.logger.warn(`group_member 变动 入档异常: ${err}`));
1732
+ return;
1733
+ }
1734
+ // 群管理员变动
1735
+ if (notice.noticeType === 'group_admin' || notice.noticeType === 'group_member_admin') {
1736
+ if (!notice.groupId)
1737
+ return;
1738
+ const sessionId = makeSessionId(notice.selfId, 'group', undefined, notice.groupId);
1739
+ const isSelf = notice.userId != null && notice.userId === notice.selfId;
1740
+ const isSet = notice.subType === 'set' || notice.subType === 'unban' /* spurious */;
1741
+ (async () => {
1742
+ const userNick = await resolveNickname(state, notice.userId, notice.groupId);
1743
+ const userLabel = isSelf ? '我' : userNick ? `${userNick}(${notice.userId})` : (notice.userId ?? '某人');
1744
+ const action = notice.subType === 'set' ? '被设置为管理员' : notice.subType === 'unset' ? '被取消管理员' : '管理员状态变化';
1745
+ const content = `[notice/group_admin/${notice.subType ?? 'change'}] ${userLabel} ${action}`;
1746
+ await archivePlatformNotice({
1747
+ sessionId,
1748
+ noticeType: notice.noticeType,
1749
+ subType: notice.subType,
1750
+ content,
1751
+ userId: notice.userId,
1752
+ groupId: notice.groupId,
1753
+ data: { isSelf, isSet },
1754
+ });
1755
+ })().catch(err => ctx.logger.warn(`group_admin 入档异常: ${err}`));
1756
+ return;
1757
+ }
1758
+ // 好友添加
1759
+ if (notice.noticeType === 'friend_add' || notice.noticeType === 'friend_increase') {
1760
+ if (!notice.userId)
1761
+ return;
1762
+ const sessionId = makeSessionId(notice.selfId, 'private', notice.userId);
1763
+ (async () => {
1764
+ const userNick = await resolveNickname(state, notice.userId);
1765
+ const userLabel = userNick ? `${userNick}(${notice.userId})` : notice.userId;
1766
+ const content = `[notice/${notice.noticeType}] ${userLabel} 成为了我的好友`;
1767
+ await archivePlatformNotice({
1768
+ sessionId,
1769
+ noticeType: notice.noticeType,
1770
+ content,
1771
+ userId: notice.userId,
1772
+ });
1773
+ })().catch(err => ctx.logger.warn(`friend_add 入档异常: ${err}`));
1774
+ return;
1775
+ }
1776
+ // 群文件上传 → 转化为 inbound:message
1777
+ if (notice.noticeType === 'group_upload' && notice.groupId) {
1778
+ const selfId = notice.selfId;
1779
+ const sessionId = makeSessionId(selfId, 'group', notice.userId, notice.groupId);
1780
+ const fileName = notice.data?.fileName ?? '未知文件';
1781
+ const content = `[文件上传: ${notice.userId} 上传了 ${fileName}]`;
1782
+ ctx.emit('inbound:message', {
1783
+ content,
1784
+ sessionId,
1785
+ platform: 'onebot',
1786
+ userId: notice.userId,
1787
+ sessionType: 'group',
1788
+ groupId: notice.groupId,
1789
+ noticeType: 'group_upload',
1790
+ });
1791
+ return;
1792
+ }
1793
+ }
1794
+ function handleMetaEvent(state, raw) {
1795
+ if (!state.protocol)
1796
+ return;
1797
+ const meta = state.protocol.parseMetaEvent(raw);
1798
+ if (meta.subType === 'connect' || meta.subType === 'lifecycle') {
1799
+ ctx.logger.debug(`OneBot[${state.protocol.version}] meta 事件: ${meta.subType}`);
1800
+ if (meta.selfId && !state.selfId) {
1801
+ state.selfId = meta.selfId;
1802
+ ctx.logger.info(`OneBot self_id (via meta): ${state.selfId}`);
1803
+ }
1804
+ if (meta.version) {
1805
+ ctx.logger.info(`OneBot 实现: ${meta.version.impl ?? 'unknown'} v${meta.version.version ?? '?'} (onebot ${meta.version.onebot_version ?? '?'})`);
1806
+ }
1807
+ }
1808
+ else if (meta.subType === 'heartbeat') {
1809
+ // 心跳事件不输出日志
1810
+ }
1811
+ else if (meta.subType === 'status_update') {
1812
+ ctx.logger.debug(`OneBot[${state.protocol.version}] 状态更新事件`);
1813
+ }
1814
+ else {
1815
+ ctx.logger.debug(`OneBot[${state.protocol.version}] 跳过未识别 meta 事件: sub_type=${meta.subType ?? '?'}`);
1816
+ }
1817
+ }
1818
+ // ----- 群聊时间感知提示 + 特殊事件触发上下文 -----
1819
+ // 群聊中多人消息平铺在历史中,注入提示帮助模型关注时间线
1820
+ // 特殊事件(如戳一戳、文件上传)触发时注入说明,让模型知道触发原因
1821
+ const noticePatterns = [
1822
+ {
1823
+ pattern: /^\[戳一戳:/,
1824
+ hint: '这条消息不是用户手动输入的文字,而是一个「戳一戳」互动事件——有人戳了你。请根据戳一戳的情境做出自然、俏皮的反应,而不是直接回复消息内容。',
1825
+ },
1826
+ { pattern: /^\[文件上传:/, hint: '这条消息不是用户手动输入的文字,而是一个文件上传通知事件。' },
1827
+ ];
1828
+ ctx.middleware('agent:llm:before', async (data, next) => {
1829
+ if (data.sessionId?.includes(':group:')) {
1830
+ // 在最后一条用户消息前插入时间感知提示
1831
+ let lastUserIdx = -1;
1832
+ for (let i = data.messages.length - 1; i >= 0; i--) {
1833
+ if (data.messages[i].role === 'user') {
1834
+ lastUserIdx = i;
1835
+ break;
1836
+ }
1837
+ }
1838
+ if (lastUserIdx > 0) {
1839
+ data.messages.splice(lastUserIdx, 0, {
1840
+ role: 'system',
1841
+ content: '注意:以上是群聊的历史消息记录,包含多位群友的发言。' +
1842
+ '请留意消息的时间先后顺序,优先关注近期的对话内容和上下文。',
1843
+ metadata: { injector: 'platform' },
1844
+ });
1845
+ }
1846
+ }
1847
+ // 特殊事件触发上下文:检查最后一条用户消息是否为非文本事件
1848
+ if (data.sessionId?.startsWith('onebot:')) {
1849
+ const lastMsg = data.messages[data.messages.length - 1];
1850
+ if (lastMsg?.role === 'user' && typeof lastMsg.content === 'string') {
1851
+ // 去掉发送者前缀后匹配事件模式
1852
+ const bare = lastMsg.content.replace(/^\[[^\]]*\]:\s*/, '');
1853
+ for (const { pattern, hint } of noticePatterns) {
1854
+ if (pattern.test(bare)) {
1855
+ // 在用户消息前插入事件说明
1856
+ data.messages.splice(data.messages.length - 1, 0, {
1857
+ role: 'system',
1858
+ content: hint,
1859
+ metadata: { injector: 'platform' },
1860
+ });
1861
+ break;
1862
+ }
1863
+ }
1864
+ }
1865
+ }
1866
+ await next();
1867
+ });
1868
+ // ----- 监听消息回复事件 -----
1869
+ ctx.on('outbound:message', async (msg) => {
1870
+ if (!msg.sessionId.startsWith('onebot:'))
1871
+ return;
1872
+ // 把结构化 attachments 渲染为 <image url="base64://..."/> 标记,
1873
+ // 远程 URL / 本地文件统一编码为 base64 通过 WS 隧道发送,避免 daemon
1874
+ // 与 Aalis 不在同一文件系统时(典型:Docker 部署)发生 ENOENT
1875
+ let content = msg.content ?? '';
1876
+ if (msg.attachments?.length) {
1877
+ // 出站附件也统一落盘 data/{kind}s/{session}/ —— 让 agent 自己发出去的
1878
+ // 图/音/视频能进入后续历史回放与归档检索,行为与入站对称。
1879
+ // 落盘失败(超大 / 无法解码)不阻塞发送,保留原 data 继续走 renderer。
1880
+ await Promise.all(msg.attachments.map(async (att) => {
1881
+ try {
1882
+ const local = await cacheOneAttachment(storage, proc, att.kind, att.data, msg.sessionId, attachmentMaxBytes, ctx.logger);
1883
+ if (local) {
1884
+ // cacheAttachmentBuffer 返回相对路径 "data/images/...",
1885
+ // 转为 storage URI "data:/images/...",让 renderAttachmentsAsContentMarkers
1886
+ // 走 storage.readFile 读回 buffer,而非兜底成无法访问的相对 file://
1887
+ att.data = local.replace(/^([^/]+)\//, '$1:/');
1888
+ if (att.kind === 'audio')
1889
+ att.mimeType = 'audio/wav';
1890
+ }
1891
+ }
1892
+ catch (err) {
1893
+ ctx.logger.debug(`OneBot 出站附件缓存异常 [${att.kind}]: ${err}`);
1894
+ }
1895
+ }));
1896
+ try {
1897
+ const markers = await renderAttachmentsAsContentMarkers(msg.attachments, storage, ctx.logger);
1898
+ if (markers)
1899
+ content = content ? `${content}\n${markers}` : markers;
1900
+ }
1901
+ catch (err) {
1902
+ ctx.logger.warn(`OneBot 渲染附件失败: ${err}`);
1903
+ }
1904
+ }
1905
+ if (!content.trim()) {
1906
+ ctx.logger.debug(`OneBot 跳过空消息 [${msg.sessionId}]`);
1907
+ return;
1908
+ }
1909
+ ctx.logger.debug(`OneBot 发送消息 [${msg.sessionId}]: ${content}`);
1910
+ // 冷却 / 退避 / idle 调度由 plugin-flow-control 自行处理(监听 outbound:message)
1911
+ adapter.sendMessage(msg.sessionId, content, { skipSplit: msg.source !== 'agent' }).catch(err => {
1912
+ ctx.logger.warn(`OneBot 发送消息失败: ${err}`);
1913
+ });
1914
+ });
1915
+ // ----- 生命周期 -----
1916
+ ctx.on('ready', () => {
1917
+ for (const connConfig of connections) {
1918
+ if (!connConfig.url) {
1919
+ ctx.logger.warn('OneBot 连接配置缺少 url,跳过');
1920
+ continue;
1921
+ }
1922
+ connectOne(connConfig);
1923
+ }
1924
+ });
1925
+ ctx.onDispose(() => {
1926
+ selfMuted.clear();
1927
+ muteRecoveryChecked.clear();
1928
+ for (const state of states) {
1929
+ if (state.reconnectTimer)
1930
+ clearTimeout(state.reconnectTimer);
1931
+ stopHeartbeat(state);
1932
+ if (state.ws) {
1933
+ state.ws.removeAllListeners();
1934
+ if (state.ws.readyState === 0 /* CONNECTING */) {
1935
+ state.ws.terminate();
1936
+ }
1937
+ else {
1938
+ state.ws.close();
1939
+ }
1940
+ }
1941
+ for (const [, pending] of state.pendingActions) {
1942
+ clearTimeout(pending.timer);
1943
+ }
1944
+ }
1945
+ states.length = 0;
1946
+ });
1947
+ }
1948
+ //# sourceMappingURL=index.js.map