qq-bot-roost 0.9.1 → 0.9.2

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 CHANGED
@@ -10,7 +10,7 @@
10
10
  <img src="https://img.shields.io/badge/dependencies-0-brightgreen" alt="零依赖">
11
11
  <img src="https://img.shields.io/badge/tests-700%2B%20passed-brightgreen" alt="测试 700+ 项">
12
12
  <img src="https://img.shields.io/badge/plugins-JS%20%7C%20Python%20%7C%20Go%20%7C%20any-blue" alt="多语言插件">
13
- <img src="https://img.shields.io/badge/version-0.9.1-blueviolet" alt="版本 0.9.1">
13
+ <img src="https://img.shields.io/badge/version-0.9.2-blueviolet" alt="版本 0.9.2">
14
14
  <img src="https://img.shields.io/badge/license-Non--Commercial-red" alt="禁止商用">
15
15
  <a href="https://github.com/tdcq9594/qq-bot-roost/actions"><img src="https://img.shields.io/github/actions/workflow/status/tdcq9594/qq-bot-roost/ci.yml?branch=master" alt="CI"></a>
16
16
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qq-bot-roost",
3
- "version": "0.9.1",
3
+ "version": "0.9.2",
4
4
  "type": "module",
5
5
  "description": "栖 Roost:零依赖的 QQ 官方机器人框架,填 AppID/AppSecret 即连,插件可写任意语言",
6
6
  "license": "SEE LICENSE IN LICENSE",
package/src/delivery.js CHANGED
@@ -267,5 +267,80 @@ export function createDeliveryLedger({ db, botId, log = () => {} }) {
267
267
  }
268
268
  }
269
269
 
270
- return { claim, settle, settleByMsgId, recoverPending, get, recent, stats };
270
+ /**
271
+ * 一条逻辑消息可能对应好几行。
272
+ *
273
+ * 为什么:被动回复超过 1800 字时,发送层会切成几块各发一次,
274
+ * 每块一个幂等键(`${base}#1`、`${base}#2`…,见 ai-chat 的 `sendReply`)。
275
+ * 这是必须的——共用一个 id 的话第 2 块起会被 `claim` 判成 `reuse` 直接丢掉。
276
+ *
277
+ * 但「这条消息到底送没送到」问的是**整条**,不是某一块。所以这里把
278
+ * base 和它的所有分块聚合成一个状态。
279
+ *
280
+ * 聚合规则按「坏消息优先」:只要有一块 `unknown`,整条就是 `unknown`
281
+ * (**绝不能重发**——重发会把已经出去的那块再发一遍);有一块 `failed`
282
+ * 就是 `failed`(可重试,重试只会补没发出去的那块,因为 id 是确定的);
283
+ * 还在飞就是 `pending`;等审核是 `audited`;全成功才是 `sent`。
284
+ *
285
+ * @param {string} requestId base id
286
+ * @returns {null | {state:string, parts:number, msgId:string, code:string,
287
+ * attempts:number, at:number, rows:object[]}}
288
+ */
289
+ function family(requestId) {
290
+ if (!ready()) return null;
291
+ const base = String(requestId ?? '').trim();
292
+ if (!base) return null;
293
+ /**
294
+ * `LIKE` 里的 `%` `_` 要转义,否则 base 里带这两个字符会误匹配到别人的行。
295
+ * requestId 是我们自己生成的(`qs-…` / `sp-…`),正常不会有,
296
+ * 但这里是公共入口,不能靠上游自觉。
297
+ */
298
+ const esc = base.replace(/[\\%_]/g, (m) => `\\${m}`);
299
+ try {
300
+ const rows = db
301
+ .prepare(`SELECT * FROM deliveries WHERE request_id = ? OR request_id LIKE ? ESCAPE '\\' ORDER BY request_id`)
302
+ .all(base, `${esc}#%`);
303
+ if (!rows.length) return null;
304
+
305
+ const rank = {
306
+ [DELIVERY_STATE.SENT]: 0,
307
+ [DELIVERY_STATE.AUDITED]: 1,
308
+ [DELIVERY_STATE.PENDING]: 2,
309
+ [DELIVERY_STATE.FAILED]: 3,
310
+ [DELIVERY_STATE.UNKNOWN]: 4,
311
+ };
312
+ /**
313
+ * 认不出的状态**不参与比较**,不能给它一个默认名次。
314
+ *
315
+ * 给 0(等于 sent)会让一条状态坏掉的行被当成已送达;
316
+ * 给 99(等于最坏)又会让它压过真正的 `unknown`,把「绝对不能重发」
317
+ * 这条信息淹掉。两种情况都会让人对着一个假答案往下走。
318
+ *
319
+ * 正常不会出现(`settle` 会校验状态),但账本是跨版本存活的,
320
+ * 老版本写过什么值谁也不敢保证。
321
+ */
322
+ const known = rows.filter((r) => r.state in rank);
323
+ const pool = known.length ? known : rows;
324
+ let worst = pool[0];
325
+ for (const r of pool) if (rank[r.state] > rank[worst.state]) worst = r;
326
+ if (!known.length) log(`投递账本里有认不出的状态:${rows.map((r) => r.state).join('、')}`);
327
+
328
+ /** 报最坏那块的 msg_id / code,人核对时想知道的是「哪块出问题了」 */
329
+ const at = rows.reduce((m, r) => Math.max(m, Number(r.settled_at || r.created_at || 0)), 0);
330
+ return {
331
+ state: worst.state,
332
+ parts: rows.length,
333
+ msgId: worst.msg_id || '',
334
+ code: worst.code || '',
335
+ attempts: rows.reduce((m, r) => Math.max(m, Number(r.attempts || 0)), 0),
336
+ at,
337
+ rows,
338
+ };
339
+ } catch (e) {
340
+ log(`投递账本家族查询失败:${e.message}`);
341
+ return null;
342
+ }
343
+ }
344
+
345
+ return { claim, settle, settleByMsgId, recoverPending, get, family, recent, stats };
271
346
  }
