qq-bot-roost 0.9.5 → 0.9.7

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.5-blueviolet" alt="版本 0.9.5">
13
+ <img src="https://img.shields.io/badge/version-0.9.7-blueviolet" alt="版本 0.9.7">
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
 
@@ -474,7 +474,7 @@ openid 完全不同。共享 `roles.json` 等于所有权限记录全部对不
474
474
  ## 开发
475
475
 
476
476
  ```bash
477
- npm test # 609 项(selftest 590 + qrlogin 8 + deps 11)
477
+ npm test # 636 项(selftest 617 + qrlogin 8 + deps 11)
478
478
  npm run test:e2e # 端到端 168 项(起 mock 服务打全部接口,跑在临时目录)
479
479
  npm run test:all # 全量约 618 项:自检 + 扫码 + 依赖 + e2e(真机驱动要真机,没配就跳过)
480
480
  npm run check:secrets # 扫硬编码凭据(CI 也跑这个,提交前建议过一遍)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qq-bot-roost",
3
- "version": "0.9.5",
3
+ "version": "0.9.7",
4
4
  "type": "module",
5
5
  "description": "栖 Roost:零依赖的 QQ 官方机器人框架,填 AppID/AppSecret 即连,插件可写任意语言",
6
6
  "license": "SEE LICENSE IN LICENSE",
package/src/api.js CHANGED
@@ -1,3 +1,4 @@
1
+ import crypto from 'node:crypto';
1
2
  import fs from 'node:fs/promises';
2
3
  import { createLogger } from './logger.js';
3
4
  /** 路径里的 id 一律编码:openid / 群号是外部数据,含 / 会拼出别的路径 */
@@ -11,10 +12,10 @@ export const FILE_TYPE = { IMAGE: 1, VIDEO: 2, VOICE: 3, FILE: 4 };
11
12
 
12
13
  /**
13
14
  * 富媒体软限制(字节)。官方口径:图片 20MB / 视频 30MB / 语音 20MB / 文件 200MB;
14
- * 超软限会被服务端拒(普通 base64 上传接口直接 413)。
15
+ * 超软限**降级为文件**(file_type=4),超硬限制(200MB)直接报错。
15
16
  *
16
- * `message.js` 的 `replyMedia` 用它做「图片超 20MB 自动降级为文件」;
17
- * `uploadLocalFile` 拿它当最后一道兜底(防直接调它传超大文件)。
17
+ * `message.js` 的 `replyMedia` 用它算降级后的 file_type;
18
+ * `uploadLocalFile` 拿它当最后一道兜底。
18
19
  */
19
20
  export const MEDIA_SOFT_LIMIT = {
20
21
  [FILE_TYPE.IMAGE]: 20 * 1024 * 1024,
@@ -23,6 +24,27 @@ export const MEDIA_SOFT_LIMIT = {
23
24
  [FILE_TYPE.FILE]: 200 * 1024 * 1024,
24
25
  };
25
26
 
27
+ /** 所有富媒体类型的硬上限。超过它官方直接报 850031,连分片都没用 */
28
+ export const MEDIA_HARD_LIMIT = 200 * 1024 * 1024;
29
+
30
+ /**
31
+ * 二进制直传(`file_data`)的上限:**5MB**。
32
+ *
33
+ * 官方原文:「直接上传二进制数据……数据大小不能超过 5M,超过建议使用分片上传」。
34
+ * 实测网关对请求体的容忍比 5MB 大一些(约 10MB),但那是运气不是承诺——
35
+ * 一张 24.4MB 的图 base64 后 32.5MB,网关直接回 `413 Request Entity Too Large`
36
+ * (stgw 的 HTML 报错,连 QQ 的业务错误码都没有)。
37
+ *
38
+ * 所以超过这个值一律走分片上传,别再赌网关的脾气。
39
+ */
40
+ export const DIRECT_UPLOAD_LIMIT = 5 * 1024 * 1024;
41
+
42
+ /**
43
+ * `md5_10m` 取的字节数:官方写死 10002432(约 9.54MB),不是整数 MB。
44
+ * 这个值用于「秒传判断」,传错了服务端不会报错、只是秒传不生效。
45
+ */
46
+ export const MD5_10M_SIZE = 10_002_432;
47
+
26
48
  /**
27
49
  * 被动回复序号的**最后一道**兜底上限。
28
50
  *
@@ -230,12 +252,22 @@ export class QQApi {
230
252
 
231
253
  /**
232
254
  * 上传富媒体。
255
+ *
256
+ * 三种路径,对应官方 `/files` 的三个入参:
257
+ * · `url` —— URL 上传(平台自己去下载转存)
258
+ * · `fileData` —— 二进制直传(base64,**5MB 以内**,见 `DIRECT_UPLOAD_LIMIT`)
259
+ * · `uploadId` —— 分片上传的合并(分片已由 `uploadLocalFileSharded` 传完)
260
+ *
233
261
  * @param {'group'|'c2c'} scene
234
262
  * @param {string} target group_openid 或 user openid
235
- * @param {object} opts { fileType, url?, fileData?(base64) }
263
+ * @param {object} opts { fileType, url?, fileData?(base64), uploadId?, fileName? }
236
264
  * @returns {Promise<{file_uuid:string, file_info:string, ttl:number}>}
237
265
  */
238
- async uploadMedia(scene, target, { fileType = FILE_TYPE.IMAGE, url, fileData, reuse = true } = {}) {
266
+ async uploadMedia(
267
+ scene,
268
+ target,
269
+ { fileType = FILE_TYPE.IMAGE, url, fileData, uploadId, fileName, reuse = true } = {},
270
+ ) {
239
271
  // 只有按 url 传的才缓存:base64 数据做键得先算哈希,不值当
240
272
  const cacheKey = reuse && url ? `${scene}:${enc(target)}:${fileType}:${url}` : null;
241
273
  if (cacheKey) {
@@ -252,6 +284,14 @@ export class QQApi {
252
284
  const body = { file_type: fileType, srv_send_msg: false };
253
285
  if (url) body.url = url;
254
286
  if (fileData) body.file_data = fileData;
287
+ /**
288
+ * 分片合并:`upload_id` 一进,服务端就走「合并已上传分片」那条路,
289
+ * `url` 可以为空。但 `file_name` 这条路径**必填**(官方字段说明)。
290
+ */
291
+ if (uploadId) {
292
+ body.upload_id = uploadId;
293
+ if (fileName) body.file_name = fileName;
294
+ }
255
295
  const r = await this.#request('POST', path, body);
256
296
 
257
297
  if (cacheKey && r?.file_info) {
@@ -266,26 +306,29 @@ export class QQApi {
266
306
  }
267
307
 
268
308
  /**
269
- * 从本地文件读取并上传(自动转 base64)。
309
+ * 从本地文件读取并上传(**直传**,base64 整包)。
310
+ *
311
+ * 只适合小文件:`file_data` 官方限 5MB(`DIRECT_UPLOAD_LIMIT`),
312
+ * 超过它请用 `uploadLocalFileSharded`——这里不偷偷分片,
313
+ * 因为「直传」这个方法的语义就是一次请求,隐式换成十几次请求会让调用方
314
+ * 对耗时和失败几率的预期全错。
270
315
  *
271
316
  * 带缓存:同一个文件(路径 + 大小 + 修改时间都一样)在 TTL 内不重复上传。
272
- * 图片缓存类插件(本地存几百张图,用户点一次发一张)没有这层的话,
273
- * 每发一次都要把整张图 base64 传一遍,慢且费额度。
274
317
  */
275
318
  async uploadLocalFile(scene, target, filePath, fileType = FILE_TYPE.IMAGE) {
276
319
  let cacheKey = null;
277
320
  try {
278
321
  const st = await fs.stat(filePath);
279
322
  /**
280
- * 软限制前置校验:普通 base64 上传接口对富媒体有大小上限,
281
- * 超了服务端直接 413。在这里先挡一道,报一个能看懂的错误,
282
- * 别让调用方拿到「HTTP 413 ... <html>」这种垃圾信息。
323
+ * 直传上限前置校验:超过 5MB 的 base64 请求体,网关会直接回
324
+ * `413 Request Entity Too Large`(HTML,连业务错误码都没有)。
325
+ * 在这里挡一道,报一个能看懂的错误。
283
326
  */
284
- const limit = MEDIA_SOFT_LIMIT[fileType];
285
- if (limit && st.size > limit) {
327
+ if (st.size > DIRECT_UPLOAD_LIMIT) {
286
328
  const mb = (st.size / 1024 / 1024).toFixed(1);
287
- const cap = Math.round(limit / 1024 / 1024);
288
- throw new Error(`${filePath} 有 ${mb}MB,超过 ${cap}MB 上限,发不出去(先压缩或换张小图)`);
329
+ throw new Error(
330
+ `${filePath} 有 ${mb}MB,超过直传上限 ${DIRECT_UPLOAD_LIMIT / 1024 / 1024}MB,请改用 uploadLocalFileSharded`,
331
+ );
289
332
  }
290
333
  cacheKey = `${scene}:${enc(target)}:${fileType}:file:${filePath}:${st.size}:${Math.floor(st.mtimeMs)}`;
291
334
  const hit = this.mediaCache.get(cacheKey);
@@ -297,7 +340,7 @@ export class QQApi {
297
340
  * 不能当缓存命中,继续往下走让 `readFile` 抛真正的错误;
298
341
  * 超限是**明确拒绝**,必须直接把错误往上抛,不能悄悄读文件再传一遍。
299
342
  */
300
- if (/上限/.test(e?.message ?? '')) throw e;
343
+ if (/直传上限/.test(e?.message ?? '')) throw e;
301
344
  /* stat 失败就按无缓存走,readFile 会抛真正的错误 */
302
345
  }
303
346
 
@@ -317,6 +360,176 @@ export class QQApi {
317
360
  return up;
318
361
  }
319
362
 
363
+ /**
364
+ * PUT 一个分片到预签名 URL。**裸 fetch,不带 QQ 的任何头。**
365
+ *
366
+ * 为什么不能复用 `#request`:那个方法会拼 `baseUrl + path`(预签名是绝对
367
+ * URL)、强塞 `Content-Type: application/json`(分片是二进制)、强带
368
+ * `Authorization: QQBot …`(预签名 URL 自带签名,多带鉴权头反而会被
369
+ * COS 拒)、超时写死 15 秒(5MB 分片在慢网下要更久)。
370
+ *
371
+ * @param {string} presignedUrl 预签名上传地址
372
+ * @param {Buffer} buf 分片二进制
373
+ * @param {number} [timeoutMs]
374
+ */
375
+ async #putPart(presignedUrl, buf, timeoutMs = 60_000) {
376
+ let res;
377
+ try {
378
+ res = await fetch(presignedUrl, {
379
+ method: 'PUT',
380
+ signal: AbortSignal.timeout(timeoutMs),
381
+ headers: { 'Content-Length': String(buf.length) },
382
+ body: buf,
383
+ });
384
+ } catch (e) {
385
+ const reason = e?.name === 'TimeoutError' ? `超时(${Math.round(timeoutMs / 1000)}s)` : e.message;
386
+ throw new Error(`分片 PUT 网络错误: ${reason}`);
387
+ }
388
+ if (!res.ok) {
389
+ const detail = await res.text().catch(() => '');
390
+ throw new Error(`分片 PUT 失败 HTTP ${res.status}${detail ? `:${detail.slice(0, 200)}` : ''}`);
391
+ }
392
+ }
393
+
394
+ /**
395
+ * 大文件分片上传(官方推荐路径)。
396
+ *
397
+ * 四步(官方文档《富媒体消息概述》):
398
+ * ① `upload_prepare` → upload_id / block_size / parts[] / upload_config
399
+ * ② 逐片 `PUT` 到 `parts[i].presigned_url`
400
+ * ③ 每片成功后 `upload_part_finish` 确认
401
+ * ④ 带 `upload_id` 调 `/files` 合并 → `file_info`
402
+ *
403
+ * 返回值与 `uploadLocalFile` 同形状(`{file_uuid, file_info, ttl}`),
404
+ * 调用方不必分叉。
405
+ *
406
+ * **分片顺序不能乱**:`parts` 按 `index` 排序后逐片上传;并发数听
407
+ * `upload_config.concurrency`(官方默认 1),我们上限 4——再高对
408
+ * 5MB 一片的 COS PUT 没什么收益,反而容易触发服务端侧限流。
409
+ *
410
+ * @param {'group'|'c2c'} scene
411
+ * @param {string} target
412
+ * @param {string} filePath 本地文件
413
+ * @param {number} fileType
414
+ * @param {object} [opts]
415
+ * @param {(m:string)=>void} [opts.log]
416
+ * @returns {Promise<{file_uuid:string, file_info:string, ttl:number}>}
417
+ */
418
+ async uploadLocalFileSharded(scene, target, filePath, fileType = FILE_TYPE.FILE, opts = {}) {
419
+ /**
420
+ * `opts.log` 可能是**函数**,也可能是 `createLogger()` 那种**对象**(有 .info)。
421
+ * 统一成函数再往下用——本机就因为这个炸过 `logFn is not a function`:
422
+ * prepare 已经成功、图却一张没发出去,日志只有一行看不懂的类型错误。
423
+ */
424
+ const rawLog = opts.log;
425
+ const logFn =
426
+ typeof rawLog === 'function'
427
+ ? rawLog
428
+ : typeof rawLog?.info === 'function'
429
+ ? (m) => rawLog.info(m)
430
+ : () => {};
431
+
432
+ const buf = await fs.readFile(filePath);
433
+ if (buf.length > MEDIA_HARD_LIMIT) {
434
+ const mb = (buf.length / 1024 / 1024).toFixed(1);
435
+ throw new Error(`${filePath} 有 ${mb}MB,超过官方硬限制 200MB,分片也发不出去`);
436
+ }
437
+
438
+ const fileName = filePath.split(/[\\/]/).pop() || 'file';
439
+ /**
440
+ * 三个校验值官方都要:
441
+ * · `md5` / `sha1` 是整个文件的
442
+ * · `md5_10m` 是**前 10002432 字节**的(不是 10MB,官方写死的数字)
443
+ * 算错了服务端不报错,只是秒传判断失效,所以照官方数字来。
444
+ */
445
+ const md5 = crypto.createHash('md5').update(buf).digest('hex');
446
+ const sha1 = crypto.createHash('sha1').update(buf).digest('hex');
447
+ const md5_10m = crypto.createHash('md5').update(buf.subarray(0, MD5_10M_SIZE)).digest('hex');
448
+
449
+ const prep = await this.uploadPrepare(scene, target, {
450
+ fileType,
451
+ fileSize: buf.length,
452
+ fileName,
453
+ md5,
454
+ sha1,
455
+ md5_10m,
456
+ });
457
+ const uploadId = prep?.upload_id;
458
+ const parts = Array.isArray(prep?.parts) ? prep.parts.slice() : [];
459
+ if (!uploadId || !parts.length) {
460
+ throw new Error(`upload_prepare 没返回可用的 upload_id / parts(${JSON.stringify(prep).slice(0, 200)})`);
461
+ }
462
+ parts.sort((a, b) => Number(a.index) - Number(b.index));
463
+
464
+ /**
465
+ * 并发数:听服务端的,但不超 4。
466
+ * `retry_timeout` / `retry_delay` 也是服务端下发的,用于 40093001 重试。
467
+ */
468
+ const conf = prep.upload_config ?? {};
469
+ const concurrency = Math.max(1, Math.min(Number(conf.concurrency) || 1, 4));
470
+ const retryTimeoutMs = Math.max(1, Number(conf.retry_timeout) || 300) * 1000;
471
+ const retryDelayMs = Math.max(0, Number(conf.retry_delay) || 1) * 1000;
472
+ logFn(`${fileName} ${(buf.length / 1024 / 1024).toFixed(1)}MB · ${parts.length} 片 · 并发 ${concurrency}`);
473
+
474
+ /**
475
+ * 每片的字节区间。**按排序后的顺序累加偏移**,不靠 `index` 当数组下标——
476
+ * `index` 是服务端给的序号,正常情况下就是 0,1,2…,但拿它直接当
477
+ * `parts[index]` 用,一旦服务端给的序号不连续就会切错片(内容错位、
478
+ * 合并出来的文件是坏的,而服务端不会报错)。
479
+ */
480
+ const offsets = [];
481
+ let acc = 0;
482
+ for (const p of parts) {
483
+ const size = Number(p.block_size) || 0;
484
+ offsets.push({ part: p, start: acc, end: size ? acc + size : buf.length });
485
+ acc += size;
486
+ }
487
+
488
+ /**
489
+ * 单片的「PUT + 确认」,带 40093001 重试。
490
+ *
491
+ * `40093001`(分片转存 BDH 通道异常)是**明确可重试**的,官方给了
492
+ * `retry_timeout`;`40093002`(日配额上限)重试一万次也没用,必须
493
+ * 直接抛出去让上层告诉用户「今天发不了了」。
494
+ */
495
+ const uploadOne = async ({ part, start, end }) => {
496
+ const idx = Number(part.index);
497
+ const chunk = buf.subarray(start, end);
498
+ const chunkMd5 = crypto.createHash('md5').update(chunk).digest('hex');
499
+
500
+ let lastErr = null;
501
+ const deadline = Date.now() + retryTimeoutMs;
502
+ while (Date.now() < deadline) {
503
+ try {
504
+ await this.#putPart(part.presigned_url, chunk);
505
+ await this.uploadPartFinish(scene, target, {
506
+ uploadId,
507
+ partIndex: idx,
508
+ blockSize: chunk.length,
509
+ md5: chunkMd5,
510
+ });
511
+ return;
512
+ } catch (e) {
513
+ lastErr = e;
514
+ if (Number(e?.qqCode) === 40093002) throw e; // 日配额,重试无用
515
+ await new Promise((r) => setTimeout(r, retryDelayMs));
516
+ }
517
+ }
518
+ throw new Error(`分片 ${idx} 上传失败(重试到 retry_timeout 仍不行):${lastErr?.message ?? '未知'}`);
519
+ };
520
+
521
+ /**
522
+ * 按 concurrency 分批并发。用「一批一批」而不是「维持 N 个在飞」:
523
+ * 片数通常 3~10,分批足够,代码简单且失败位置好定位。
524
+ */
525
+ for (let i = 0; i < offsets.length; i += concurrency) {
526
+ await Promise.all(offsets.slice(i, i + concurrency).map(uploadOne));
527
+ }
528
+
529
+ // ④ 合并
530
+ return this.uploadMedia(scene, target, { fileType, uploadId, fileName, reuse: false });
531
+ }
532
+
320
533
  /** 上传后再以 msg_type=7 发出(被动回复) */
