qq-bot-roost 1.0.2 → 1.0.4
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/README.md +2 -2
- package/package.json +57 -57
- package/src/bot.js +10 -1
- package/src/config.js +741 -730
- package/src/ledger.js +905 -554
- package/src/message.js +998 -900
- package/src/outbound.js +791 -690
- package/src/plugin/api-record.js +129 -0
- package/src/plugin/context.js +990 -976
- package/src/storage/events-schema.js +124 -99
- package/src/web/server.js +3494 -3280
- package/web/app.js +4372 -4053
- package/web/index.html +1203 -1116
- package/web/style.css +44 -0
package/src/message.js
CHANGED
|
@@ -1,900 +1,998 @@
|
|
|
1
|
-
import fs from 'node:fs/promises';
|
|
2
|
-
import { createLogger } from './logger.js';
|
|
3
|
-
import { FILE_TYPE, MEDIA_SOFT_LIMIT, MEDIA_HARD_LIMIT, DIRECT_UPLOAD_LIMIT } from './api.js';
|
|
4
|
-
import { messageKindOf, EVENTS } from './events.js';
|
|
5
|
-
import { renderMenuMd, mdToPlain } from './markdown.js';
|
|
6
|
-
import { newTraceId } from './trace.js';
|
|
7
|
-
|
|
8
|
-
const log = createLogger('message');
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* 事件 → 会话类型 的映射来自 src/events.js 的登记表,
|
|
12
|
-
* 那里维护了官方全部 42 个事件,只有标了 message 的才会规范化成 Message。
|
|
13
|
-
*/
|
|
14
|
-
export function isMessageEvent(name) {
|
|
15
|
-
return Boolean(messageKindOf(name));
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* @ 的格式:<@2409AB55F21D2ACFED13223632472280>
|
|
20
|
-
* ID 可能是十进制,也可能是十六进制大写串,所以不能只匹配 \d+。
|
|
21
|
-
*/
|
|
22
|
-
const MENTION_RE = /<@!?([0-9A-Za-z]+)>/g;
|
|
23
|
-
const MENTION_TEST = /<@!?[0-9A-Za-z]+>/;
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* 去掉正文里的 @xxx,保留其余内容。
|
|
27
|
-
*
|
|
28
|
-
* 两种形态都要处理:
|
|
29
|
-
* 1. 官方文档说的 `<@openid>` / `<@!openid>` —— 正则剥掉
|
|
30
|
-
* 2. **实测中遇到的 `@昵称 ` 纯文本前缀** —— 有些客户端/协议端把 @ 发成
|
|
31
|
-
* 一段普通文字(内容里就是 `@天道酬勤 /kun 菜单`),不剥的话
|
|
32
|
-
* 这条消息永远不以命令前缀开头,`/kun 签到` 根本进不了命令分发。
|
|
33
|
-
*
|
|
34
|
-
* 第 2 种只剥**开头**那一个,且要求后面跟着空白——避免把正文里的
|
|
35
|
-
* `@某人` 误伤(`你好 @张三` 不动)。
|
|
36
|
-
*/
|
|
37
|
-
function stripMention(content) {
|
|
38
|
-
return String(content ?? '')
|
|
39
|
-
.replace(MENTION_RE, ' ')
|
|
40
|
-
.replace(/^@[^\s@]+\s+/, '')
|
|
41
|
-
.replace(/\s+/g, ' ')
|
|
42
|
-
.trim();
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* 从正文里捞 @ 的 openid。
|
|
47
|
-
*
|
|
48
|
-
* 官方文档说:消息里 @ 某成员时,content 里会写成 `<@member_openid>`。
|
|
49
|
-
* 也就是说**「@ 了谁」这个信息本来就在正文里**——只是 `stripMention()`
|
|
50
|
-
* 为了让命令解析能正常跑(`<@xxx> /ping` 开头不是 `/`,会被判成非命令),
|
|
51
|
-
* 把它连同其余 @ 一起删掉了。
|
|
52
|
-
*
|
|
53
|
-
* 所以这里在删除**之前**先捞一份出来,和 `d.mentions` 合并。
|
|
54
|
-
* 这样即使官方某个事件类型不返回 mentions 字段,插件照样能知道 @ 了谁。
|
|
55
|
-
*
|
|
56
|
-
* `<@all>` 是 @全体,不是具体的人,跳过。
|
|
57
|
-
*/
|
|
58
|
-
function mentionsFromContent(content) {
|
|
59
|
-
const out = [];
|
|
60
|
-
for (const m of String(content ?? '').matchAll(MENTION_RE)) {
|
|
61
|
-
const id = m[1];
|
|
62
|
-
if (!id || id.toLowerCase() === 'all') continue;
|
|
63
|
-
if (!out.includes(id)) out.push(id);
|
|
64
|
-
}
|
|
65
|
-
return out;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* 正文里是否 @ 了机器人。
|
|
70
|
-
* 优先用官方给的 mentions 数组(带 is_you 标记,最准),
|
|
71
|
-
* 没有该字段时退回正则匹配。
|
|
72
|
-
*/
|
|
73
|
-
function detectMention(d) {
|
|
74
|
-
if (Array.isArray(d?.mentions) && d.mentions.length) {
|
|
75
|
-
return d.mentions.some((m) => m && (m.is_you || m.bot));
|
|
76
|
-
}
|
|
77
|
-
return MENTION_TEST.test(String(d?.content ?? ''));
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* 附件归一化。
|
|
82
|
-
*
|
|
83
|
-
* 官方事件里的 `attachments[]` 是图片 / 语音 / 视频 / 文件**唯一**的载体——
|
|
84
|
-
* `content` 里只有文字。以前完全没解析,等于图片和语音全丢。
|
|
85
|
-
*
|
|
86
|
-
* 语音特别值钱,官方直接给了两样东西,不用自己造:
|
|
87
|
-
* - `asr_refer_text` 语音的 ASR 参考结果(不用自己跑识别)
|
|
88
|
-
* - `voice_wav_url` SILK 转好的 WAV 地址(不用自己转码)
|
|
89
|
-
*
|
|
90
|
-
* @param {any[]} list 事件里的 attachments 数组
|
|
91
|
-
* @returns {Array<object>}
|
|
92
|
-
*/
|
|
93
|
-
export function parseAttachments(list) {
|
|
94
|
-
const out = [];
|
|
95
|
-
for (const a of Array.isArray(list) ? list : []) {
|
|
96
|
-
if (!a || typeof a !== 'object') continue;
|
|
97
|
-
const ct = String(a.content_type ?? '');
|
|
98
|
-
const type = ct === 'voice'
|
|
99
|
-
? 'voice'
|
|
100
|
-
: ct.startsWith('image/')
|
|
101
|
-
? 'image'
|
|
102
|
-
: ct.startsWith('video/')
|
|
103
|
-
? 'video'
|
|
104
|
-
: ct === 'file'
|
|
105
|
-
? 'file'
|
|
106
|
-
: 'other';
|
|
107
|
-
out.push({
|
|
108
|
-
type,
|
|
109
|
-
url: String(a.url ?? ''),
|
|
110
|
-
filename: String(a.filename ?? ''),
|
|
111
|
-
size: Number(a.size) || 0,
|
|
112
|
-
width: Number(a.width) || 0,
|
|
113
|
-
height: Number(a.height) || 0,
|
|
114
|
-
contentType: ct,
|
|
115
|
-
/** 语音专用:SILK 转好的 WAV 地址,可直接喂给 ASR */
|
|
116
|
-
wavUrl: String(a.voice_wav_url ?? ''),
|
|
117
|
-
/** 语音专用:官方给的 ASR 参考结果 */
|
|
118
|
-
asrText: String(a.asr_refer_text ?? ''),
|
|
119
|
-
raw: a,
|
|
120
|
-
});
|
|
121
|
-
}
|
|
122
|
-
return out;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/**
|
|
126
|
-
* `message_scene` 归一化。
|
|
127
|
-
*
|
|
128
|
-
* `ext` 是 `["msg_idx=REFIDX_x", "ref_msg_idx=TMP_x", "auth_token=x"]` 这种
|
|
129
|
-
* key=value 字符串数组。其中:
|
|
130
|
-
* - `msg_idx` **本条消息**的引用索引 → 回复时塞进 `message_reference.message_id`
|
|
131
|
-
* - `ref_msg_idx` 本条消息**引用了谁** → 想顺着引用链往上找时用
|
|
132
|
-
*
|
|
133
|
-
* 以前这两个值全丢了,所以「引用回复」根本做不了。
|
|
134
|
-
*/
|
|
135
|
-
export function parseMessageScene(d) {
|
|
136
|
-
const ext = d?.message_scene?.ext;
|
|
137
|
-
const map = {};
|
|
138
|
-
for (const item of Array.isArray(ext) ? ext : []) {
|
|
139
|
-
const s = String(item ?? '');
|
|
140
|
-
const i = s.indexOf('=');
|
|
141
|
-
if (i > 0) map[s.slice(0, i)] = s.slice(i + 1);
|
|
142
|
-
}
|
|
143
|
-
return {
|
|
144
|
-
source: String(d?.message_scene?.source ?? ''),
|
|
145
|
-
msgIdx: map.msg_idx ?? '',
|
|
146
|
-
refMsgIdx: map.ref_msg_idx ?? '',
|
|
147
|
-
authToken: map.auth_token ?? '',
|
|
148
|
-
};
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/** ARK 卡片(B 站分享、小程序、位置卡…)结构化 */
|
|
152
|
-
function parseArk(a) {
|
|
153
|
-
if (!a || typeof a !== 'object') return null;
|
|
154
|
-
const f = a.fields && typeof a.fields === 'object' ? a.fields : {};
|
|
155
|
-
return {
|
|
156
|
-
type: String(a.ark_type ?? ''),
|
|
157
|
-
name: String(a.ark_name ?? ''),
|
|
158
|
-
prompt: String(a.prompt ?? ''),
|
|
159
|
-
title: String(f.title ?? ''),
|
|
160
|
-
desc: String(f.desc ?? ''),
|
|
161
|
-
url: String(f.jump_url ?? ''),
|
|
162
|
-
fields: f,
|
|
163
|
-
raw: a,
|
|
164
|
-
};
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
/**
|
|
168
|
-
* `msg_elements` 归一化(递归)。
|
|
169
|
-
*
|
|
170
|
-
* message_type:0 普通文本 / 3 结构化卡片 / 101 并行消息 /
|
|
171
|
-
* **102 聊天记录(合并转发)** / **103 引用消息**
|
|
172
|
-
*
|
|
173
|
-
* 102 就是「合并转发」——以前我们说官方没有合并转发,其实是官方推了、
|
|
174
|
-
* 我们没解析。103 是引用,`content` 里是那段被引用的原文。
|
|
175
|
-
*/
|
|
176
|
-
export function parseElements(list, depth = 0) {
|
|
177
|
-
const out = [];
|
|
178
|
-
if (depth > 3) return out; // 官方嵌套不会很深,防畸形数据把栈打穿
|
|
179
|
-
for (const el of Array.isArray(list) ? list : []) {
|
|
180
|
-
if (!el || typeof el !== 'object') continue;
|
|
181
|
-
const mt = Number(el.message_type);
|
|
182
|
-
const type =
|
|
183
|
-
mt === 0 ? 'text'
|
|
184
|
-
: mt === 3 ? 'ark'
|
|
185
|
-
: mt === 101 ? 'parallel'
|
|
186
|
-
: mt === 102 ? 'chatRecord'
|
|
187
|
-
: mt === 103 ? 'quote'
|
|
188
|
-
: 'other';
|
|
189
|
-
const author = el.author && typeof el.author === 'object' ? el.author : null;
|
|
190
|
-
out.push({
|
|
191
|
-
type,
|
|
192
|
-
messageType: mt,
|
|
193
|
-
msgIdx: String(el.msg_idx ?? ''),
|
|
194
|
-
author: author
|
|
195
|
-
? {
|
|
196
|
-
id: String(author.member_openid ?? author.user_openid ?? author.id ?? ''),
|
|
197
|
-
name: String(author.username ?? ''),
|
|
198
|
-
isBot: Boolean(author.bot),
|
|
199
|
-
}
|
|
200
|
-
: null,
|
|
201
|
-
content: String(el.content ?? ''),
|
|
202
|
-
attachments: parseAttachments(el.attachments),
|
|
203
|
-
ark: parseArk(el.ark_data),
|
|
204
|
-
children: parseElements(el.msg_elements, depth + 1),
|
|
205
|
-
raw: el,
|
|
206
|
-
});
|
|
207
|
-
}
|
|
208
|
-
return out;
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
/**
|
|
212
|
-
* 把官方事件体统一成好用的 Message 对象,插件只跟它打交道。
|
|
213
|
-
*
|
|
214
|
-
* @param {object} [p.identity] 身份对照表(`IdentityStore`)。没传就是没启用绑定,
|
|
215
|
-
* 所有 `uin` / `*Aliases` 字段一律空,老行为不变。
|
|
216
|
-
*/
|
|
217
|
-
export function buildMessage(eventName, d, { api, cfg, outbound, session, traceId, identity }) {
|
|
218
|
-
const kind = messageKindOf(eventName) ?? 'unknown';
|
|
219
|
-
|
|
220
|
-
let groupId = '';
|
|
221
|
-
let userId = '';
|
|
222
|
-
const userName = d.author?.username ?? '';
|
|
223
|
-
if (kind === 'group') {
|
|
224
|
-
groupId = d.group_openid ?? '';
|
|
225
|
-
userId = d.author?.member_openid ?? d.author?.id ?? '';
|
|
226
|
-
} else if (kind === 'c2c') {
|
|
227
|
-
userId = d.author?.user_openid ?? d.author?.id ?? '';
|
|
228
|
-
} else if (kind === 'guild') {
|
|
229
|
-
groupId = d.channel_id ?? '';
|
|
230
|
-
userId = d.author?.id ?? '';
|
|
231
|
-
} else if (kind === 'dm') {
|
|
232
|
-
groupId = d.guild_id ?? '';
|
|
233
|
-
userId = d.author?.id ?? '';
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
const content = stripMention(d.content);
|
|
237
|
-
const prefix = cfg.commandPrefix || '/';
|
|
238
|
-
const isCommand = content.startsWith(prefix);
|
|
239
|
-
const mentioned = detectMention(d);
|
|
240
|
-
const chatId = groupId || userId;
|
|
241
|
-
const receivedAt = Date.now();
|
|
242
|
-
|
|
243
|
-
/**
|
|
244
|
-
* 身份对照:openid 换机器人就变,QQ号 / 群号不变。
|
|
245
|
-
*
|
|
246
|
-
* 这里只**查表**,不做任何自动匹配——认人只能靠管理员在面板「身份」页绑一次。
|
|
247
|
-
* 未绑定一律给空串、不抛错:内核每条消息都要过这里,抛错会把消息吞掉。
|
|
248
|
-
* 插件要「没绑就提示管理员」,用 `ctx.identity.requireOpenid()`,那条路才抛。
|
|
249
|
-
*
|
|
250
|
-
* `userAliases` / `groupAliases` 是读旧记忆的备用键:号码本身 + 这个号码名下
|
|
251
|
-
* 所有 openid(含别的机器人的)。**人和群必须分成两组**——合成一个数组,
|
|
252
|
-
* 群会话就会读到私聊记忆,这种串场很难在事后发现。
|
|
253
|
-
*
|
|
254
|
-
* 只在群聊 / 单聊算:频道(guild/dm)的 `channel_id`、`guild_id` 不是 openid,
|
|
255
|
-
* 拿它去对照 QQ号没有意义。
|
|
256
|
-
*/
|
|
257
|
-
const idOn = Boolean(identity) && (kind === 'group' || kind === 'c2c');
|
|
258
|
-
const idUser = idOn ? identity.resolveUser(cfg?.id, userId) : null;
|
|
259
|
-
const idGroup = idOn && kind === 'group' ? identity.resolveGroup(cfg?.id, groupId) : null;
|
|
260
|
-
// 记一次「见过」(内部有 60 秒节流,不会每条消息都写盘)
|
|
261
|
-
if (idUser) identity.touch({ kind: 'user', uin: idUser.uin, botId: cfg?.id, scope: `${kind}:${chatId}` });
|
|
262
|
-
if (idGroup) identity.touch({ kind: 'group', uin: idGroup.uin, botId: cfg?.id, scope: `group:${chatId}` });
|
|
263
|
-
|
|
264
|
-
// 没 @ 也不算命令,但可能处在免@窗口内 —— 此时同样算「在跟机器人说话」
|
|
265
|
-
const sticky = Boolean(session?.isSticky(chatId));
|
|
266
|
-
const addressed = mentioned || isCommand || sticky;
|
|
267
|
-
|
|
268
|
-
/**
|
|
269
|
-
* 官方事件里以前被整个丢掉的载荷。
|
|
270
|
-
* 不解析的话,图片 / 语音 / 卡片 / 引用 / 合并转发在插件眼里只剩一段文字。
|
|
271
|
-
*/
|
|
272
|
-
const attachments = parseAttachments(d?.attachments);
|
|
273
|
-
const scene = parseMessageScene(d);
|
|
274
|
-
const ark = parseArk(d?.ark_data);
|
|
275
|
-
const elements = parseElements(d?.msg_elements);
|
|
276
|
-
|
|
277
|
-
const msg = {
|
|
278
|
-
id: d.id ?? '',
|
|
279
|
-
/**
|
|
280
|
-
* 链路 id:这一条消息从进站到回话的唯一标识。
|
|
281
|
-
*
|
|
282
|
-
* 生成点必须在内核——它先看到原始事件;插件只是路过,
|
|
283
|
-
* 让插件生成会出现「同一条消息被两个插件各生成一个 id」。
|
|
284
|
-
*/
|
|
285
|
-
traceId: String(traceId || newTraceId()),
|
|
286
|
-
/**
|
|
287
|
-
* 内核拿到这条事件的时间。四段耗时的第一段靠它算:
|
|
288
|
-
* 官方事件到达 → 插件真正拿到,中间是内核的分发排队。
|
|
289
|
-
*/
|
|
290
|
-
receivedAt,
|
|
291
|
-
kind,
|
|
292
|
-
event: eventName,
|
|
293
|
-
// 事件元信息(属于哪个 intent、中文说明),来自 src/events.js
|
|
294
|
-
eventMeta: EVENTS[eventName] ?? null,
|
|
295
|
-
groupId,
|
|
296
|
-
userId,
|
|
297
|
-
userName,
|
|
298
|
-
chatId,
|
|
299
|
-
|
|
300
|
-
/* ---------------- 身份(QQ号 / 群号 ↔ openid) ---------------- */
|
|
301
|
-
|
|
302
|
-
/**
|
|
303
|
-
* QQ号。面板绑过才有,否则空串。
|
|
304
|
-
*
|
|
305
|
-
* **给 AI 看的时候号码优先、openid 兜底**:openid 换机器人就变,
|
|
306
|
-
* 号码不变。有号码时按号码认人,没有才退回 openid。
|
|
307
|
-
*/
|
|
308
|
-
uin: idUser?.uin ?? '',
|
|
309
|
-
/** 群号,同上 */
|
|
310
|
-
groupUin: idGroup?.uin ?? '',
|
|
311
|
-
/** 跨机器人稳定的身份 id:人 `u:<QQ号>`、群 `g:<群号>` */
|
|
312
|
-
identityId: idUser?.identityId ?? '',
|
|
313
|
-
groupIdentityId: idGroup?.identityId ?? '',
|
|
314
|
-
/** 读旧记忆的备用键:号码 + 该号码名下所有 openid(不含当前 chatId) */
|
|
315
|
-
userAliases: idUser ? identity.aliasesOf('user', idUser.uin, userId) : [],
|
|
316
|
-
groupAliases: idGroup ? identity.aliasesOf('group', idGroup.uin, groupId) : [],
|
|
317
|
-
|
|
318
|
-
content,
|
|
319
|
-
raw: d,
|
|
320
|
-
mentioned,
|
|
321
|
-
/**
|
|
322
|
-
* 本条消息 @ 了谁(不含机器人自己)。
|
|
323
|
-
*
|
|
324
|
-
* 两个来源合并:
|
|
325
|
-
* 1. `d.mentions`——字段名各事件不统一,可能是 id / member_openid / user_openid,
|
|
326
|
-
* 而且带 `is_you` / `bot` 标记,能准确排除机器人自己
|
|
327
|
-
* 2. **正文里的 `<@openid>`**——官方文档明确说 @ 成员时 content 里就是这个格式。
|
|
328
|
-
* 以前 `stripMention()` 把它删了,于是「@ 了谁」这个信息白白丢掉,
|
|
329
|
-
* 插件只能让用户手填 openid。现在删之前先捞一份。
|
|
330
|
-
*
|
|
331
|
-
* 合并时 declared 优先:它知道谁是机器人,正文里认不出来。
|
|
332
|
-
*/
|
|
333
|
-
mentions: (() => {
|
|
334
|
-
const declared = (Array.isArray(d?.mentions) ? d.mentions : [])
|
|
335
|
-
.map((m) => ({
|
|
336
|
-
id: m?.id ?? m?.member_openid ?? m?.user_openid ?? '',
|
|
337
|
-
isBot: Boolean(m?.is_you || m?.bot),
|
|
338
|
-
// 昵称带上:禁言/踢人成功时能回「已禁言 张三」,比一串 openid 好认
|
|
339
|
-
name: String(m?.username ?? '').trim(),
|
|
340
|
-
}))
|
|
341
|
-
.filter((m) => m.id);
|
|
342
|
-
const fromText = mentionsFromContent(d?.content).map((id) => ({ id, isBot: false, name: '' }));
|
|
343
|
-
|
|
344
|
-
const seen = new Set();
|
|
345
|
-
const out = [];
|
|
346
|
-
for (const m of [...declared, ...fromText]) {
|
|
347
|
-
if (seen.has(m.id)) continue;
|
|
348
|
-
seen.add(m.id);
|
|
349
|
-
if (!m.isBot) out.push(m);
|
|
350
|
-
}
|
|
351
|
-
return out;
|
|
352
|
-
})(),
|
|
353
|
-
|
|
354
|
-
/**
|
|
355
|
-
* 同上,但**不排除机器人自己**。
|
|
356
|
-
*
|
|
357
|
-
* 插件用 `mentions`(点名单选人的时候不该把机器人算进去),
|
|
358
|
-
* 日志和排查用这个——`@机器人 /禁言 1m` 里被 @ 的机器人恰好是
|
|
359
|
-
* mentions 过滤掉的那个,日志里只剩 `<@2409AB55…>`,看不出 @ 了谁。
|
|
360
|
-
*/
|
|
361
|
-
allMentions: (() => {
|
|
362
|
-
const declared = (Array.isArray(d?.mentions) ? d.mentions : [])
|
|
363
|
-
.map((m) => ({
|
|
364
|
-
id: m?.id ?? m?.member_openid ?? m?.user_openid ?? '',
|
|
365
|
-
isBot: Boolean(m?.is_you || m?.bot),
|
|
366
|
-
name: String(m?.username ?? '').trim(),
|
|
367
|
-
}))
|
|
368
|
-
.filter((m) => m.id);
|
|
369
|
-
const fromText = mentionsFromContent(d?.content).map((id) => ({ id, isBot: false, name: '' }));
|
|
370
|
-
|
|
371
|
-
const seen = new Set();
|
|
372
|
-
const out = [];
|
|
373
|
-
for (const m of [...declared, ...fromText]) {
|
|
374
|
-
if (seen.has(m.id)) continue;
|
|
375
|
-
seen.add(m.id);
|
|
376
|
-
out.push(m);
|
|
377
|
-
}
|
|
378
|
-
return out;
|
|
379
|
-
})(),
|
|
380
|
-
|
|
381
|
-
/* ---------------- 官方载荷(2026-10-26 补齐) ---------------- */
|
|
382
|
-
|
|
383
|
-
/** 附件全量:图片 / 语音 / 视频 / 文件 */
|
|
384
|
-
attachments,
|
|
385
|
-
/** 只取图片,插件最常用(喂视觉模型) */
|
|
386
|
-
images: attachments.filter((a) => a.type === 'image'),
|
|
387
|
-
/** 只取语音;`asrText` 是官方 ASR 结果,`wavUrl` 是转好的 WAV */
|
|
388
|
-
voices: attachments.filter((a) => a.type === 'voice'),
|
|
389
|
-
/** 其余附件(视频 / 文件 / 未知) */
|
|
390
|
-
files: attachments.filter((a) => a.type === 'video' || a.type === 'file' || a.type === 'other'),
|
|
391
|
-
/** { source, msgIdx, refMsgIdx, authToken } */
|
|
392
|
-
scene,
|
|
393
|
-
/** 本条消息的引用索引 → 回复时当 message_reference 用 */
|
|
394
|
-
msgIdx: scene.msgIdx,
|
|
395
|
-
/** 本条消息引用了谁 */
|
|
396
|
-
refMsgIdx: scene.refMsgIdx,
|
|
397
|
-
/** 卡片消息(B 站分享 / 小程序 / 位置卡…),没有就是 null */
|
|
398
|
-
ark,
|
|
399
|
-
/** 消息元素:102 聊天记录(合并转发)、103 引用、101 并行 */
|
|
400
|
-
elements,
|
|
401
|
-
/** 这条消息本身如果是「引用某条」,这里是被引用的原文(拿不到就是空串) */
|
|
402
|
-
quoted: elements.find((e) => e.type === 'quote')?.content ?? '',
|
|
403
|
-
|
|
404
|
-
sticky,
|
|
405
|
-
addressed,
|
|
406
|
-
isCommand,
|
|
407
|
-
receivedAt, // 被动回复窗口从这里开始算,出站调度器要用
|
|
408
|
-
replied: false, // 任何发送动作都会置为 true,供内核判断「有没有插件回应」
|
|
409
|
-
command: '',
|
|
410
|
-
args: [],
|
|
411
|
-
at: Number(d.timestamp) || receivedAt,
|
|
412
|
-
};
|
|
413
|
-
|
|
414
|
-
if (isCommand) {
|
|
415
|
-
const body = content.slice(prefix.length).trim();
|
|
416
|
-
const [cmd, ...rest] = body.split(/\s+/);
|
|
417
|
-
msg.command = (cmd || '').toLowerCase();
|
|
418
|
-
msg.args = rest;
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
// 底层发送:group 走群接口,c2c 走单聊接口。
|
|
422
|
-
// 一律经过 outbound,由它处理被动窗口(群聊 5 分钟 / 单聊 60 分钟)、每秒限速、主动消息额度。
|
|
423
|
-
const send = (opts, passive) => {
|
|
424
|
-
if (kind !== 'group' && kind !== 'c2c') return Promise.resolve(null);
|
|
425
|
-
/**
|
|
426
|
-
* `requestId` **不是官方字段**,要从 body 里摘出来单独传给 outbound——
|
|
427
|
-
* 混进去会被 api.js 当成未知参数一路带到接口上。
|
|
428
|
-
*
|
|
429
|
-
* 它是投递账本的幂等键(阶段 5):同一个 id 再调一次只发一条。
|
|
430
|
-
* 没有它就退回原来的「发一次算一次」。
|
|
431
|
-
*/
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
*
|
|
480
|
-
*
|
|
481
|
-
*
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
*
|
|
629
|
-
*
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
}
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
*
|
|
718
|
-
*
|
|
719
|
-
*
|
|
720
|
-
*
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
*
|
|
838
|
-
*
|
|
839
|
-
* await
|
|
840
|
-
*
|
|
841
|
-
*
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
msg.
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import { createLogger } from './logger.js';
|
|
3
|
+
import { FILE_TYPE, MEDIA_SOFT_LIMIT, MEDIA_HARD_LIMIT, DIRECT_UPLOAD_LIMIT } from './api.js';
|
|
4
|
+
import { messageKindOf, EVENTS } from './events.js';
|
|
5
|
+
import { renderMenuMd, mdToPlain } from './markdown.js';
|
|
6
|
+
import { newTraceId } from './trace.js';
|
|
7
|
+
|
|
8
|
+
const log = createLogger('message');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 事件 → 会话类型 的映射来自 src/events.js 的登记表,
|
|
12
|
+
* 那里维护了官方全部 42 个事件,只有标了 message 的才会规范化成 Message。
|
|
13
|
+
*/
|
|
14
|
+
export function isMessageEvent(name) {
|
|
15
|
+
return Boolean(messageKindOf(name));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @ 的格式:<@2409AB55F21D2ACFED13223632472280>
|
|
20
|
+
* ID 可能是十进制,也可能是十六进制大写串,所以不能只匹配 \d+。
|
|
21
|
+
*/
|
|
22
|
+
const MENTION_RE = /<@!?([0-9A-Za-z]+)>/g;
|
|
23
|
+
const MENTION_TEST = /<@!?[0-9A-Za-z]+>/;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 去掉正文里的 @xxx,保留其余内容。
|
|
27
|
+
*
|
|
28
|
+
* 两种形态都要处理:
|
|
29
|
+
* 1. 官方文档说的 `<@openid>` / `<@!openid>` —— 正则剥掉
|
|
30
|
+
* 2. **实测中遇到的 `@昵称 ` 纯文本前缀** —— 有些客户端/协议端把 @ 发成
|
|
31
|
+
* 一段普通文字(内容里就是 `@天道酬勤 /kun 菜单`),不剥的话
|
|
32
|
+
* 这条消息永远不以命令前缀开头,`/kun 签到` 根本进不了命令分发。
|
|
33
|
+
*
|
|
34
|
+
* 第 2 种只剥**开头**那一个,且要求后面跟着空白——避免把正文里的
|
|
35
|
+
* `@某人` 误伤(`你好 @张三` 不动)。
|
|
36
|
+
*/
|
|
37
|
+
function stripMention(content) {
|
|
38
|
+
return String(content ?? '')
|
|
39
|
+
.replace(MENTION_RE, ' ')
|
|
40
|
+
.replace(/^@[^\s@]+\s+/, '')
|
|
41
|
+
.replace(/\s+/g, ' ')
|
|
42
|
+
.trim();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 从正文里捞 @ 的 openid。
|
|
47
|
+
*
|
|
48
|
+
* 官方文档说:消息里 @ 某成员时,content 里会写成 `<@member_openid>`。
|
|
49
|
+
* 也就是说**「@ 了谁」这个信息本来就在正文里**——只是 `stripMention()`
|
|
50
|
+
* 为了让命令解析能正常跑(`<@xxx> /ping` 开头不是 `/`,会被判成非命令),
|
|
51
|
+
* 把它连同其余 @ 一起删掉了。
|
|
52
|
+
*
|
|
53
|
+
* 所以这里在删除**之前**先捞一份出来,和 `d.mentions` 合并。
|
|
54
|
+
* 这样即使官方某个事件类型不返回 mentions 字段,插件照样能知道 @ 了谁。
|
|
55
|
+
*
|
|
56
|
+
* `<@all>` 是 @全体,不是具体的人,跳过。
|
|
57
|
+
*/
|
|
58
|
+
function mentionsFromContent(content) {
|
|
59
|
+
const out = [];
|
|
60
|
+
for (const m of String(content ?? '').matchAll(MENTION_RE)) {
|
|
61
|
+
const id = m[1];
|
|
62
|
+
if (!id || id.toLowerCase() === 'all') continue;
|
|
63
|
+
if (!out.includes(id)) out.push(id);
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 正文里是否 @ 了机器人。
|
|
70
|
+
* 优先用官方给的 mentions 数组(带 is_you 标记,最准),
|
|
71
|
+
* 没有该字段时退回正则匹配。
|
|
72
|
+
*/
|
|
73
|
+
function detectMention(d) {
|
|
74
|
+
if (Array.isArray(d?.mentions) && d.mentions.length) {
|
|
75
|
+
return d.mentions.some((m) => m && (m.is_you || m.bot));
|
|
76
|
+
}
|
|
77
|
+
return MENTION_TEST.test(String(d?.content ?? ''));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 附件归一化。
|
|
82
|
+
*
|
|
83
|
+
* 官方事件里的 `attachments[]` 是图片 / 语音 / 视频 / 文件**唯一**的载体——
|
|
84
|
+
* `content` 里只有文字。以前完全没解析,等于图片和语音全丢。
|
|
85
|
+
*
|
|
86
|
+
* 语音特别值钱,官方直接给了两样东西,不用自己造:
|
|
87
|
+
* - `asr_refer_text` 语音的 ASR 参考结果(不用自己跑识别)
|
|
88
|
+
* - `voice_wav_url` SILK 转好的 WAV 地址(不用自己转码)
|
|
89
|
+
*
|
|
90
|
+
* @param {any[]} list 事件里的 attachments 数组
|
|
91
|
+
* @returns {Array<object>}
|
|
92
|
+
*/
|
|
93
|
+
export function parseAttachments(list) {
|
|
94
|
+
const out = [];
|
|
95
|
+
for (const a of Array.isArray(list) ? list : []) {
|
|
96
|
+
if (!a || typeof a !== 'object') continue;
|
|
97
|
+
const ct = String(a.content_type ?? '');
|
|
98
|
+
const type = ct === 'voice'
|
|
99
|
+
? 'voice'
|
|
100
|
+
: ct.startsWith('image/')
|
|
101
|
+
? 'image'
|
|
102
|
+
: ct.startsWith('video/')
|
|
103
|
+
? 'video'
|
|
104
|
+
: ct === 'file'
|
|
105
|
+
? 'file'
|
|
106
|
+
: 'other';
|
|
107
|
+
out.push({
|
|
108
|
+
type,
|
|
109
|
+
url: String(a.url ?? ''),
|
|
110
|
+
filename: String(a.filename ?? ''),
|
|
111
|
+
size: Number(a.size) || 0,
|
|
112
|
+
width: Number(a.width) || 0,
|
|
113
|
+
height: Number(a.height) || 0,
|
|
114
|
+
contentType: ct,
|
|
115
|
+
/** 语音专用:SILK 转好的 WAV 地址,可直接喂给 ASR */
|
|
116
|
+
wavUrl: String(a.voice_wav_url ?? ''),
|
|
117
|
+
/** 语音专用:官方给的 ASR 参考结果 */
|
|
118
|
+
asrText: String(a.asr_refer_text ?? ''),
|
|
119
|
+
raw: a,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* `message_scene` 归一化。
|
|
127
|
+
*
|
|
128
|
+
* `ext` 是 `["msg_idx=REFIDX_x", "ref_msg_idx=TMP_x", "auth_token=x"]` 这种
|
|
129
|
+
* key=value 字符串数组。其中:
|
|
130
|
+
* - `msg_idx` **本条消息**的引用索引 → 回复时塞进 `message_reference.message_id`
|
|
131
|
+
* - `ref_msg_idx` 本条消息**引用了谁** → 想顺着引用链往上找时用
|
|
132
|
+
*
|
|
133
|
+
* 以前这两个值全丢了,所以「引用回复」根本做不了。
|
|
134
|
+
*/
|
|
135
|
+
export function parseMessageScene(d) {
|
|
136
|
+
const ext = d?.message_scene?.ext;
|
|
137
|
+
const map = {};
|
|
138
|
+
for (const item of Array.isArray(ext) ? ext : []) {
|
|
139
|
+
const s = String(item ?? '');
|
|
140
|
+
const i = s.indexOf('=');
|
|
141
|
+
if (i > 0) map[s.slice(0, i)] = s.slice(i + 1);
|
|
142
|
+
}
|
|
143
|
+
return {
|
|
144
|
+
source: String(d?.message_scene?.source ?? ''),
|
|
145
|
+
msgIdx: map.msg_idx ?? '',
|
|
146
|
+
refMsgIdx: map.ref_msg_idx ?? '',
|
|
147
|
+
authToken: map.auth_token ?? '',
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** ARK 卡片(B 站分享、小程序、位置卡…)结构化 */
|
|
152
|
+
function parseArk(a) {
|
|
153
|
+
if (!a || typeof a !== 'object') return null;
|
|
154
|
+
const f = a.fields && typeof a.fields === 'object' ? a.fields : {};
|
|
155
|
+
return {
|
|
156
|
+
type: String(a.ark_type ?? ''),
|
|
157
|
+
name: String(a.ark_name ?? ''),
|
|
158
|
+
prompt: String(a.prompt ?? ''),
|
|
159
|
+
title: String(f.title ?? ''),
|
|
160
|
+
desc: String(f.desc ?? ''),
|
|
161
|
+
url: String(f.jump_url ?? ''),
|
|
162
|
+
fields: f,
|
|
163
|
+
raw: a,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* `msg_elements` 归一化(递归)。
|
|
169
|
+
*
|
|
170
|
+
* message_type:0 普通文本 / 3 结构化卡片 / 101 并行消息 /
|
|
171
|
+
* **102 聊天记录(合并转发)** / **103 引用消息**
|
|
172
|
+
*
|
|
173
|
+
* 102 就是「合并转发」——以前我们说官方没有合并转发,其实是官方推了、
|
|
174
|
+
* 我们没解析。103 是引用,`content` 里是那段被引用的原文。
|
|
175
|
+
*/
|
|
176
|
+
export function parseElements(list, depth = 0) {
|
|
177
|
+
const out = [];
|
|
178
|
+
if (depth > 3) return out; // 官方嵌套不会很深,防畸形数据把栈打穿
|
|
179
|
+
for (const el of Array.isArray(list) ? list : []) {
|
|
180
|
+
if (!el || typeof el !== 'object') continue;
|
|
181
|
+
const mt = Number(el.message_type);
|
|
182
|
+
const type =
|
|
183
|
+
mt === 0 ? 'text'
|
|
184
|
+
: mt === 3 ? 'ark'
|
|
185
|
+
: mt === 101 ? 'parallel'
|
|
186
|
+
: mt === 102 ? 'chatRecord'
|
|
187
|
+
: mt === 103 ? 'quote'
|
|
188
|
+
: 'other';
|
|
189
|
+
const author = el.author && typeof el.author === 'object' ? el.author : null;
|
|
190
|
+
out.push({
|
|
191
|
+
type,
|
|
192
|
+
messageType: mt,
|
|
193
|
+
msgIdx: String(el.msg_idx ?? ''),
|
|
194
|
+
author: author
|
|
195
|
+
? {
|
|
196
|
+
id: String(author.member_openid ?? author.user_openid ?? author.id ?? ''),
|
|
197
|
+
name: String(author.username ?? ''),
|
|
198
|
+
isBot: Boolean(author.bot),
|
|
199
|
+
}
|
|
200
|
+
: null,
|
|
201
|
+
content: String(el.content ?? ''),
|
|
202
|
+
attachments: parseAttachments(el.attachments),
|
|
203
|
+
ark: parseArk(el.ark_data),
|
|
204
|
+
children: parseElements(el.msg_elements, depth + 1),
|
|
205
|
+
raw: el,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
return out;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* 把官方事件体统一成好用的 Message 对象,插件只跟它打交道。
|
|
213
|
+
*
|
|
214
|
+
* @param {object} [p.identity] 身份对照表(`IdentityStore`)。没传就是没启用绑定,
|
|
215
|
+
* 所有 `uin` / `*Aliases` 字段一律空,老行为不变。
|
|
216
|
+
*/
|
|
217
|
+
export function buildMessage(eventName, d, { api, cfg, outbound, session, traceId, identity }) {
|
|
218
|
+
const kind = messageKindOf(eventName) ?? 'unknown';
|
|
219
|
+
|
|
220
|
+
let groupId = '';
|
|
221
|
+
let userId = '';
|
|
222
|
+
const userName = d.author?.username ?? '';
|
|
223
|
+
if (kind === 'group') {
|
|
224
|
+
groupId = d.group_openid ?? '';
|
|
225
|
+
userId = d.author?.member_openid ?? d.author?.id ?? '';
|
|
226
|
+
} else if (kind === 'c2c') {
|
|
227
|
+
userId = d.author?.user_openid ?? d.author?.id ?? '';
|
|
228
|
+
} else if (kind === 'guild') {
|
|
229
|
+
groupId = d.channel_id ?? '';
|
|
230
|
+
userId = d.author?.id ?? '';
|
|
231
|
+
} else if (kind === 'dm') {
|
|
232
|
+
groupId = d.guild_id ?? '';
|
|
233
|
+
userId = d.author?.id ?? '';
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const content = stripMention(d.content);
|
|
237
|
+
const prefix = cfg.commandPrefix || '/';
|
|
238
|
+
const isCommand = content.startsWith(prefix);
|
|
239
|
+
const mentioned = detectMention(d);
|
|
240
|
+
const chatId = groupId || userId;
|
|
241
|
+
const receivedAt = Date.now();
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* 身份对照:openid 换机器人就变,QQ号 / 群号不变。
|
|
245
|
+
*
|
|
246
|
+
* 这里只**查表**,不做任何自动匹配——认人只能靠管理员在面板「身份」页绑一次。
|
|
247
|
+
* 未绑定一律给空串、不抛错:内核每条消息都要过这里,抛错会把消息吞掉。
|
|
248
|
+
* 插件要「没绑就提示管理员」,用 `ctx.identity.requireOpenid()`,那条路才抛。
|
|
249
|
+
*
|
|
250
|
+
* `userAliases` / `groupAliases` 是读旧记忆的备用键:号码本身 + 这个号码名下
|
|
251
|
+
* 所有 openid(含别的机器人的)。**人和群必须分成两组**——合成一个数组,
|
|
252
|
+
* 群会话就会读到私聊记忆,这种串场很难在事后发现。
|
|
253
|
+
*
|
|
254
|
+
* 只在群聊 / 单聊算:频道(guild/dm)的 `channel_id`、`guild_id` 不是 openid,
|
|
255
|
+
* 拿它去对照 QQ号没有意义。
|
|
256
|
+
*/
|
|
257
|
+
const idOn = Boolean(identity) && (kind === 'group' || kind === 'c2c');
|
|
258
|
+
const idUser = idOn ? identity.resolveUser(cfg?.id, userId) : null;
|
|
259
|
+
const idGroup = idOn && kind === 'group' ? identity.resolveGroup(cfg?.id, groupId) : null;
|
|
260
|
+
// 记一次「见过」(内部有 60 秒节流,不会每条消息都写盘)
|
|
261
|
+
if (idUser) identity.touch({ kind: 'user', uin: idUser.uin, botId: cfg?.id, scope: `${kind}:${chatId}` });
|
|
262
|
+
if (idGroup) identity.touch({ kind: 'group', uin: idGroup.uin, botId: cfg?.id, scope: `group:${chatId}` });
|
|
263
|
+
|
|
264
|
+
// 没 @ 也不算命令,但可能处在免@窗口内 —— 此时同样算「在跟机器人说话」
|
|
265
|
+
const sticky = Boolean(session?.isSticky(chatId));
|
|
266
|
+
const addressed = mentioned || isCommand || sticky;
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* 官方事件里以前被整个丢掉的载荷。
|
|
270
|
+
* 不解析的话,图片 / 语音 / 卡片 / 引用 / 合并转发在插件眼里只剩一段文字。
|
|
271
|
+
*/
|
|
272
|
+
const attachments = parseAttachments(d?.attachments);
|
|
273
|
+
const scene = parseMessageScene(d);
|
|
274
|
+
const ark = parseArk(d?.ark_data);
|
|
275
|
+
const elements = parseElements(d?.msg_elements);
|
|
276
|
+
|
|
277
|
+
const msg = {
|
|
278
|
+
id: d.id ?? '',
|
|
279
|
+
/**
|
|
280
|
+
* 链路 id:这一条消息从进站到回话的唯一标识。
|
|
281
|
+
*
|
|
282
|
+
* 生成点必须在内核——它先看到原始事件;插件只是路过,
|
|
283
|
+
* 让插件生成会出现「同一条消息被两个插件各生成一个 id」。
|
|
284
|
+
*/
|
|
285
|
+
traceId: String(traceId || newTraceId()),
|
|
286
|
+
/**
|
|
287
|
+
* 内核拿到这条事件的时间。四段耗时的第一段靠它算:
|
|
288
|
+
* 官方事件到达 → 插件真正拿到,中间是内核的分发排队。
|
|
289
|
+
*/
|
|
290
|
+
receivedAt,
|
|
291
|
+
kind,
|
|
292
|
+
event: eventName,
|
|
293
|
+
// 事件元信息(属于哪个 intent、中文说明),来自 src/events.js
|
|
294
|
+
eventMeta: EVENTS[eventName] ?? null,
|
|
295
|
+
groupId,
|
|
296
|
+
userId,
|
|
297
|
+
userName,
|
|
298
|
+
chatId,
|
|
299
|
+
|
|
300
|
+
/* ---------------- 身份(QQ号 / 群号 ↔ openid) ---------------- */
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* QQ号。面板绑过才有,否则空串。
|
|
304
|
+
*
|
|
305
|
+
* **给 AI 看的时候号码优先、openid 兜底**:openid 换机器人就变,
|
|
306
|
+
* 号码不变。有号码时按号码认人,没有才退回 openid。
|
|
307
|
+
*/
|
|
308
|
+
uin: idUser?.uin ?? '',
|
|
309
|
+
/** 群号,同上 */
|
|
310
|
+
groupUin: idGroup?.uin ?? '',
|
|
311
|
+
/** 跨机器人稳定的身份 id:人 `u:<QQ号>`、群 `g:<群号>` */
|
|
312
|
+
identityId: idUser?.identityId ?? '',
|
|
313
|
+
groupIdentityId: idGroup?.identityId ?? '',
|
|
314
|
+
/** 读旧记忆的备用键:号码 + 该号码名下所有 openid(不含当前 chatId) */
|
|
315
|
+
userAliases: idUser ? identity.aliasesOf('user', idUser.uin, userId) : [],
|
|
316
|
+
groupAliases: idGroup ? identity.aliasesOf('group', idGroup.uin, groupId) : [],
|
|
317
|
+
|
|
318
|
+
content,
|
|
319
|
+
raw: d,
|
|
320
|
+
mentioned,
|
|
321
|
+
/**
|
|
322
|
+
* 本条消息 @ 了谁(不含机器人自己)。
|
|
323
|
+
*
|
|
324
|
+
* 两个来源合并:
|
|
325
|
+
* 1. `d.mentions`——字段名各事件不统一,可能是 id / member_openid / user_openid,
|
|
326
|
+
* 而且带 `is_you` / `bot` 标记,能准确排除机器人自己
|
|
327
|
+
* 2. **正文里的 `<@openid>`**——官方文档明确说 @ 成员时 content 里就是这个格式。
|
|
328
|
+
* 以前 `stripMention()` 把它删了,于是「@ 了谁」这个信息白白丢掉,
|
|
329
|
+
* 插件只能让用户手填 openid。现在删之前先捞一份。
|
|
330
|
+
*
|
|
331
|
+
* 合并时 declared 优先:它知道谁是机器人,正文里认不出来。
|
|
332
|
+
*/
|
|
333
|
+
mentions: (() => {
|
|
334
|
+
const declared = (Array.isArray(d?.mentions) ? d.mentions : [])
|
|
335
|
+
.map((m) => ({
|
|
336
|
+
id: m?.id ?? m?.member_openid ?? m?.user_openid ?? '',
|
|
337
|
+
isBot: Boolean(m?.is_you || m?.bot),
|
|
338
|
+
// 昵称带上:禁言/踢人成功时能回「已禁言 张三」,比一串 openid 好认
|
|
339
|
+
name: String(m?.username ?? '').trim(),
|
|
340
|
+
}))
|
|
341
|
+
.filter((m) => m.id);
|
|
342
|
+
const fromText = mentionsFromContent(d?.content).map((id) => ({ id, isBot: false, name: '' }));
|
|
343
|
+
|
|
344
|
+
const seen = new Set();
|
|
345
|
+
const out = [];
|
|
346
|
+
for (const m of [...declared, ...fromText]) {
|
|
347
|
+
if (seen.has(m.id)) continue;
|
|
348
|
+
seen.add(m.id);
|
|
349
|
+
if (!m.isBot) out.push(m);
|
|
350
|
+
}
|
|
351
|
+
return out;
|
|
352
|
+
})(),
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* 同上,但**不排除机器人自己**。
|
|
356
|
+
*
|
|
357
|
+
* 插件用 `mentions`(点名单选人的时候不该把机器人算进去),
|
|
358
|
+
* 日志和排查用这个——`@机器人 /禁言 1m` 里被 @ 的机器人恰好是
|
|
359
|
+
* mentions 过滤掉的那个,日志里只剩 `<@2409AB55…>`,看不出 @ 了谁。
|
|
360
|
+
*/
|
|
361
|
+
allMentions: (() => {
|
|
362
|
+
const declared = (Array.isArray(d?.mentions) ? d.mentions : [])
|
|
363
|
+
.map((m) => ({
|
|
364
|
+
id: m?.id ?? m?.member_openid ?? m?.user_openid ?? '',
|
|
365
|
+
isBot: Boolean(m?.is_you || m?.bot),
|
|
366
|
+
name: String(m?.username ?? '').trim(),
|
|
367
|
+
}))
|
|
368
|
+
.filter((m) => m.id);
|
|
369
|
+
const fromText = mentionsFromContent(d?.content).map((id) => ({ id, isBot: false, name: '' }));
|
|
370
|
+
|
|
371
|
+
const seen = new Set();
|
|
372
|
+
const out = [];
|
|
373
|
+
for (const m of [...declared, ...fromText]) {
|
|
374
|
+
if (seen.has(m.id)) continue;
|
|
375
|
+
seen.add(m.id);
|
|
376
|
+
out.push(m);
|
|
377
|
+
}
|
|
378
|
+
return out;
|
|
379
|
+
})(),
|
|
380
|
+
|
|
381
|
+
/* ---------------- 官方载荷(2026-10-26 补齐) ---------------- */
|
|
382
|
+
|
|
383
|
+
/** 附件全量:图片 / 语音 / 视频 / 文件 */
|
|
384
|
+
attachments,
|
|
385
|
+
/** 只取图片,插件最常用(喂视觉模型) */
|
|
386
|
+
images: attachments.filter((a) => a.type === 'image'),
|
|
387
|
+
/** 只取语音;`asrText` 是官方 ASR 结果,`wavUrl` 是转好的 WAV */
|
|
388
|
+
voices: attachments.filter((a) => a.type === 'voice'),
|
|
389
|
+
/** 其余附件(视频 / 文件 / 未知) */
|
|
390
|
+
files: attachments.filter((a) => a.type === 'video' || a.type === 'file' || a.type === 'other'),
|
|
391
|
+
/** { source, msgIdx, refMsgIdx, authToken } */
|
|
392
|
+
scene,
|
|
393
|
+
/** 本条消息的引用索引 → 回复时当 message_reference 用 */
|
|
394
|
+
msgIdx: scene.msgIdx,
|
|
395
|
+
/** 本条消息引用了谁 */
|
|
396
|
+
refMsgIdx: scene.refMsgIdx,
|
|
397
|
+
/** 卡片消息(B 站分享 / 小程序 / 位置卡…),没有就是 null */
|
|
398
|
+
ark,
|
|
399
|
+
/** 消息元素:102 聊天记录(合并转发)、103 引用、101 并行 */
|
|
400
|
+
elements,
|
|
401
|
+
/** 这条消息本身如果是「引用某条」,这里是被引用的原文(拿不到就是空串) */
|
|
402
|
+
quoted: elements.find((e) => e.type === 'quote')?.content ?? '',
|
|
403
|
+
|
|
404
|
+
sticky,
|
|
405
|
+
addressed,
|
|
406
|
+
isCommand,
|
|
407
|
+
receivedAt, // 被动回复窗口从这里开始算,出站调度器要用
|
|
408
|
+
replied: false, // 任何发送动作都会置为 true,供内核判断「有没有插件回应」
|
|
409
|
+
command: '',
|
|
410
|
+
args: [],
|
|
411
|
+
at: Number(d.timestamp) || receivedAt,
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
if (isCommand) {
|
|
415
|
+
const body = content.slice(prefix.length).trim();
|
|
416
|
+
const [cmd, ...rest] = body.split(/\s+/);
|
|
417
|
+
msg.command = (cmd || '').toLowerCase();
|
|
418
|
+
msg.args = rest;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// 底层发送:group 走群接口,c2c 走单聊接口。
|
|
422
|
+
// 一律经过 outbound,由它处理被动窗口(群聊 5 分钟 / 单聊 60 分钟)、每秒限速、主动消息额度。
|
|
423
|
+
const send = (opts, passive) => {
|
|
424
|
+
if (kind !== 'group' && kind !== 'c2c') return Promise.resolve(null);
|
|
425
|
+
/**
|
|
426
|
+
* `requestId` **不是官方字段**,要从 body 里摘出来单独传给 outbound——
|
|
427
|
+
* 混进去会被 api.js 当成未知参数一路带到接口上。
|
|
428
|
+
*
|
|
429
|
+
* 它是投递账本的幂等键(阶段 5):同一个 id 再调一次只发一条。
|
|
430
|
+
* 没有它就退回原来的「发一次算一次」。
|
|
431
|
+
*/
|
|
432
|
+
/**
|
|
433
|
+
* `plugin` 同理要摘出来:它只用于账本归属(v3),
|
|
434
|
+
* 混进 body 会被 `api.js` 的白名单丢掉(不会 400),
|
|
435
|
+
* 但会让 payload 里多一个谁也看不懂的字段。
|
|
436
|
+
*/
|
|
437
|
+
/**
|
|
438
|
+
* `fileType` 也摘出来:它只用于账本给「(图片)/(语音)/(文件)」占位,
|
|
439
|
+
* 混进 body 会被 api 当未知参数带出去。
|
|
440
|
+
*/
|
|
441
|
+
const { requestId, plugin, fileType, ...rest } = opts ?? {};
|
|
442
|
+
/**
|
|
443
|
+
* 出站归属统一在这里注入 —— **一处覆盖所有发送路径**。
|
|
444
|
+
*
|
|
445
|
+
* 以前只在 `flushReplies` 拼 `plugins`,于是 `replyMarkdown` /
|
|
446
|
+
* `replyMedia` / `replyImageBuffer` 这些绕过攒批的路径全部漏掉,
|
|
447
|
+
* 真机 549 行里 `plugin` 非空 0 条就是这么来的。
|
|
448
|
+
* `msg.pluginId` 由 bot.js 在分发前设置;`bot.js:1035` 是串行 await,
|
|
449
|
+
* 异步回话时它不会被后续监听器覆盖。
|
|
450
|
+
*/
|
|
451
|
+
const sender = String(plugin ?? msg.pluginId ?? '');
|
|
452
|
+
const body = passive ? { msgId: msg.id, ...rest } : { ...rest };
|
|
453
|
+
msg.replied = true;
|
|
454
|
+
|
|
455
|
+
if (!outbound) {
|
|
456
|
+
// 兜底:没有调度器(例如早期调用方)就直接发
|
|
457
|
+
return kind === 'group' ? api.sendGroup(groupId, body) : api.sendC2C(userId, body);
|
|
458
|
+
}
|
|
459
|
+
return outbound.send({
|
|
460
|
+
kind,
|
|
461
|
+
target: kind === 'group' ? groupId : userId,
|
|
462
|
+
chatKey: `${kind}:${chatId}`,
|
|
463
|
+
payload: body,
|
|
464
|
+
receivedAt,
|
|
465
|
+
...(requestId ? { requestId: String(requestId) } : {}),
|
|
466
|
+
...(sender ? { plugin: sender } : {}),
|
|
467
|
+
...(fileType ? { fileType: Number(fileType) } : {}),
|
|
468
|
+
});
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
/* ---------------- 回复合并 ---------------- */
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* 消息认领。
|
|
475
|
+
*
|
|
476
|
+
* 有的插件是「关键词触发」的(回声、养鲲、群管),它们处理过一条消息之后,
|
|
477
|
+
* 别的插件不该再对同一条消息说话——否则群里会看到两个人同时回答。
|
|
478
|
+
*
|
|
479
|
+
* 认领之后,**没声明 `seeClaimed` 的插件就收不到这条消息了**。
|
|
480
|
+
* 顺序由插件的 `priority` 决定(高的先跑),所以「AI 对话」这类
|
|
481
|
+
* 什么都想接的插件把 priority 调低,让专门的插件先认领。
|
|
482
|
+
*
|
|
483
|
+
* // 插件里
|
|
484
|
+
* if (matched) { msg.claim(); ... }
|
|
485
|
+
*
|
|
486
|
+
* 也可以直接 `return true`——内核会替你认领。
|
|
487
|
+
*/
|
|
488
|
+
msg.claimedBy = '';
|
|
489
|
+
msg.claim = () => {
|
|
490
|
+
if (!msg.claimedBy) msg.claimedBy = msg.pluginId || 'unknown';
|
|
491
|
+
return true;
|
|
492
|
+
};
|
|
493
|
+
|
|
494
|
+
// 一次消息处理中可能有多个插件都要回复(命令 + 监听器 + 被动触发),
|
|
495
|
+
// 逐条发出去会刷屏。所以先攒起来,处理完统一发一条。
|
|
496
|
+
const replyBuf = [];
|
|
497
|
+
const MAX_CHUNK = 1500; // 单条上限,超了按行切成多条
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* 被动回复(带 msg_id,群聊 5 分钟内、单聊 60 分钟内有效)。
|
|
501
|
+
* **不会立刻发出**,而是攒到本次处理结束,由 flushReplies() 合并成一条。
|
|
502
|
+
* 想立刻发用 msg.replyNow()。
|
|
503
|
+
*/
|
|
504
|
+
msg.reply = (text, opts = {}) => {
|
|
505
|
+
if (text === undefined || text === null || text === '') return Promise.resolve(null);
|
|
506
|
+
/**
|
|
507
|
+
* 记下是谁在回。
|
|
508
|
+
*
|
|
509
|
+
* 同一条消息可能有好几个插件都想回(比如「回声」接关键词、
|
|
510
|
+
* 「AI 对话」接提问)。这些回复会攒起来合并成一条发出去,
|
|
511
|
+
* 合并时得让人看出哪句是谁说的——不然群里看到一坨混在一起的文字,
|
|
512
|
+
* 根本分不清是哪个功能在说话。
|
|
513
|
+
*
|
|
514
|
+
* 这两个字段由 bot.js 在调用插件前设上,插件自己不用管。
|
|
515
|
+
*/
|
|
516
|
+
replyBuf.push({
|
|
517
|
+
text: String(text),
|
|
518
|
+
opts,
|
|
519
|
+
pluginId: msg.pluginId ?? '',
|
|
520
|
+
pluginName: msg.pluginName ?? '',
|
|
521
|
+
});
|
|
522
|
+
msg.replied = true; // 立刻标记,内核据此判断「这条消息有人管」
|
|
523
|
+
return Promise.resolve(null);
|
|
524
|
+
};
|
|
525
|
+
|
|
526
|
+
/** 绕过合并,立刻发一条 */
|
|
527
|
+
msg.replyNow = (text, opts = {}) => {
|
|
528
|
+
if (text === undefined || text === null || text === '') return Promise.resolve(null);
|
|
529
|
+
/**
|
|
530
|
+
* 带插件归属(v3)。
|
|
531
|
+
*
|
|
532
|
+
* `msg.reply` 那条路走 `flushReplies`,会从 `replyBuf` 里拼出 `plugins`;
|
|
533
|
+
* 但 `replyNow`(多段直发)**绕过合并**,直接调 `send`。真机实测两条出站
|
|
534
|
+
* 记录的 `plugin` 都是空,就是这么漏的。这里从 `msg.pluginId` 补上——
|
|
535
|
+
* 它由 `bot.js` 在调监听器之前设好,闭包里拿得到。
|
|
536
|
+
*/
|
|
537
|
+
return send({
|
|
538
|
+
content: String(text),
|
|
539
|
+
...opts,
|
|
540
|
+
...(msg.pluginId ? { plugin: String(msg.pluginId) } : {}),
|
|
541
|
+
}, true);
|
|
542
|
+
};
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* 引用回复:以「引用某条消息」的样式发出去(官方 `message_reference`)。
|
|
546
|
+
*
|
|
547
|
+
* 跟 `reply()` 不是一回事:
|
|
548
|
+
* - `reply()` 是**被动回复**,靠 msg_id,群聊 5 分钟 / 5 次,单聊 60 分钟 / 4 次
|
|
549
|
+
* - 这个是**引用**,靠 `REFIDX`,视觉上带"回复某某"的引用块
|
|
550
|
+
*
|
|
551
|
+
* 默认引用当前这条消息(用它的 `msg_idx`)。想引用别的传 `refIdx`。
|
|
552
|
+
* 拿不到引用索引时自动退化成普通回复,不会因为缺字段发不出去。
|
|
553
|
+
*/
|
|
554
|
+
msg.replyQuote = (text, opts = {}) => {
|
|
555
|
+
if (text === undefined || text === null || text === '') return Promise.resolve(null);
|
|
556
|
+
const { refIdx, ...rest } = opts;
|
|
557
|
+
const ref = String(refIdx || msg.msgIdx || '');
|
|
558
|
+
if (!ref) return msg.replyNow(text, rest);
|
|
559
|
+
return send({ content: String(text), messageReference: { message_id: ref }, ...rest }, true);
|
|
560
|
+
};
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* 把攒下的回复合并成一条发出去。由内核在本次处理结束时调用。
|
|
564
|
+
* 同一段文本内的 opts 只取第一条的(合并后就是一条消息,没法各带各的)。
|
|
565
|
+
*/
|
|
566
|
+
msg.flushReplies = async () => {
|
|
567
|
+
if (!replyBuf.length) return null;
|
|
568
|
+
const items = replyBuf.splice(0, replyBuf.length);
|
|
569
|
+
const opts = items[0].opts ?? {};
|
|
570
|
+
/**
|
|
571
|
+
* 这条合并消息里出现了哪些插件(去重、逗号分隔)。
|
|
572
|
+
*
|
|
573
|
+
* 一个群的一轮里可能有「回声」和「AI 对话」都回了,合并成一条发出去,
|
|
574
|
+
* 正文里有 `【回声】` 这种标题,但那是文本、查不了。账本要的是可过滤的列。
|
|
575
|
+
*/
|
|
576
|
+
const plugins = [...new Set(items.map((i) => i.pluginId).filter(Boolean))].join(',');
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* 合并策略:**多个插件都回了才加标题**。
|
|
580
|
+
*
|
|
581
|
+
* 【回声】 ← 只有出现两个及以上插件时才有这行
|
|
582
|
+
* 你好呀
|
|
583
|
+
*
|
|
584
|
+
* 【AI 对话】
|
|
585
|
+
* 你好,我是 AI
|
|
586
|
+
*
|
|
587
|
+
* 只有一个插件回时不加——那种情况下加标题纯属噪音,
|
|
588
|
+
* 而且会把现有插件的输出格式全改掉。
|
|
589
|
+
*
|
|
590
|
+
* 同一个插件的多段回复并进它自己那一块,不重复加标题。
|
|
591
|
+
*/
|
|
592
|
+
const groups = new Map(); // pluginId -> { name, texts[] }
|
|
593
|
+
for (const it of items) {
|
|
594
|
+
const key = it.pluginId || '';
|
|
595
|
+
if (!groups.has(key)) groups.set(key, { name: it.pluginName || '', texts: [] });
|
|
596
|
+
groups.get(key).texts.push(it.text);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
let merged;
|
|
600
|
+
if (groups.size > 1) {
|
|
601
|
+
merged = [...groups.values()]
|
|
602
|
+
.map((g) => {
|
|
603
|
+
const body = g.texts.join('\n');
|
|
604
|
+
// 拿不到插件名(匿名回复)就只放正文,别留个空标题
|
|
605
|
+
return g.name ? `【${g.name}】\n${body}` : body;
|
|
606
|
+
})
|
|
607
|
+
.join('\n\n');
|
|
608
|
+
} else {
|
|
609
|
+
merged = items.map((x) => x.text).join('\n');
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// 超长按行切,避免一条消息塞不下被平台拒
|
|
613
|
+
const chunks = [];
|
|
614
|
+
let cur = '';
|
|
615
|
+
for (const line of merged.split('\n')) {
|
|
616
|
+
if (cur && (cur.length + 1 + line.length) > MAX_CHUNK) {
|
|
617
|
+
chunks.push(cur);
|
|
618
|
+
cur = line;
|
|
619
|
+
} else {
|
|
620
|
+
cur = cur ? `${cur}\n${line}` : line;
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
if (cur) chunks.push(cur);
|
|
624
|
+
|
|
625
|
+
let last = null;
|
|
626
|
+
for (const c of chunks) {
|
|
627
|
+
/**
|
|
628
|
+
* 单条发失败不能把整轮处理带崩。
|
|
629
|
+
*
|
|
630
|
+
* 调用方(bot.js 的 finally)是 `await msg.flushReplies()`,这里一抛就变成
|
|
631
|
+
* 未捕获 rejection;掉线瞬间、被动窗口过期、主动额度用尽都会走到这条路。
|
|
632
|
+
* 结果是**一个群的一条消息发不出去,所有机器人一起退出**。
|
|
633
|
+
*/
|
|
634
|
+
try {
|
|
635
|
+
last = await send({ content: c, ...opts, ...(plugins ? { plugin: plugins } : {}) }, true);
|
|
636
|
+
} catch (e) {
|
|
637
|
+
log.warn(`回复发送失败(${chunks.length > 1 ? '分段 ' : ''}已跳过):${e.message}`);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
return last;
|
|
641
|
+
};
|
|
642
|
+
|
|
643
|
+
/** 主动推送(不带 msg_id,有频次限制) */
|
|
644
|
+
msg.push = (text, opts = {}) => {
|
|
645
|
+
if (text === undefined || text === null || text === '') return Promise.resolve(null);
|
|
646
|
+
return send({ content: String(text), ...opts }, false);
|
|
647
|
+
};
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* Markdown 消息(可带按钮 keyboard)。Markdown 是独立消息类型,先把攒的文本发掉。
|
|
651
|
+
*
|
|
652
|
+
* `opts.requestId`:投递账本的幂等键。以前这条路完全绕开账本——
|
|
653
|
+
* 插件给了 id 也只对纯文本生效,markdown 那条永远记不上,重发就会重复。
|
|
654
|
+
* 第三个参数是可选的,老调用方不传,行为不变。
|
|
655
|
+
*/
|
|
656
|
+
msg.replyMarkdown = async (markdown, keyboard, opts = {}) => {
|
|
657
|
+
await msg.flushReplies();
|
|
658
|
+
try {
|
|
659
|
+
return await send({ markdown, keyboard, ...(opts?.requestId ? { requestId: opts.requestId } : {}) }, true);
|
|
660
|
+
} catch (e) {
|
|
661
|
+
/**
|
|
662
|
+
* Markdown 是**独立消息类型**,没有纯文本兜底:平台不放行(没权限、
|
|
663
|
+
* 内容被判不合规)就整条发不出去。而所有 `ctx.menu()` 出来的菜单都是
|
|
664
|
+
* markdown——不降级的话,用户点菜单看到的是「什么都没发生」。
|
|
665
|
+
*
|
|
666
|
+
* 只在「服务端明确拒了」时降级(4xx)。理由:
|
|
667
|
+
* · 5xx / 网络错误 有可能已经送达,重发会变成两条;
|
|
668
|
+
* · 429 是限流,换纯文本一样被限,白烧一次;
|
|
669
|
+
* · offline / expired / quota 换纯文本也发不出去,expired/quota 还会多扣额度。
|
|
670
|
+
*/
|
|
671
|
+
const status = Number(e?.status) || 0;
|
|
672
|
+
const rejected = (status >= 400 && status < 500 && status !== 429) || e?.code === 'api';
|
|
673
|
+
if (!rejected) {
|
|
674
|
+
log.error('发送 Markdown 失败:', e.message);
|
|
675
|
+
return null;
|
|
676
|
+
}
|
|
677
|
+
const plain = mdToPlain(markdown);
|
|
678
|
+
if (!plain) {
|
|
679
|
+
log.error(`Markdown 被拒(${e.message}),且降不出纯文本`);
|
|
680
|
+
return null;
|
|
681
|
+
}
|
|
682
|
+
/**
|
|
683
|
+
* 不带 keyboard:按钮挂在纯文本上平台不认,带上会连累整条消息再被拒一次。
|
|
684
|
+
* 按钮里的指令本来也会出现在正文里(ctx.menu 渲染时带上),信息不丢。
|
|
685
|
+
*/
|
|
686
|
+
log.warn(`Markdown 被拒(${e.message}),降级为纯文本重发`);
|
|
687
|
+
/**
|
|
688
|
+
* 降级重发**沿用同一个 requestId**:对用户来说这仍然是「一条消息」,
|
|
689
|
+
* 账本里就该只有一行。上一步抛错时已经结算成 `failed`(可重试),
|
|
690
|
+
* 所以这里 claim 会放行并复用同一行,最终状态是 `sent`。
|
|
691
|
+
* 万一是 `unknown`,claim 会抛错——那正是我们要的「不重复发」。
|
|
692
|
+
*/
|
|
693
|
+
return send({ content: plain, ...(opts?.requestId ? { requestId: opts.requestId } : {}) }, true).catch((e2) => {
|
|
694
|
+
log.error('降级纯文本也没发出去:', e2.message);
|
|
695
|
+
return null;
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
};
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* 发一条带**可点击指令**的菜单(markdown 消息)。
|
|
702
|
+
*
|
|
703
|
+
* 群聊用 `<qqbot-cmd-input>`(点了把指令插进输入框,用户自己按发送),
|
|
704
|
+
* 单聊 / 频道用 `<qqbot-cmd-enter>`(点了直接发)。
|
|
705
|
+
* 这两种标签只能在 markdown 消息里生效,普通文本里会显示成原始标签。
|
|
706
|
+
*
|
|
707
|
+
* @param {string} title 大标题
|
|
708
|
+
* @param {Array<{title:string, items:Array<{text:string, desc?:string, cmd?:string}>}>} groups
|
|
709
|
+
* @param {object} [opts] { keyboard } 需要真按钮时传
|
|
710
|
+
*/
|
|
711
|
+
msg.replyMenu = async (title, groups, opts = {}) => {
|
|
712
|
+
const content = renderMenuMd(title, groups, { kind });
|
|
713
|
+
return msg.replyMarkdown({ content }, opts.keyboard);
|
|
714
|
+
};
|
|
715
|
+
|
|
716
|
+
/**
|
|
717
|
+
* 发一条**模板消息**:markdown 正文 + 按钮 + ark 结构化模板一次带出去。
|
|
718
|
+
*
|
|
719
|
+
* 为什么不直接用 replyMarkdown:这三样属于同一条消息的不同部分,
|
|
720
|
+
* 分三次发会变成三条独立消息,用户看到的是三块碎片。
|
|
721
|
+
*
|
|
722
|
+
* await msg.replyCard({
|
|
723
|
+
* markdown: { content: '## 今日运势\n> 大吉' },
|
|
724
|
+
* keyboard: { content: { rows: [...] } },
|
|
725
|
+
* });
|
|
726
|
+
*
|
|
727
|
+
* ark 和 markdown 同时给时以 markdown 为准(官方不支持一条消息两种富文本)。
|
|
728
|
+
*
|
|
729
|
+
* @param {{markdown?:object, keyboard?:object, ark?:object}} card
|
|
730
|
+
*/
|
|
731
|
+
msg.replyCard = async (card = {}) => {
|
|
732
|
+
const { markdown, keyboard } = card;
|
|
733
|
+
let { ark } = card;
|
|
734
|
+
if (!markdown && !ark) return null;
|
|
735
|
+
/**
|
|
736
|
+
* 一条消息只能有一种富文本:两个都给时**以 markdown 为准**,ark 丢掉。
|
|
737
|
+
* 必须在这里丢,不能靠下层——api 的 #payload 是先写 markdown 后写 ark,
|
|
738
|
+
* 两个都传进去 msg_type 会被改成 3(ark 赢),和这里说的正好相反。
|
|
739
|
+
*/
|
|
740
|
+
if (markdown && ark) {
|
|
741
|
+
log.warn('replyCard 同时给了 markdown 和 ark,已忽略 ark(一条消息只支持一种富文本)');
|
|
742
|
+
ark = undefined;
|
|
743
|
+
}
|
|
744
|
+
await msg.flushReplies();
|
|
745
|
+
return send({ markdown, keyboard, ark }, true).catch((e) => {
|
|
746
|
+
log.error('发送模板消息失败:', e.message);
|
|
747
|
+
return null;
|
|
748
|
+
});
|
|
749
|
+
};
|
|
750
|
+
|
|
751
|
+
/**
|
|
752
|
+
* 发富媒体:上传后以 msg_type=7 发送。
|
|
753
|
+
*
|
|
754
|
+
* 富媒体在官方接口里只能是独立消息(msg_type=7),但这条消息自带
|
|
755
|
+
* `content` 文本字段:传了 `text` 就和图走**同一条**,不用先发一句话
|
|
756
|
+
* 再发一张图(那是两条消息,群里看着是两截)。不传 text 时保持老
|
|
757
|
+
* 行为——content 给一个空格(官方不接受空 content)。
|
|
758
|
+
*
|
|
759
|
+
* 发送前会先把攒着的文本 flush 掉,免得「缓冲中的回复」被这条
|
|
760
|
+
* 富媒体消息挤成顺序错乱的两条。
|
|
761
|
+
*/
|
|
762
|
+
const replyMedia = async (fileType, source, { local = false, text = '' } = {}) => {
|
|
763
|
+
if (!source) return null;
|
|
764
|
+
await msg.flushReplies();
|
|
765
|
+
try {
|
|
766
|
+
const scene = kind === 'group' ? 'group' : 'c2c';
|
|
767
|
+
const target = kind === 'group' ? groupId : userId;
|
|
768
|
+
if (scene !== 'group' && scene !== 'c2c') return null;
|
|
769
|
+
|
|
770
|
+
/**
|
|
771
|
+
* 本地文件先看清两件事:**要不要降级**、**走直传还是分片**。
|
|
772
|
+
*
|
|
773
|
+
* ① 软限制(图 20MB / 视频 30MB / 语音 20MB)——官方语义是
|
|
774
|
+
* 「超过软限制降级为文件类型上传」,所以把 `file_type` 改成 4。
|
|
775
|
+
* ② 直传上限 5MB——**这才是 413 的真正来源**。一张 24.4MB 的图
|
|
776
|
+
* base64 之后 32.5MB,网关直接回 `413 Request Entity Too Large`。
|
|
777
|
+
* 所以超过 5MB 一律走分片(`uploadLocalFileSharded`),
|
|
778
|
+
* 降级判断照旧生效(分片时 file_type 也按软限算)。
|
|
779
|
+
*
|
|
780
|
+
* 硬限制 200MB 是官方的绝对上限,到了这里连分片也救不了。
|
|
781
|
+
*/
|
|
782
|
+
let effectiveType = fileType;
|
|
783
|
+
let size = 0;
|
|
784
|
+
if (local) {
|
|
785
|
+
const st = await fs.stat(source);
|
|
786
|
+
size = st.size;
|
|
787
|
+
if (size > MEDIA_HARD_LIMIT) {
|
|
788
|
+
throw new Error(
|
|
789
|
+
`${source} 有 ${(size / 1024 / 1024).toFixed(1)}MB,超过官方 200MB 硬限制`,
|
|
790
|
+
);
|
|
791
|
+
}
|
|
792
|
+
const soft = MEDIA_SOFT_LIMIT[fileType];
|
|
793
|
+
if (soft && size > soft) {
|
|
794
|
+
effectiveType = FILE_TYPE.FILE;
|
|
795
|
+
log.info(`媒体 ${(size / 1024 / 1024).toFixed(1)}MB 超过 ${Math.round(soft / 1024 / 1024)}MB 软限制,降级按文件发送`);
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
/**
|
|
800
|
+
* 三选一:
|
|
801
|
+
* 远程 URL → 平台自己去下载
|
|
802
|
+
* 本地小文件(≤5MB)→ 直传 base64
|
|
803
|
+
* 本地大文件(>5MB)→ 分片上传
|
|
804
|
+
*/
|
|
805
|
+
let up;
|
|
806
|
+
if (!local) {
|
|
807
|
+
up = await api.uploadMedia(scene, target, { fileType: effectiveType, url: source });
|
|
808
|
+
} else if (size > DIRECT_UPLOAD_LIMIT) {
|
|
809
|
+
log.info(`媒体 ${(size / 1024 / 1024).toFixed(1)}MB 超过直传上限,走分片上传`);
|
|
810
|
+
up = await api.uploadLocalFileSharded(scene, target, source, effectiveType, {
|
|
811
|
+
/**
|
|
812
|
+
* 这里必须包一层:`log` 是 `createLogger()` 返回的**对象**,
|
|
813
|
+
* 而 `uploadLocalFileSharded` 要的是「一个打印函数」。
|
|
814
|
+
* 直接传 `{ log }` 会在分片开始前炸 `logFn is not a function`
|
|
815
|
+
* (2026-10-30 实际踩过,prepare 成功了、图却没发出去)。
|
|
816
|
+
*/
|
|
817
|
+
log: (m) => log.info(m),
|
|
818
|
+
});
|
|
819
|
+
} else {
|
|
820
|
+
up = await api.uploadLocalFile(scene, target, source, effectiveType);
|
|
821
|
+
}
|
|
822
|
+
if (!up?.file_info) throw new Error('未返回 file_info');
|
|
823
|
+
|
|
824
|
+
const content = text ? String(text) : ' ';
|
|
825
|
+
return await send({
|
|
826
|
+
msgType: 7, content, media: { file_info: up.file_info },
|
|
827
|
+
/** 有效类型(可能因超过软限被降级成 FILE),账本占位按它来 */
|
|
828
|
+
fileType: effectiveType,
|
|
829
|
+
}, true);
|
|
830
|
+
} catch (e) {
|
|
831
|
+
log.error(`发送富媒体(type=${fileType}) 失败:`, e.message);
|
|
832
|
+
return null;
|
|
833
|
+
}
|
|
834
|
+
};
|
|
835
|
+
|
|
836
|
+
/**
|
|
837
|
+
* 发图片。`opts.text` 会作为这条富媒体消息的正文,和图同一条发出:
|
|
838
|
+
*
|
|
839
|
+
* await msg.replyImage(file, { local: true, text: '横图 2290×1200' });
|
|
840
|
+
*
|
|
841
|
+
* 不传 text 就只发图(老行为)。
|
|
842
|
+
*/
|
|
843
|
+
msg.replyImage = (src, opts) => replyMedia(FILE_TYPE.IMAGE, src, opts);
|
|
844
|
+
msg.replyVideo = (src, opts) => replyMedia(FILE_TYPE.VIDEO, src, opts);
|
|
845
|
+
msg.replyVoice = (src, opts) => replyMedia(FILE_TYPE.VOICE, src, opts);
|
|
846
|
+
msg.replyFile = (src, opts) => replyMedia(FILE_TYPE.FILE, src, opts);
|
|
847
|
+
|
|
848
|
+
/**
|
|
849
|
+
* 发一张内存里的图片(Buffer)。
|
|
850
|
+
*
|
|
851
|
+
* 渲染类插件用得上:画完直接发,不用先落盘再读回来,
|
|
852
|
+
* 也不用自己拼 base64 和 file_info。
|
|
853
|
+
*/
|
|
854
|
+
msg.replyImageBuffer = async (buf, { fileType = FILE_TYPE.IMAGE } = {}) => {
|
|
855
|
+
if (!buf?.length) return null;
|
|
856
|
+
await msg.flushReplies();
|
|
857
|
+
try {
|
|
858
|
+
const scene = kind === 'group' ? 'group' : 'c2c';
|
|
859
|
+
const target = kind === 'group' ? groupId : userId;
|
|
860
|
+
if (scene !== 'group' && scene !== 'c2c') return null;
|
|
861
|
+
|
|
862
|
+
const up = await api.uploadMedia(scene, target, {
|
|
863
|
+
fileType,
|
|
864
|
+
fileData: Buffer.from(buf).toString('base64'),
|
|
865
|
+
reuse: false, // base64 本来就不进缓存
|
|
866
|
+
});
|
|
867
|
+
if (!up?.file_info) throw new Error('未返回 file_info');
|
|
868
|
+
return await send({
|
|
869
|
+
msgType: 7, content: ' ', media: { file_info: up.file_info }, fileType,
|
|
870
|
+
}, true);
|
|
871
|
+
} catch (e) {
|
|
872
|
+
log.error('发送图片 Buffer 失败:', e.message);
|
|
873
|
+
return null;
|
|
874
|
+
}
|
|
875
|
+
};
|
|
876
|
+
|
|
877
|
+
/**
|
|
878
|
+
* 流式回复(**仅单聊**,官方没有群聊的流式接口)。
|
|
879
|
+
*
|
|
880
|
+
* 适合 AI 打字机效果:一边生成一边往下发,不用等整段生成完。
|
|
881
|
+
* 返回一个对象,用法:
|
|
882
|
+
*
|
|
883
|
+
* const s = msg.replyStream();
|
|
884
|
+
* await s.push('正在想'); // 第一片,index 自动递增
|
|
885
|
+
* await s.push('正在想……');
|
|
886
|
+
* await s.end('这是完整答案'); // 最后一片,input_state=10
|
|
887
|
+
*
|
|
888
|
+
* 内部自动维护 index 和 stream_msg_id,插件不用管。
|
|
889
|
+
* 走的是「replace」模式——每次传全量正文,服务端会算差量,
|
|
890
|
+
* 所以内容必须以上次已下发的前缀开头,不能中途改写前面的话。
|
|
891
|
+
*/
|
|
892
|
+
msg.replyStream = (opts = {}) => {
|
|
893
|
+
if (kind !== 'c2c') {
|
|
894
|
+
/**
|
|
895
|
+
* 群聊没有流式接口,退化成一条 markdown。
|
|
896
|
+
*
|
|
897
|
+
* 以前 push() 和 end() 各调一次 replyMarkdown,于是「分两段说」在群里
|
|
898
|
+
* 会发两条独立消息、账本记两笔;而流式契约里 end() 传的是**最终全量正文**,
|
|
899
|
+
* push() 只是中间态,真按两条发出去用户看到的是半截内容。
|
|
900
|
+
* 现在 push() 只缓存、end() 发一次最终全文,与单聊 end() 补记一笔的口径对齐。
|
|
901
|
+
* 调用方按契约必须调 end();只 push() 不 end() 时不再单独发一条。
|
|
902
|
+
*/
|
|
903
|
+
const pieces = [];
|
|
904
|
+
let ended = false;
|
|
905
|
+
let endResult = null;
|
|
906
|
+
return {
|
|
907
|
+
push: async (text) => {
|
|
908
|
+
const t = String(text ?? '');
|
|
909
|
+
if (t) pieces.push(t);
|
|
910
|
+
return null;
|
|
911
|
+
},
|
|
912
|
+
end: async (text) => {
|
|
913
|
+
if (ended) return endResult;
|
|
914
|
+
const final = String(text ?? '') || pieces.join('');
|
|
915
|
+
ended = true;
|
|
916
|
+
endResult = await msg.replyMarkdown({ content: final });
|
|
917
|
+
return endResult;
|
|
918
|
+
},
|
|
919
|
+
};
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
let index = 0;
|
|
923
|
+
/** end() 可能被重复调用,出站账本只记一笔 */
|
|
924
|
+
let recorded = false;
|
|
925
|
+
/** 重复 end() 也不该再打一次接口;成功后才置位,失败仍可重试 */
|
|
926
|
+
let ended = false;
|
|
927
|
+
let endResult = null;
|
|
928
|
+
let streamId = opts.streamMsgId || '';
|
|
929
|
+
const target = userId;
|
|
930
|
+
/**
|
|
931
|
+
* **一条流式消息只占一个 `msg_seq`**,所有分片共用。
|
|
932
|
+
*
|
|
933
|
+
* 这一步必须在首片之前做:流式接口不走 `#payload`,不主动占号的话
|
|
934
|
+
* 服务端按默认 1 算,而我们后续的普通回复也从 1 开始递增,
|
|
935
|
+
* 两边一撞就是 `40054005 消息被去重`(用户看到「发了但没出现」)。
|
|
936
|
+
*/
|
|
937
|
+
const seq = typeof api.takeSeq === 'function' ? api.takeSeq(msg.id) : 1;
|
|
938
|
+
|
|
939
|
+
return {
|
|
940
|
+
/** 发一片。返回服务端生成的 stream_msg_id(第一片才有) */
|
|
941
|
+
async push(contentRaw) {
|
|
942
|
+
const r = await api.sendStreamMessage(target, {
|
|
943
|
+
contentRaw,
|
|
944
|
+
index: index++,
|
|
945
|
+
state: 1,
|
|
946
|
+
mode: 'replace',
|
|
947
|
+
streamMsgId: streamId || undefined,
|
|
948
|
+
msgId: msg.id,
|
|
949
|
+
msgSeq: seq,
|
|
950
|
+
isWakeup: opts.isWakeup,
|
|
951
|
+
});
|
|
952
|
+
if (r?.id && !streamId) streamId = r.id;
|
|
953
|
+
return r;
|
|
954
|
+
},
|
|
955
|
+
/** 结束流。contentRaw 传最终全量正文 */
|
|
956
|
+
async end(contentRaw) {
|
|
957
|
+
/** 重复调用回首次结果:不打接口、不自增 index、不重复记账 */
|
|
958
|
+
if (ended) return endResult;
|
|
959
|
+
const r = await api.sendStreamMessage(target, {
|
|
960
|
+
contentRaw,
|
|
961
|
+
index: index++,
|
|
962
|
+
state: 10,
|
|
963
|
+
mode: 'replace',
|
|
964
|
+
streamMsgId: streamId || undefined,
|
|
965
|
+
msgId: msg.id,
|
|
966
|
+
msgSeq: seq,
|
|
967
|
+
isWakeup: opts.isWakeup,
|
|
968
|
+
});
|
|
969
|
+
/** 接口成功后才封口:发送失败时允许重试,别把重试永久挡死 */
|
|
970
|
+
ended = true;
|
|
971
|
+
endResult = r;
|
|
972
|
+
/**
|
|
973
|
+
* 流式回复直连 api,**不经过 outbound.send** —— 那边两个记账出口
|
|
974
|
+
* 都覆盖不到,单聊长回复会整条不进账本、「谁」列依旧是空的。
|
|
975
|
+
* 成功后补记一笔最终全文;recorded 防 end() 重复调用造成双记。
|
|
976
|
+
* 群聊在开头就退化成 replyMarkdown,那条路已经走 send(),不会到这。
|
|
977
|
+
*/
|
|
978
|
+
if (!recorded) {
|
|
979
|
+
recorded = true;
|
|
980
|
+
try {
|
|
981
|
+
outbound?.recordDirect?.({
|
|
982
|
+
kind,
|
|
983
|
+
target,
|
|
984
|
+
content: String(contentRaw ?? ''),
|
|
985
|
+
plugin: String(opts.plugin ?? msg.pluginId ?? ''),
|
|
986
|
+
id: String(r?.id ?? streamId ?? ''),
|
|
987
|
+
});
|
|
988
|
+
} catch (e) {
|
|
989
|
+
log.warn(`流式回复没记进账本(不影响发送):${e.message}`);
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
return r;
|
|
993
|
+
},
|
|
994
|
+
};
|
|
995
|
+
};
|
|
996
|
+
|
|
997
|
+
return msg;
|
|
998
|
+
}
|