qq-bot-roost 1.0.8 → 1.0.9

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
@@ -8,9 +8,9 @@
8
8
 
9
9
  <img src="https://img.shields.io/badge/Node.js-22.13%2B-339933?logo=node.js&logoColor=white" alt="Node.js 22.13+">
10
10
  <img src="https://img.shields.io/badge/dependencies-0-brightgreen" alt="零依赖">
11
- <img src="https://img.shields.io/badge/tests-813%20passed-brightgreen" alt="测试 813 项">
11
+ <img src="https://img.shields.io/badge/tests-826%20passed-brightgreen" alt="测试 826 项">
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-1.0.8-blueviolet" alt="版本 1.0.8">
13
+ <img src="https://img.shields.io/badge/version-1.0.9-blueviolet" alt="版本 1.0.9">
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": "1.0.8",
3
+ "version": "1.0.9",
4
4
  "type": "module",
5
5
  "description": "栖 Roost:零依赖的 QQ 官方机器人框架,填 AppID/AppSecret 即连,插件可写任意语言",
6
6
  "license": "SEE LICENSE IN LICENSE",
package/src/api.js CHANGED
@@ -147,6 +147,8 @@ export class QQApi {
147
147
  this.seqMap = new Map();
148
148
  // 群管内邀开关,由 BotRuntime 按配置同步;默认关
149
149
  this.groupInvited = false;
150
+ // 「发图带空格」兼容开关,由 BotRuntime 按 cfg.media.placeholderSpace 同步;默认关
151
+ this.mediaPlaceholderSpace = false;
150
152
  // 群角色查询缓存:groupOpenid -> { at, value }。bot_state 只有 30 QPM
151
153
  this.roleCache = new Map();
152
154
  /**
@@ -564,11 +566,15 @@ export class QQApi {
564
566
  const up = await this.uploadMedia(scene, target, { fileType, url, fileData });
565
567
  if (!up?.file_info) throw new Error('上传成功但未返回 file_info');
566
568
  /**
567
- * **不要 content。**
569
+ * **不要在这里传 content。**
568
570
  *
569
571
  * 官方字段表:`msg_type=7` 时生效的是 `media`,`content` 只在
570
572
  * `msg_type=0` 时是全文。以前这里填了一个空格占位,客户端会把
571
573
  * 它当成正文,渲染出一块空的文字区(图被挤到一侧)。
574
+ *
575
+ * 那个空格现在做成面板「配置 → 发图兼容」的可选开关(默认关):
576
+ * 打开后由 `#payload()` 在最终报文里补,这里依旧不带 content,
577
+ * 免得开关关着时又冒出空格、开着时补两遍。
572
578
  */
573
579
  const opts = {
574
580
  msgType: 7,
@@ -632,6 +638,19 @@ export class QQApi {
632
638
  return this.groupInvited;
633
639
  }
634
640
 
641
+ /**
642
+ * 「发图带空格」开关。由 BotRuntime 按 cfg.media.placeholderSpace 初始化,
643
+ * 面板改动时由 /api/config 同步进来,立即生效、不用重启。
644
+ *
645
+ * 只认布尔 true:`"false"` / `0` / `undefined` 一律当关。
646
+ * 面板配置走 HTTP,字符串 "false" 按真值判断会被读成「开」——
647
+ * security.trust.enforce 踩过这个坑,这里不重犯。
648
+ */
649
+ setMediaPlaceholderSpace(v) {
650
+ this.mediaPlaceholderSpace = v === true;
651
+ return this.mediaPlaceholderSpace;
652
+ }
653
+
635
654
  /** 内邀接口的统一闸门。没开门就抛错,别把请求发出去刷一屏 11253 */
636
655
  #needInvite() {
637
656
  if (!this.groupInvited) {
@@ -1260,7 +1279,24 @@ export class QQApi {
1260
1279
  }
1261
1280
 
1262
1281
  #payload(opts) {
1263
- return buildSendPayload(opts, (id) => this.#nextSeq(id));
1282
+ const payload = buildSendPayload(opts, (id) => this.#nextSeq(id));
1283
+ /**
1284
+ * 「发图带空格」兼容开关(默认关,面板「配置 → 发图兼容」)。
1285
+ *
1286
+ * 放在最终报文这一层,是为了**一处覆盖所有发图路径**:
1287
+ * msg.replyImage / replyImageBuffer、api.sendMedia、插件拿到的
1288
+ * ctx.api.sendMedia、以及没有调度器时的 api.sendGroup 兜底,最后都过这里。
1289
+ * 放在 outbound 或 message 层都会漏,还会污染投递账本的正文摘要
1290
+ * (账本把纯空格当「没有正文」,测试也断言富媒体不带 content)。
1291
+ *
1292
+ * 只补「没有正文的富媒体」:
1293
+ * · msg_type=7 且 content 为空 —— 老版就是在这里塞 ' '
1294
+ * · 有 text 的图文混排不动,markdown(2) / ark(3) / 卡片(8) 也不动
1295
+ */
1296
+ if (this.mediaPlaceholderSpace && payload.msg_type === 7 && !payload.content) {
1297
+ payload.content = ' ';
1298
+ }
1299
+ return payload;
1264
1300
  }
1265
1301
 
1266
1302
  /**
package/src/bot.js CHANGED
@@ -200,6 +200,9 @@ export class BotRuntime {
200
200
  // 群管内邀开关:api 层有自己的副本,配置变了由面板接口同步过来。
201
201
  // mock 的 api 也实现了同名方法,一起同步,方便本地验证插件的群管逻辑
202
202
  this.api.setGroupInvited?.(cfg.groupManage?.invited);
203
+ // 发图带空格:只影响真实 QQApi 的最终报文;mock api 不拼官方报文、
204
+ // 没实现这个 setter,?. 会直接跳过(演示模式下开关不适用)
205
+ this.api.setMediaPlaceholderSpace?.(cfg.media?.placeholderSpace);
203
206
 
204
207
  this.session = new SessionManager({
205
208
  stickyMs: Math.max(0, (cfg.session?.stickySec ?? 180) * 1000),
package/src/config.js CHANGED
@@ -335,6 +335,22 @@ const BOT_DEFAULTS = {
335
335
  groupManage: {
336
336
  invited: false,
337
337
  },
338
+
339
+ /**
340
+ * 富媒体(图片 / 视频 / 语音 / 文件)发送兼容选项。
341
+ *
342
+ * `placeholderSpace` 默认 false:只发一张图时,msg_type=7 的 content
343
+ * 保持空串,QQ 里是一张干净的图(v1.0.6 起的行为)。
344
+ * 打开后由 `QQApi.#payload()` 给没有正文的富媒体补一个空格,回到
345
+ * v1.0.6 之前的排版:客户端按图文混排渲染,图外面多一块空白文字区。
346
+ * 老客户端 / 旧插件依赖那个占位空格时才需要打开。
347
+ *
348
+ * 官方对非图片类型的 content 会忽略,所以实际只对图片有观感影响;
349
+ * 带说明文字的图片(replyImage 的 text)不受影响。
350
+ */
351
+ media: {
352
+ placeholderSpace: false,
353
+ },
338
354
  };
339
355
 
340
356
  function deepMerge(base, extra) {
@@ -529,6 +545,8 @@ export async function saveBotConfig(id, cfg) {
529
545
  gate: cfg.gate ?? { pauseOnStart: false },
530
546
  plugins: cfg.plugins,
531
547
  groupManage: cfg.groupManage ?? { invited: false },
548
+ // 脏值(字符串 / 数组 / 旧配置里混进来的东西)一律规范成默认关,别污染配置文件
549
+ media: { placeholderSpace: cfg.media?.placeholderSpace === true },
532
550
  });
533
551
  }
534
552
 
package/src/message.js CHANGED
@@ -848,6 +848,9 @@ export function buildMessage(eventName, d, { api, cfg, outbound, session, traceI
848
848
  * 理由是「官方不接受空 content」——那说的是传 `''`,跟不传不是一回事,
849
849
  * 结果客户端按图文混排渲染,图外面多出一圈文字区(v1.0.6 修掉)。
850
850
  *
851
+ * 面板「配置 → 发图兼容」把这个空格做成了可选开关:打开后由 api 层在
852
+ * 最终报文里补回占位空格(老客户端 / 旧插件需要时),这里依旧不带 content。
853
+ *
851
854
  * 发送前会先把攒着的文本 flush 掉,免得「缓冲中的回复」被这条
852
855
  * 富媒体消息挤成顺序错乱的两条。
853
856
  */
package/src/web/server.js CHANGED
@@ -1605,6 +1605,24 @@ export async function startWebServer({
1605
1605
  cfg.groupManage = { invited: Boolean(body.groupManage.invited) };
1606
1606
  rt.api?.setGroupInvited?.(cfg.groupManage.invited);
1607
1607
  }
1608
+ /**
1609
+ * 「发图带空格」兼容开关(默认关)。
1610
+ * 严格只认布尔 true:字符串 "false" 按真值判断会被当成「开」,
1611
+ * 和 security.trust.enforce 踩过的是同一个坑。
1612
+ * 同步给 api 实例,勾选后立即生效、不用重启。
1613
+ *
1614
+ * 还要求「对象 + 布尔字段」齐全才写:面板漏传字段、旧版前端、
1615
+ * 或者 body.media 是 [] / {} 时,不该把已经打开的开关静默关掉。
1616
+ */
1617
+ if (
1618
+ body.media &&
1619
+ typeof body.media === 'object' &&
1620
+ !Array.isArray(body.media) &&
1621
+ typeof body.media.placeholderSpace === 'boolean'
1622
+ ) {
1623
+ cfg.media = { placeholderSpace: body.media.placeholderSpace };
1624
+ rt.api?.setMediaPlaceholderSpace?.(cfg.media.placeholderSpace);
1625
+ }
1608
1626
 
1609
1627
  await saveBotConfig(rt.id, cfg);
1610
1628
  log.info('配置已通过面板更新');
package/web/app.js CHANGED
@@ -1070,6 +1070,8 @@ function fillForm(c) {
1070
1070
  f.logLevel.value = c.logLevel || 'info';
1071
1071
 
1072
1072
  f.groupInvited.checked = Boolean(c.groupManage?.invited);
1073
+ // 严格只认 true:老配置文件里没这个字段时保持关闭
1074
+ f.mediaPlaceholderSpace.checked = c.media?.placeholderSpace === true;
1073
1075
 
1074
1076
  /**
1075
1077
  * 插件来源校验开关。`pubkeySet: false` 时必须说清楚「开了也没用」——
@@ -1405,6 +1407,36 @@ document.addEventListener('click', async (e) => {
1405
1407
  }
1406
1408
  });
1407
1409
 
1410
+ /* ---------------- 发图兼容:勾选即生效 ---------------- */
1411
+
1412
+ /**
1413
+ * 「保存配置」按钮在表单顶部/底部,离这块设置很远,勾完还得滚过去点一下才生效。
1414
+ * 这里跟 `autoSaveIntents` 一个套路:change 时局部 PUT 只提交 media 一个字段,
1415
+ * 后端 `/api/config` 是合并语义,其它配置项不会被这次请求碰。
1416
+ * 不需要防抖——勾一下就是一次,且不会像 intents 那样触发重连。
1417
+ */
1418
+ $('#mediaPlaceholderSpace').addEventListener('change', async (e) => {
1419
+ const on = e.target.checked;
1420
+ const tip = $('#mediaSpaceMsg');
1421
+ if (tip) tip.textContent = '正在应用…';
1422
+ try {
1423
+ await api('/api/config', {
1424
+ method: 'PUT',
1425
+ body: JSON.stringify({ media: { placeholderSpace: on } }),
1426
+ });
1427
+ if (tip) {
1428
+ tip.textContent = on ? '已生效:发图会带空格' : '已生效:恢复干净图';
1429
+ setTimeout(() => {
1430
+ if (tip.textContent.startsWith('已生效')) tip.textContent = '';
1431
+ }, 4000);
1432
+ }
1433
+ } catch (err) {
1434
+ // 没保存成功就把勾选退回去,别让用户以为已经开了
1435
+ e.target.checked = !on;
1436
+ if (tip) tip.textContent = '应用失败: ' + err.message;
1437
+ }
1438
+ });
1439
+
1408
1440
  $('#cfgForm').addEventListener('submit', async (e) => {
1409
1441
  e.preventDefault();
1410
1442
  const f = e.target;
@@ -1444,6 +1476,10 @@ $('#cfgForm').addEventListener('submit', async (e) => {
1444
1476
  groupManage: {
1445
1477
  invited: f.groupInvited.checked,
1446
1478
  },
1479
+ // 发图兼容:和 groupManage 一样是每台机器人一份,勾选后立即生效
1480
+ media: {
1481
+ placeholderSpace: f.mediaPlaceholderSpace.checked,
1482
+ },
1447
1483
  // 全局设置(config/bot.json),和 webui 一样不写进机器人配置
1448
1484
  security: {
1449
1485
  trust: { enforce: f.trustEnforce.checked },
package/web/index.html CHANGED
@@ -517,6 +517,22 @@
517
517
  </label>
518
518
  </div>
519
519
 
520
+ <div class="box">
521
+ <div class="box-hd"><h3>发图兼容</h3><span class="hint">默认关闭</span></div>
522
+ <label class="switch">
523
+ <input type="checkbox" name="mediaPlaceholderSpace" id="mediaPlaceholderSpace">
524
+ 发图片时带一个空格(v1.0.6 之前的行为)
525
+ </label>
526
+ <div class="hint" id="mediaSpaceMsg" style="margin-top:4px"></div>
527
+ <div class="hint" style="margin-top:8px">
528
+ 只发一张图时,框架本来不带正文,QQ 里就是干净的一张图。打开后会在富媒体消息里
529
+ 补一个空格当正文,回到旧版排版:图片被挤向一侧、外面多出一块空白文字区。<br>
530
+ 可能有奇妙的作用(),<b>勾选后立即生效、不用点保存也不用重启</b>。
531
+ 带说明文字的图片(<code>replyImage(..., { text })</code>)不受影响;
532
+ 视频 / 语音 / 文件的 content 官方会忽略,勾上也没有副作用。
533
+ </div>
534
+ </div>
535
+
520
536
  <div class="box">
521
537
  <div class="box-hd"><h3>群管能力</h3><span class="hint">禁言 / 撤回已开放;群成员与踢人需官方白名单</span></div>
522
538
  <label class="switch">