321
534
  async sendMedia(scene, target, { fileType, url, fileData, msgId, messageReference }) {
322
535
  const up = await this.uploadMedia(scene, target, { fileType, url, fileData });
package/src/message.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import { createLogger } from './logger.js';
3
- import { FILE_TYPE, MEDIA_SOFT_LIMIT } from './api.js';
3
+ import { FILE_TYPE, MEDIA_SOFT_LIMIT, MEDIA_HARD_LIMIT, DIRECT_UPLOAD_LIMIT } from './api.js';
4
4
  import { messageKindOf, EVENTS } from './events.js';
5
5
  import { renderMenuMd, mdToPlain } from './markdown.js';
6
6
 
@@ -672,29 +672,57 @@ export function buildMessage(eventName, d, { api, cfg, outbound, session }) {
672
672
  if (scene !== 'group' && scene !== 'c2c') return null;
673
673
 
674
674
  /**
675
- * 图片超软限(20MB)自动降级为文件。
675
+ * 本地文件先看清两件事:**要不要降级**、**走直传还是分片**。
676
676
  *
677
- * 普通 base64 上传接口对图片有 20MB 上限,超了服务端直接 413。
678
- * 但文件(file_type=4)上限是 200MB,所以本地大图不该直接报错——
679
- * 改成按「文件」发出去,用户收到的是文件卡片(可下载查看)。
680
- * 官方「超软限降级为文件」的语义,落到这里实现。
677
+ * ① 软限制(图 20MB / 视频 30MB / 语音 20MB)——官方语义是
678
+ * 「超过软限制降级为文件类型上传」,所以把 `file_type` 改成 4。
679
+ * ② 直传上限 5MB——**这才是 413 的真正来源**。一张 24.4MB 的图
680
+ * base64 之后 32.5MB,网关直接回 `413 Request Entity Too Large`。
681
+ * 所以超过 5MB 一律走分片(`uploadLocalFileSharded`),
682
+ * 降级判断照旧生效(分片时 file_type 也按软限算)。
683
+ *
684
+ * 硬限制 200MB 是官方的绝对上限,到了这里连分片也救不了。
681
685
  */
682
686
  let effectiveType = fileType;
683
- if (local && fileType === FILE_TYPE.IMAGE) {
684
- try {
685
- const st = await fs.stat(source);
686
- if (st.size > MEDIA_SOFT_LIMIT[FILE_TYPE.IMAGE]) {
687
- effectiveType = FILE_TYPE.FILE;
688
- log.info(`图片 ${(st.size / 1024 / 1024).toFixed(1)}MB 超过 20MB 软限制,降级按文件发送`);
689
- }
690
- } catch {
691
- /* stat 失败按原类型走,让 uploadLocalFile 抛真正的错误 */
687
+ let size = 0;
688
+ if (local) {
689
+ const st = await fs.stat(source);
690
+ size = st.size;
691
+ if (size > MEDIA_HARD_LIMIT) {
692
+ throw new Error(
693
+ `${source} 有 ${(size / 1024 / 1024).toFixed(1)}MB,超过官方 200MB 硬限制`,
694
+ );
695
+ }
696
+ const soft = MEDIA_SOFT_LIMIT[fileType];
697
+ if (soft && size > soft) {
698
+ effectiveType = FILE_TYPE.FILE;
699
+ log.info(`媒体 ${(size / 1024 / 1024).toFixed(1)}MB 超过 ${Math.round(soft / 1024 / 1024)}MB 软限制,降级按文件发送`);
692
700
  }
693
701
  }
694
702
 
695
- const up = local
696
- ? await api.uploadLocalFile(scene, target, source, effectiveType)
697
- : await api.uploadMedia(scene, target, { fileType: effectiveType, url: source });
703
+ /**
704
+ * 三选一:
705
+ * 远程 URL → 平台自己去下载
706
+ * 本地小文件(≤5MB)→ 直传 base64
707
+ * 本地大文件(>5MB)→ 分片上传
708
+ */
709
+ let up;
710
+ if (!local) {
711
+ up = await api.uploadMedia(scene, target, { fileType: effectiveType, url: source });
712
+ } else if (size > DIRECT_UPLOAD_LIMIT) {
713
+ log.info(`媒体 ${(size / 1024 / 1024).toFixed(1)}MB 超过直传上限,走分片上传`);
714
+ up = await api.uploadLocalFileSharded(scene, target, source, effectiveType, {
715
+ /**
716
+ * 这里必须包一层:`log` 是 `createLogger()` 返回的**对象**,
717
+ * 而 `uploadLocalFileSharded` 要的是「一个打印函数」。
718
+ * 直接传 `{ log }` 会在分片开始前炸 `logFn is not a function`
719
+ * (2026-10-30 实际踩过,prepare 成功了、图却没发出去)。
720
+ */
721
+ log: (m) => log.info(m),
722
+ });
723
+ } else {
724
+ up = await api.uploadLocalFile(scene, target, source, effectiveType);
725
+ }
698
726
  if (!up?.file_info) throw new Error('未返回 file_info');
699
727
 
700
728
  const content = text ? String(text) : ' ';
package/src/outbound.js CHANGED
@@ -211,6 +211,34 @@ class QuotaLedger {
211
211
  }
212
212
  }
213
213
 
214
+ /**
215
+ * 从出站报文里挑出「人看得懂的那部分」,喂给 `digestOf`。
216
+ *
217
+ * 报文不止一种形状,只认顶层 `content` 会漏掉一大半:
218
+ *
219
+ * · 纯文本 `{ content }`
220
+ * · markdown `{ markdown: { content } }` ← ai-chat 默认就走这条
221
+ * · 卡片 `{ markdown, keyboard, ark }`
222
+ * · 富媒体 `{ msgType: 7, media }`,正文只有一个占位空格
223
+ *
224
+ * 漏掉的后果不是发不出去(账本的状态机不看摘要),而是摘要列全变成
225
+ * `…(0)#da39a3ee`——所有记录长得一模一样,「人工核对」这条路等于堵死,
226
+ * 真出现 `unknown` 的时候只能靠猜。
227
+ */
228
+ export function digestTextOf(payload) {
229
+ const p = payload ?? {};
230
+ const md = p.markdown;
231
+ const text = p.content ?? p.text ?? (typeof md === 'string' ? md : md?.content);
232
+ if (typeof text === 'string' && text.trim()) return text;
233
+ /**
234
+ * 没有正文的报文给个占位。一串空摘要里冒出 `[媒体]` / `[卡片]`,
235
+ * 至少能分辨「这条本来就没有文字」,而不是「摘要功能又坏了」。
236
+ */
237
+ if (p.media) return '[媒体]';
238
+ if (p.ark) return '[卡片]';
239
+ return '';
240
+ }
241
+
214
242
  export class Outbound {
215
243
  /**
216
244
  * @param {object} opts
@@ -296,7 +324,7 @@ export class Outbound {
296
324
  chatId: opts.target,
297
325
  replyTo: opts.payload?.msgId,
298
326
  msgSeq: opts.payload?.msgSeq,
299
- digest: digestOf(opts.payload?.content ?? opts.payload?.text ?? ''),
327
+ digest: digestOf(digestTextOf(opts.payload)),
300
328
  });
301
329
 
302
330
  if (c.action === 'reuse') {
package/web/app.js CHANGED
@@ -3095,11 +3095,17 @@ function renderDelivery(d) {
3095
3095
  const st = DELIVERY_LABEL[x.state] ?? { text: x.state, tip: '' };
3096
3096
  const when = x.created_at ? new Date(Number(x.created_at)).toLocaleString('zh-CN', { hour12: false }) : '-';
3097
3097
  const warn = x.state === 'unknown' || x.state === 'failed';
3098
+ /**
3099
+ * 摘要空的老记录(修 `digestTextOf` 之前发的)一律是 `…(0)#da39a3ee`,
3100
+ * 一列看下来完全分不清谁是谁。显示成一句人话,原文挂在 title 上备查。
3101
+ */
3102
+ const digest = String(x.digest ?? '');
3103
+ const digestText = /^…\(0\)#/.test(digest) ? '(无正文)' : digest.slice(0, 60);
3098
3104
  return `<tr>
3099
3105
  <td>${escapeHtml(when)}</td>
3100
3106
  <td class="mono">${escapeHtml(String(x.chat_id ?? '').slice(0, 12))}</td>
3101
3107
  <td${warn ? ' style="color:var(--warn-text)"' : ''} title="${escapeHtml(st.tip)}">${escapeHtml(st.text)}</td>
3102
- <td>${escapeHtml(String(x.digest ?? '').slice(0, 60))}</td>
3108
+ <td title="${escapeHtml(digest)}">${escapeHtml(digestText)}</td>
3103
3109
  <td class="num">${Number(x.attempts ?? 0)}</td>
3104
3110
  <td class="mono">${escapeHtml(String(x.code ?? ''))}</td>
3105
3111
  </tr>`;