package/src/message.js CHANGED
@@ -549,11 +549,17 @@ export function buildMessage(eventName, d, { api, cfg, outbound, session }) {
549
549
  return send({ content: String(text), ...opts }, false);
550
550
  };
551
551
 
552
- /** Markdown 消息(可带按钮 keyboard)。Markdown 是独立消息类型,先把攒的文本发掉 */
553
- msg.replyMarkdown = async (markdown, keyboard) => {
552
+ /**
553
+ * Markdown 消息(可带按钮 keyboard)。Markdown 是独立消息类型,先把攒的文本发掉。
554
+ *
555
+ * `opts.requestId`:投递账本的幂等键。以前这条路完全绕开账本——
556
+ * 插件给了 id 也只对纯文本生效,markdown 那条永远记不上,重发就会重复。
557
+ * 第三个参数是可选的,老调用方不传,行为不变。
558
+ */
559
+ msg.replyMarkdown = async (markdown, keyboard, opts = {}) => {
554
560
  await msg.flushReplies();
555
561
  try {
556
- return await send({ markdown, keyboard }, true);
562
+ return await send({ markdown, keyboard, ...(opts?.requestId ? { requestId: opts.requestId } : {}) }, true);
557
563
  } catch (e) {
558
564
  /**
559
565
  * Markdown 是**独立消息类型**,没有纯文本兜底:平台不放行(没权限、
@@ -581,7 +587,13 @@ export function buildMessage(eventName, d, { api, cfg, outbound, session }) {
581
587
  * 按钮里的指令本来也会出现在正文里(ctx.menu 渲染时带上),信息不丢。
582
588
  */
583
589
  log.warn(`Markdown 被拒(${e.message}),降级为纯文本重发`);
584
- return send({ content: plain }, true).catch((e2) => {
590
+ /**
591
+ * 降级重发**沿用同一个 requestId**:对用户来说这仍然是「一条消息」,
592
+ * 账本里就该只有一行。上一步抛错时已经结算成 `failed`(可重试),
593
+ * 所以这里 claim 会放行并复用同一行,最终状态是 `sent`。
594
+ * 万一是 `unknown`,claim 会抛错——那正是我们要的「不重复发」。
595
+ */
596
+ return send({ content: plain, ...(opts?.requestId ? { requestId: opts.requestId } : {}) }, true).catch((e2) => {
585
597
  log.error('降级纯文本也没发出去:', e2.message);
586
598
  return null;
587
599
  });