@zaofan/dsh-qqbot 1.1.0 → 1.1.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.
Files changed (96) hide show
  1. package/client/qqbot-settings.js +571 -52
  2. package/dist/api/group-admin.d.ts +22 -0
  3. package/dist/api/group-admin.d.ts.map +1 -1
  4. package/dist/api/group-admin.js +32 -0
  5. package/dist/api/group-admin.js.map +1 -1
  6. package/dist/channel-tools.d.ts.map +1 -1
  7. package/dist/channel-tools.js +192 -77
  8. package/dist/channel-tools.js.map +1 -1
  9. package/dist/commands/botplay.d.ts.map +1 -1
  10. package/dist/commands/botplay.js +6 -2
  11. package/dist/commands/botplay.js.map +1 -1
  12. package/dist/commands/index.d.ts.map +1 -1
  13. package/dist/commands/index.js +6 -2
  14. package/dist/commands/index.js.map +1 -1
  15. package/dist/commands/inject.d.ts +17 -0
  16. package/dist/commands/inject.d.ts.map +1 -0
  17. package/dist/commands/inject.js +70 -0
  18. package/dist/commands/inject.js.map +1 -0
  19. package/dist/commands/misc.d.ts +1 -1
  20. package/dist/commands/misc.d.ts.map +1 -1
  21. package/dist/commands/misc.js +25 -3
  22. package/dist/commands/misc.js.map +1 -1
  23. package/dist/commands/session.d.ts +2 -0
  24. package/dist/commands/session.d.ts.map +1 -1
  25. package/dist/commands/session.js +41 -3
  26. package/dist/commands/session.js.map +1 -1
  27. package/dist/config.d.ts +6 -0
  28. package/dist/config.d.ts.map +1 -1
  29. package/dist/config.js +2 -1
  30. package/dist/config.js.map +1 -1
  31. package/dist/features/botplay-store.d.ts +13 -0
  32. package/dist/features/botplay-store.d.ts.map +1 -0
  33. package/dist/features/botplay-store.js +99 -0
  34. package/dist/features/botplay-store.js.map +1 -0
  35. package/dist/features/botplay.d.ts +8 -0
  36. package/dist/features/botplay.d.ts.map +1 -1
  37. package/dist/features/botplay.js +22 -7
  38. package/dist/features/botplay.js.map +1 -1
  39. package/dist/features/broadcast.d.ts +63 -0
  40. package/dist/features/broadcast.d.ts.map +1 -0
  41. package/dist/features/broadcast.js +225 -0
  42. package/dist/features/broadcast.js.map +1 -0
  43. package/dist/features/card-callback.d.ts +52 -0
  44. package/dist/features/card-callback.d.ts.map +1 -0
  45. package/dist/features/card-callback.js +133 -0
  46. package/dist/features/card-callback.js.map +1 -0
  47. package/dist/features/group-hub.d.ts +2 -0
  48. package/dist/features/group-hub.d.ts.map +1 -1
  49. package/dist/features/group-hub.js +2 -3
  50. package/dist/features/group-hub.js.map +1 -1
  51. package/dist/features/group-join-request.d.ts.map +1 -1
  52. package/dist/features/group-join-request.js +7 -1
  53. package/dist/features/group-join-request.js.map +1 -1
  54. package/dist/features/preset-switcher.d.ts +39 -0
  55. package/dist/features/preset-switcher.d.ts.map +1 -0
  56. package/dist/features/preset-switcher.js +172 -0
  57. package/dist/features/preset-switcher.js.map +1 -0
  58. package/dist/features/session-registry.d.ts +1 -1
  59. package/dist/features/session-registry.d.ts.map +1 -1
  60. package/dist/features/session-registry.js +30 -1
  61. package/dist/features/session-registry.js.map +1 -1
  62. package/dist/gateway/bootstrap.d.ts.map +1 -1
  63. package/dist/gateway/bootstrap.js +51 -4
  64. package/dist/gateway/bootstrap.js.map +1 -1
  65. package/dist/gateway/debounce.d.ts.map +1 -1
  66. package/dist/gateway/debounce.js +7 -0
  67. package/dist/gateway/debounce.js.map +1 -1
  68. package/dist/index.d.ts.map +1 -1
  69. package/dist/index.js +24 -5
  70. package/dist/index.js.map +1 -1
  71. package/dist/middleware/media-history.d.ts.map +1 -1
  72. package/dist/middleware/media-history.js +23 -6
  73. package/dist/middleware/media-history.js.map +1 -1
  74. package/dist/session/session-manager.d.ts +10 -0
  75. package/dist/session/session-manager.d.ts.map +1 -1
  76. package/dist/session/session-manager.js +45 -0
  77. package/dist/session/session-manager.js.map +1 -1
  78. package/dist/transport/inbound.d.ts.map +1 -1
  79. package/dist/transport/inbound.js +46 -50
  80. package/dist/transport/inbound.js.map +1 -1
  81. package/dist/transport/inject-rules.d.ts.map +1 -1
  82. package/dist/transport/inject-rules.js +8 -2
  83. package/dist/transport/inject-rules.js.map +1 -1
  84. package/dist/transport/media-kind.d.ts +37 -0
  85. package/dist/transport/media-kind.d.ts.map +1 -0
  86. package/dist/transport/media-kind.js +60 -0
  87. package/dist/transport/media-kind.js.map +1 -0
  88. package/dist/transport/outbound-buffer.d.ts +20 -2
  89. package/dist/transport/outbound-buffer.d.ts.map +1 -1
  90. package/dist/transport/outbound-buffer.js +25 -5
  91. package/dist/transport/outbound-buffer.js.map +1 -1
  92. package/dist/transport/outbound.d.ts.map +1 -1
  93. package/dist/transport/outbound.js +68 -4
  94. package/dist/transport/outbound.js.map +1 -1
  95. package/package.json +1 -1
  96. package/settings-host.js +401 -20
package/settings-host.js CHANGED
@@ -3,7 +3,7 @@
3
3
  * ②表情包图库管理 API(列表/缩略图/批量/导入), 与 dsh-qqbot 共享同进程 store 单例。
4
4
  * 仅 web profile 装配; 同源 fence 抄 modsearch。
5
5
  */
6
- import { readFileSync, writeFileSync, rmSync, mkdirSync, existsSync, readdirSync, statSync, cpSync, renameSync, openSync, readSync, closeSync, createWriteStream } from 'node:fs';
6
+ import { readFileSync, writeFileSync, appendFileSync, rmSync, mkdirSync, existsSync, readdirSync, statSync, cpSync, renameSync, openSync, readSync, closeSync, createWriteStream } from 'node:fs';
7
7
  import { extname, join, resolve, dirname, normalize, sep } from 'node:path';
8
8
  import { fileURLToPath } from 'node:url';
9
9
  import { spawn } from 'node:child_process';
@@ -13,7 +13,14 @@ import { getStickerStore } from '@zaofan/dsh-qqbot/sticker-store';
13
13
  import { getScheduleStore } from '@zaofan/dsh-qqbot/schedule-store';
14
14
 
15
15
  export const name = 'qqbot-settings';
16
- export const inject = ['settings', 'webServer', 'agentPresets'];
16
+ /**
17
+ * 服务依赖(2026-09-10 排查修复): 只声明必备的 settings/webServer。
18
+ * ⚠️ 曾声明 ['settings','webServer','agentPresets'] —— cordis 对 inject 声明的服务是
19
+ * 「等齐才调用 apply」语义: 若宿主未提供 agentPresets, 桥的 apply 永远不被调用,
20
+ * 所有 /api/qqbot-settings/* 路由都不会注册(实测表现为全部 404, 面板显示「无账号」)。
21
+ * agentPresets 现改为可选: apply 内使用时判空降级(缺失时该路由返回 500 提示)。
22
+ */
23
+ export const inject = ['settings', 'webServer'];
17
24
 
18
25
  const NS = 'im-qqbot';
19
26
  /**
@@ -99,21 +106,43 @@ function dirOf(u, key) {
99
106
 
100
107
  const MIME = { jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp', bmp: 'image/bmp' };
101
108
 
102
- /** 注册一条路由, 统一 fence + method 检查 */
109
+ /** 注册一条路由, 统一 fence + method 检查。
110
+ * ⚠️ 2026-09-10 关键修复: 同 path 只向 dsh webServer register 一次, 多 method 内部按
111
+ * req.method 分发。原实现对每个 route() 调用各 register 一次 —— dsh webServer 对同一
112
+ * path 重复 register 会抛错, 导致 apply 从该处中断, 其后所有路由全部未注册
113
+ * (实测: /group/botplay-events 的 GET+POST 同路径 → 其后 30 个路由缺失,
114
+ * dock 聊天视图/群发/审批全挂)。route() 本就接受 method 数组, 这里补按 path 去重。 */
115
+ const routeByPath = new Map();
103
116
  function route(ctx, method, path, handler) {
104
- const methods = Array.isArray(method) ? method : [method];
105
- ctx.effect(() => ctx.webServer.register({
106
- kind: 'exact',
107
- path,
108
- handler: async (req, res) => {
109
- if (!isTrusted(req)) return writeJson(res, 403, { error: 'refused: same-origin loopback only' });
110
- if (!methods.includes(req.method)) return writeJson(res, 405, { error: 'method not allowed' });
111
- try { await handler(req, res); } catch (e) { writeJson(res, 500, { error: String(e?.message ?? e) }); }
112
- },
113
- }), `qqbot-settings: ${path}`);
117
+ const methods = (Array.isArray(method) ? method : [method]).map((m) => String(m).toUpperCase());
118
+ let reg = routeByPath.get(path);
119
+ if (!reg) {
120
+ reg = { handlers: new Map() };
121
+ routeByPath.set(path, reg);
122
+ ctx.effect(() => ctx.webServer.register({
123
+ kind: 'exact',
124
+ path,
125
+ handler: async (req, res) => {
126
+ if (!isTrusted(req)) return writeJson(res, 403, { error: 'refused: same-origin loopback only' });
127
+ const h = reg.handlers.get(String(req.method || '').toUpperCase());
128
+ if (!h) return writeJson(res, 405, { error: 'method not allowed' });
129
+ try { await h(req, res); } catch (e) { writeJson(res, 500, { error: String(e?.message ?? e) }); }
130
+ },
131
+ }), `qqbot-settings: ${path}`);
132
+ }
133
+ for (const m of methods) reg.handlers.set(m, handler);
114
134
  }
115
135
 
116
136
  export function apply(ctx) {
137
+ // ── 桥装载诊断(2026-09-10 排查路由全 404): 落 ~/.dsh/qqbot-bridge-diag.log ──
138
+ // 用途: 宿主重启后可确认 apply 是否真的被 cordis 调用(以及当时 ctx 上有哪些服务)。
139
+ try {
140
+ const services = ['settings', 'webServer', 'agentPresets'].map((s) => s + '=' + (ctx && ctx[s] ? 'Y' : 'N')).join(' ');
141
+ appendFileSync(
142
+ join(homedir(), '.dsh', 'qqbot-bridge-diag.log'),
143
+ `[${new Date().toISOString()}] bridge apply 被调用 inject=${JSON.stringify(inject)} ${services}\n`,
144
+ );
145
+ } catch { /* 诊断失败不影响装载 */ }
117
146
  // ── 临时诊断(排查"设置读取失败"): settings 服务可用性 + 已注册命名空间 ──
118
147
  route(ctx, 'GET', '/api/qqbot-settings/_debug', async (_req, res) => {
119
148
  try {
@@ -848,6 +877,30 @@ export function apply(ctx) {
848
877
  function readPendingJson(cwd) {
849
878
  try { return JSON.parse(readFileSync(join(cwd, '.qqbot', 'join-pending.json'), 'utf8') || '{}'); } catch { return {}; }
850
879
  }
880
+ // ── botplay 事件独立文件存储(2026-09-10 M4.3): {dataRoot}/botplay-events.json
881
+ // (主人定: 事件配置放工作目录根, 和「表情包」等平级; 曾放 .qqbot 子目录, 读时兼容迁移)
882
+ function readBotplayEventsFile(cwd) {
883
+ try {
884
+ const root = join(cwd, 'botplay-events.json');
885
+ if (existsSync(root)) return JSON.parse(readFileSync(root, 'utf8') || '{}');
886
+ const legacy = join(cwd, '.qqbot', 'botplay-events.json');
887
+ if (existsSync(legacy)) { try { renameSync(legacy, root); } catch { /* 迁移失败继续读旧 */ } return JSON.parse(readFileSync(legacy, 'utf8') || '{}'); }
888
+ return {};
889
+ } catch { return {}; }
890
+ }
891
+ function writeBotplayEventsFile(cwd, events) {
892
+ try {
893
+ const f = join(cwd, 'botplay-events.json');
894
+ const tmp = f + '.tmp-' + Date.now();
895
+ writeFileSync(tmp, JSON.stringify({ version: 1, events: Array.isArray(events) ? events : [] }, null, 2), 'utf8');
896
+ renameSync(tmp, f);
897
+ return true;
898
+ } catch { return false; }
899
+ }
900
+ // 从 settings 现值取 botplayEvents(迁移/兜底用)
901
+ function botplayEventsFromSettings(ns) {
902
+ try { const v = viewOf(ctx.settings, ns); const ev = v && v.value && v.value.botplayEvents; return Array.isArray(ev) ? ev : []; } catch { return []; }
903
+ }
851
904
  const NSQ = (u) => (u.searchParams.get('ns') || '').trim() || undefined;
852
905
  const GQ = (u) => (u.searchParams.get('gid') || '').trim();
853
906
 
@@ -1156,6 +1209,246 @@ export function apply(ctx) {
1156
1209
  } catch (e) { writeJson(res, 500, { error: String((e && e.message) || e) }); }
1157
1210
  });
1158
1211
 
1212
+ // ── 自定义卡片按钮(2026-09-10 主人要求: 卡片按钮要能写「回文本 / 跳链接 / 执行命令」) ──
1213
+ // client 传结构化 cardButtons=[{label, action:'text'|'cmd'|'url', payload}]。
1214
+ // 这里生成 cardId、把按钮动作写进 {dataRoot}/.qqbot/card-callbacks.json —— 格式与 dist 侧
1215
+ // src/features/card-callback.ts 严格一致(改格式两边都要改); 按钮 data 编 `bpk:<cardId>:<btnId>`:
1216
+ // action=url → action.type=0 官方跳转(客户端直接跳, 不走回调)
1217
+ // 其余 → action.type=1 回调 → 点击后 QQ 推 INTERACTION_CREATE → bootstrap 的
1218
+ // cardCallbackController 查表执行(text 回文本 / cmd 走命令执行器)
1219
+ function writeCardCallbacks(dataRoot, card) {
1220
+ try {
1221
+ const p = join(dataRoot, '.qqbot', 'card-callbacks.json');
1222
+ mkdirSync(dirname(p), { recursive: true });
1223
+ let f = { version: 1, cards: [] };
1224
+ try { if (existsSync(p)) f = JSON.parse(readFileSync(p, 'utf8')) || f; } catch { f = { version: 1, cards: [] }; }
1225
+ const now = Date.now();
1226
+ const kept = (Array.isArray(f.cards) ? f.cards : []).filter(c => c && c.cardId !== card.cardId && (c.expireAt || 0) > now);
1227
+ kept.push(card);
1228
+ writeFileSync(p, JSON.stringify({ version: 1, cards: kept.slice(-200) }, null, 1), 'utf8');
1229
+ } catch { /* 写失败只影响按钮回调, 不阻断发卡 */ }
1230
+ }
1231
+ function buildCardKeyboardFromPairs(dataRoot, pairs, meta) {
1232
+ const list = (Array.isArray(pairs) ? pairs : []).filter(b => b && b.label).slice(0, 25);
1233
+ if (!list.length) return undefined;
1234
+ const cardId = 'c' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
1235
+ const buttons = list.map((b, i) => ({
1236
+ id: 'b' + (i + 1),
1237
+ label: String(b.label).slice(0, 20),
1238
+ action: b.action === 'url' ? 'url' : (b.action === 'cmd' ? 'cmd' : 'text'),
1239
+ payload: String(b.payload == null ? '' : b.payload),
1240
+ }));
1241
+ const rows = buttons.map(b => ({
1242
+ buttons: [{
1243
+ id: b.id,
1244
+ render_data: { label: b.label, style: 1 },
1245
+ action: {
1246
+ type: b.action === 'url' ? 0 : 1, // 0=跳转 1=回调
1247
+ permission: { type: 2 },
1248
+ data: 'bpk:' + cardId + ':' + b.id,
1249
+ unsupport_tips: '请在支持的客户端点击',
1250
+ },
1251
+ }],
1252
+ }));
1253
+ writeCardCallbacks(dataRoot, {
1254
+ cardId,
1255
+ buttons,
1256
+ createdAt: Date.now(),
1257
+ expireAt: Date.now() + 7 * 24 * 3600 * 1000,
1258
+ scope: meta && meta.scope,
1259
+ targetId: meta && meta.targetId,
1260
+ });
1261
+ return { content: { rows } };
1262
+ }
1263
+
1264
+ // ── 自定义 markdown 卡片发送(2026-09-10 主人实测: markdown 嵌网络图+按钮可渲染):
1265
+ // 接收 {ns?, gid | openid, markdown, keyboard?} → 群用 sendGroupCard / 私聊用 sendC2cCard;
1266
+ // 群发走 broadcast/create(type=markdown)。openid 支持为 2026-09-10 主人要求(卡片可选私聊目标)。
1267
+ route(ctx, 'POST', '/api/qqbot-settings/chat/send-card', async (req, res) => {
1268
+ const body = await readJsonBody(req);
1269
+ if (!body || typeof body !== 'object') return writeJson(res, 400, { error: 'bad body' });
1270
+ const gid = String(body.gid || '').trim();
1271
+ const openid = String(body.openid || '').trim();
1272
+ const md = String(body.markdown || '').trim();
1273
+ if (!gid && !openid) return writeJson(res, 400, { error: 'gid 或 openid 必填' });
1274
+ if (!md) return writeJson(res, 400, { error: 'markdown 必填' });
1275
+ if (md.length > 8000) return writeJson(res, 400, { error: 'markdown 过长(最多 8000 字符)' });
1276
+ try {
1277
+ const gc = await groupClientOf(String(body.ns || ''));
1278
+ if (!gc) return writeJson(res, 400, { error: '找不到该账号实例' });
1279
+ // 结构化按钮(新路径) → 生成回调表 + bpk: 键盘; 也兼容旧路径直接给 keyboard
1280
+ let kb = body.keyboard && body.keyboard.content && Array.isArray(body.keyboard.content.rows) ? body.keyboard : undefined;
1281
+ if (!kb && Array.isArray(body.cardButtons) && body.cardButtons.length) {
1282
+ kb = buildCardKeyboardFromPairs(gc.bot.cwd, body.cardButtons, { scope: openid ? 'c2c' : 'group', targetId: openid || gid });
1283
+ }
1284
+ const r = openid
1285
+ ? await gc.client.sendC2cCard(openid, md, kb)
1286
+ : await gc.client.sendGroupCard(gid, md, kb);
1287
+ audit(gc.bot.cwd, { ev: 'chat.send-card', ns: gc.bot.id, gid, openid, mdLen: md.length, kbRows: kb ? kb.content.rows.length : 0, cbButtons: Array.isArray(body.cardButtons) ? body.cardButtons.length : 0, ok: r.ok, code: r.ok ? undefined : (r.err && r.err.code) });
1288
+ writeJson(res, 200, r.ok
1289
+ ? { ok: true, msg: '✅ 卡片已发送' + (openid ? '(私聊)' : '(群)'), id: r.data && r.data.id }
1290
+ : { ok: false, err: r.err });
1291
+ } catch (e) { writeJson(res, 500, { error: String((e && e.message) || e) }); }
1292
+ });
1293
+
1294
+ // ── botplay 事件独立文件存储(M4.3): GET 读 / POST 写, 均按 ns 取 dataRoot ──
1295
+ // ⚠️ 2026-09-10 修复: NSQ 参数是 URL 对象(内部调 u.searchParams.get), 原写法 NSQ(req.url)
1296
+ // 传了字符串 → "Cannot read properties of undefined (reading 'get')" 500。
1297
+ route(ctx, 'GET', '/api/qqbot-settings/group/botplay-events', async (req, res) => {
1298
+ const ns = NSQ(new URL(req.url ?? '/', 'http://x'));
1299
+ const bot = nsBot(ns);
1300
+ if (!bot || !bot.cwd) return writeJson(res, 400, { error: '找不到该账号实例' });
1301
+ const fromFile = readBotplayEventsFile(bot.cwd);
1302
+ let events = Array.isArray(fromFile.events) ? fromFile.events : (Array.isArray(fromFile) ? fromFile : []);
1303
+ // 文件缺失/空 → 迁移 settings 旧数据(botplay 事件曾存 settings.yaml, 2026-09-10 前)
1304
+ if (events.length === 0) {
1305
+ const legacy = botplayEventsFromSettings(bot.ns);
1306
+ if (Array.isArray(legacy) && legacy.length > 0) { writeBotplayEventsFile(bot.cwd, legacy); events = legacy; }
1307
+ }
1308
+ writeJson(res, 200, { ok: true, events, source: 'file' });
1309
+ });
1310
+
1311
+ route(ctx, 'POST', '/api/qqbot-settings/group/botplay-events', async (req, res) => {
1312
+ const body = await readJsonBody(req);
1313
+ if (!body || typeof body !== 'object') return writeJson(res, 400, { error: 'bad body' });
1314
+ const ns = String(body.ns || 'im-qqbot').trim();
1315
+ const bot = nsBot(ns);
1316
+ if (!bot || !bot.cwd) return writeJson(res, 400, { error: '找不到该账号实例' });
1317
+ if (!Array.isArray(body.events)) return writeJson(res, 400, { error: 'events 必须为数组' });
1318
+ const okFile = writeBotplayEventsFile(bot.cwd, body.events);
1319
+ // settings 现值同步清空(2026-09-10: 事件已独立文件, settings 里残留旧数组会与新文件重复/歧义 → 置空)
1320
+ let okSettings = true;
1321
+ try {
1322
+ const cur = viewOf(ctx.settings, bot.ns);
1323
+ const curVal = (cur && cur.value) || {};
1324
+ if (curVal.botplayEvents !== undefined && Array.isArray(curVal.botplayEvents) && curVal.botplayEvents.length > 0) {
1325
+ const patch = Object.assign({}, curVal, { botplayEvents: [] });
1326
+ await ctx.settings.update(bot.ns, patch, (cur && cur.revision) || undefined);
1327
+ }
1328
+ } catch { okSettings = false; }
1329
+ audit(bot.cwd, { ev: 'botplay.events-save', ns: bot.ns, n: body.events.length, okFile, okSettings });
1330
+ writeJson(res, 200, { ok: okFile, msg: okFile ? '✅ 已保存(独立文件, live 热更已生效)' : '写入文件失败' });
1331
+ });
1332
+
1333
+ // ── M3 群发任务队列(2026-09-10): 持久化状态机 draft→queued→sending→done, 二次确认, 可中止/撤回 ──
1334
+ // 数据: {dataRoot}/.qqbot/broadcast-tasks.json(broadcast.ts 管理); 推进由每次 list 请求驱动(串行, 天然限频)。
1335
+ // 路由: broadcast/create(草稿) → broadcast/confirm(二次确认后入队) → broadcast/list(推进+展示) →
1336
+ // broadcast/cancel(中止) → broadcast/recall(撤回某目标已发消息)。
1337
+ async function broadcastMod() {
1338
+ return import('./dist/features/broadcast.js');
1339
+ }
1340
+
1341
+ // 创建群发草稿 {ns?, type:'text'|'markdown', text, targets:[{scope,peerId,name?}]}
1342
+ route(ctx, 'POST', '/api/qqbot-settings/group/broadcast/create', async (req, res) => {
1343
+ const body = await readJsonBody(req);
1344
+ if (!body || typeof body !== 'object') return writeJson(res, 400, { error: 'bad body' });
1345
+ const text = String(body.text || '').trim();
1346
+ const targets = Array.isArray(body.targets) ? body.targets : [];
1347
+ // 卡片群发(2026-09-10 主人要求): type=card 时 text 为 markdown 正文, 另带 keyboard
1348
+ const wantCard = body.type === 'card';
1349
+ if (!text) return writeJson(res, 400, { error: wantCard ? 'markdown 必填' : 'text 必填' });
1350
+ if (text.length > (wantCard ? 8000 : 2000)) return writeJson(res, 400, { error: wantCard ? 'markdown 过长(最多 8000 字符)' : '文本过长(最多 2000 字符)' });
1351
+ // 卡片群发(2026-09-10 主人要求): type=card 时 text 为 markdown 正文, 另带 keyboard;
1352
+ // 优先用结构化 cardButtons 现生成(回调表+bpk: 键盘, 与 chat/send-card 同一套), 兼容直接给 keyboard
1353
+ let kb = wantCard && body.keyboard && body.keyboard.content && Array.isArray(body.keyboard.content.rows) ? body.keyboard : undefined;
1354
+ if (targets.length === 0) return writeJson(res, 400, { error: 'targets 至少一个(勾选群/私聊)' });
1355
+ if (targets.length > 200) return writeJson(res, 400, { error: '目标太多(最多 200)' });
1356
+ for (const t of targets) {
1357
+ if (!t || (t.scope !== 'group' && t.scope !== 'c2c') || !t.peerId) return writeJson(res, 400, { error: 'targets 每项需 scope(group|c2c)+peerId' });
1358
+ }
1359
+ try {
1360
+ const bot = nsBot(String(body.ns || ''));
1361
+ if (!bot) return writeJson(res, 400, { error: '找不到该账号实例(请先在账号页配置 appId/appSecret)' });
1362
+ // 结构化按钮 → 现生成回调表 + bpk: 键盘(与 chat/send-card 同一套; 群发各目标共用这张表)
1363
+ if (wantCard && !kb && Array.isArray(body.cardButtons) && body.cardButtons.length) {
1364
+ kb = buildCardKeyboardFromPairs(bot.cwd, body.cardButtons, { scope: 'group', targetId: '' });
1365
+ }
1366
+ const bm = await broadcastMod();
1367
+ const task = bm.createTask(bot.cwd, {
1368
+ type: wantCard ? 'card' : (body.type === 'markdown' ? 'markdown' : 'text'),
1369
+ content: text,
1370
+ keyboard: kb,
1371
+ targets: targets.map((t) => ({ scope: t.scope, peerId: t.peerId, name: t.name ? String(t.name) : undefined })),
1372
+ created_by: 'dock',
1373
+ });
1374
+ audit(bot.cwd, { ev: 'group.broadcast.create', ns: bot.id, task_id: task.task_id, targets: targets.length, type: wantCard ? 'card' : 'text' });
1375
+ writeJson(res, 200, { ok: true, task });
1376
+ } catch (e) { writeJson(res, 500, { error: String((e && e.message) || e) }); }
1377
+ });
1378
+
1379
+ // 确认群发(二次确认后调用) {ns?, task_id}
1380
+ route(ctx, 'POST', '/api/qqbot-settings/group/broadcast/confirm', async (req, res) => {
1381
+ const body = await readJsonBody(req);
1382
+ if (!body || typeof body !== 'object') return writeJson(res, 400, { error: 'bad body' });
1383
+ const taskId = String(body.task_id || '');
1384
+ if (!taskId) return writeJson(res, 400, { error: 'task_id 必填' });
1385
+ try {
1386
+ const bot = nsBot(String(body.ns || ''));
1387
+ if (!bot) return writeJson(res, 400, { error: '找不到该账号实例' });
1388
+ const bm = await broadcastMod();
1389
+ const t = bm.confirmTask(bot.cwd, taskId);
1390
+ if (!t) return writeJson(res, 400, { error: '任务不存在' });
1391
+ if (t.state !== 'queued') return writeJson(res, 200, { ok: false, msg: `任务状态为 ${t.state}, 无法确认` });
1392
+ audit(bot.cwd, { ev: 'group.broadcast.confirm', ns: bot.id, task_id: taskId });
1393
+ writeJson(res, 200, { ok: true, task: t });
1394
+ } catch (e) { writeJson(res, 500, { error: String((e && e.message) || e) }); }
1395
+ });
1396
+
1397
+ // 群发任务列表(读时推进一个目标) {ns?}
1398
+ route(ctx, 'GET', '/api/qqbot-settings/group/broadcast/list', async (req, res) => {
1399
+ try {
1400
+ const u = new URL(req.url ?? '/', 'http://x');
1401
+ const bot = nsBot(NSQ(u));
1402
+ if (!bot) return writeJson(res, 400, { error: '找不到该账号实例' });
1403
+ const bm = await broadcastMod();
1404
+ // 推进一步: 找第一个 queued/sending 的任务推进(串行限频)
1405
+ const gc = await groupClientOf(NSQ(u));
1406
+ if (gc) {
1407
+ const tasks = bm.listTasks(bot.cwd);
1408
+ const active = tasks.find((t) => t.state === 'queued' || t.state === 'sending');
1409
+ if (active) await bm.advanceTask(bot.cwd, active.task_id, gc.client);
1410
+ }
1411
+ const after = bm.listTasks(bot.cwd);
1412
+ writeJson(res, 200, { ok: true, tasks: after });
1413
+ } catch (e) { writeJson(res, 500, { error: String((e && e.message) || e) }); }
1414
+ });
1415
+
1416
+ // 中止群发 {ns?, task_id}
1417
+ route(ctx, 'POST', '/api/qqbot-settings/group/broadcast/cancel', async (req, res) => {
1418
+ const body = await readJsonBody(req);
1419
+ if (!body || typeof body !== 'object') return writeJson(res, 400, { error: 'bad body' });
1420
+ const taskId = String(body.task_id || '');
1421
+ if (!taskId) return writeJson(res, 400, { error: 'task_id 必填' });
1422
+ try {
1423
+ const bot = nsBot(String(body.ns || ''));
1424
+ if (!bot) return writeJson(res, 400, { error: '找不到该账号实例' });
1425
+ const bm = await broadcastMod();
1426
+ const t = bm.cancelTask(bot.cwd, taskId, 'dock');
1427
+ if (!t) return writeJson(res, 400, { error: '任务不存在' });
1428
+ audit(bot.cwd, { ev: 'group.broadcast.cancel', ns: bot.id, task_id: taskId });
1429
+ writeJson(res, 200, { ok: true, task: t });
1430
+ } catch (e) { writeJson(res, 500, { error: String((e && e.message) || e) }); }
1431
+ });
1432
+
1433
+ // 撤回群发中某目标已发的消息(2 分钟窗口) {ns?, task_id, peerId}
1434
+ route(ctx, 'POST', '/api/qqbot-settings/group/broadcast/recall', async (req, res) => {
1435
+ const body = await readJsonBody(req);
1436
+ if (!body || typeof body !== 'object') return writeJson(res, 400, { error: 'bad body' });
1437
+ const taskId = String(body.task_id || '');
1438
+ const peerId = String(body.peerId || '');
1439
+ if (!taskId || !peerId) return writeJson(res, 400, { error: 'task_id 与 peerId 必填' });
1440
+ try {
1441
+ const bot = nsBot(String(body.ns || ''));
1442
+ const gc = await groupClientOf(String(body.ns || ''));
1443
+ if (!bot || !gc) return writeJson(res, 400, { error: '找不到该账号实例' });
1444
+ const bm = await broadcastMod();
1445
+ const r = await bm.recallTaskMessage(bot.cwd, taskId, peerId, gc.client);
1446
+ audit(bot.cwd, { ev: 'group.broadcast.recall', ns: bot.id, task_id: taskId, peerId, ok: r.ok });
1447
+ writeJson(res, 200, r);
1448
+ } catch (e) { writeJson(res, 500, { error: String((e && e.message) || e) }); }
1449
+ });
1450
+
1451
+
1159
1452
  // ── 悬浮球「💬 聊天视图」(2026-09-07): 读某 QQ 会话最近的入站/出站消息, 只读不建会话 ──
1160
1453
  // 会话事件结构(宿主 dsh-session): {type, seq, time, data}; user/message 的 data=消息体根(data.content),
1161
1454
  // assistant/message 的 data={turn,step,message:{role,content,…}}。只收这两型:
@@ -1221,10 +1514,19 @@ export function apply(ctx) {
1221
1514
  function chatLocalRawUrl(p) {
1222
1515
  return '/api/qqbot-settings/chat/raw-media?p=' + encodeURIComponent(p);
1223
1516
  }
1224
- // 附件行/URL 归属类型: 文件名/URL fname 扩展名优先(voice/image/file), 无扩展名看行标签与上下文
1225
- // 语音上下文(- ASR:/[Voice message])→ voice; 默认 image(QQ 图消息常走 [附件: url] 无扩展名)
1517
+ // 附件行/URL 归属类型: 行内**显式类型标签**优先, 其次文件名/URL fname 扩展名, 最后行标签与上下文
1518
+ // ⚠️ 2026-09-10 根因修复(主人实测: "视频还是附件形式, 图片也变成附件了"):
1519
+ // QQ 群聊里图片/视频的 content_type 实测可能是 `'file'`(不是 image/jpeg) —— 官方取值表:
1520
+ // voice / image/jpeg|png|gif / video/mp4 / file(=群文件)。端上(media-kind.ts)已按
1521
+ // MIME→扩展名→宽高→URL 特征(appid/orgfmt) 推断真实类型, 并以 `- Image: 名 → url` 注入 Layer 4。
1522
+ // 这里必须认这些**显式前缀**, 否则 `- File:` 会把图片/视频判成 📎 附件(图片不显示、视频不能播)。
1226
1523
  function chatAttachmentKind(line, hasVoiceCtx, defaultKind) {
1227
1524
  const l = String(line || '');
1525
+ // ① 显式类型标签(我方注入格式, 最权威)
1526
+ if (/^-\s*(?:Image|图片)\s*:/i.test(l) || /^\[(?:Image|图片)\s*:/i.test(l)) return 'image';
1527
+ if (/^-\s*(?:Video|视频)\s*:/i.test(l) || /^\[(?:Video|视频)\s*:/i.test(l)) return 'video';
1528
+ if (/^-\s*(?:Voice|语音|音频)\s*:/i.test(l) || /^\[(?:Voice|语音|音频)\s*:/i.test(l)) return 'voice';
1529
+ if (/^-\s*(?:File|文件)\s*:/i.test(l) || /^\[(?:File|文件)\s*:/i.test(l)) return 'file';
1228
1530
  // 从行内或 URL fname 参数取文件名
1229
1531
  let name = '';
1230
1532
  const fm = l.match(/fname=([^&\s]+)/);
@@ -1279,8 +1581,9 @@ export function apply(ctx) {
1279
1581
  const lk = chatLocalExtKind(lp[1]);
1280
1582
  if (lk) { images.push({ url: chatLocalRawUrl(lp[1]), kind: lk }); continue; }
1281
1583
  }
1282
- // 附件描述行: "- Attachment URLs: …" / "- File: …" / "[附件: …]" / "[文件: …]" / "[图片: …]" / "[Attachment: name -> …]"
1283
- if (/^-\s*Attachment URLs:/i.test(raw) || /^-\s*File:/i.test(raw) || /^\[(图片|附件|文件|语音|视频|音频):/i.test(raw) || /^\[Attachment:|^\[File:/i.test(raw)) {
1584
+ // 附件描述行: "- Image: …" / "- Video: …" / "- Voice: …" / "- File: …" / "- Attachment URLs: …"
1585
+ // / "[图片: …]" / "[视频: …]" / "[附件: …]" / "[Attachment: name -> …]" / Layer1 的英文标签行
1586
+ if (/^-\s*Attachment URLs:/i.test(raw) || /^-\s*(Image|Video|Voice|File)s?:/i.test(raw) || /^\[(图片|附件|文件|语音|视频|音频):/i.test(raw) || /^\[(Attachment|File|Image|Video):/i.test(raw)) {
1284
1587
  const urls = raw.match(QQ_URL_RE) || [];
1285
1588
  let got = 0;
1286
1589
  for (const u of urls) {
@@ -1290,6 +1593,10 @@ export function apply(ctx) {
1290
1593
  else if (QQ_MEDIA_RE.test(u)) { images.push({ url: u, kind: k }); got++; }
1291
1594
  }
1292
1595
  if (!got) {
1596
+ // Layer 1 的英文描述行(`[Image: 名 800×600]` / `[Video: 名]` / `[File: 名 (1.2MB)]`)不含 URL,
1597
+ // 其媒体由 Layer 4 的 `- Image:/- Video:/- File:` 行承载 → 直接丢弃, 避免显示成
1598
+ // `📎 [Image: 名 800×600]` 这种丑文本(主人 2026-09-10 实测反馈)。
1599
+ if (/^\[(Image|Video|File):/i.test(raw)) continue;
1293
1600
  // 无 URL 的 File/附件描述行(如 "- File: a.zip (3.0MB)")→ 保留可读文本, 不给空占位
1294
1601
  const desc = raw
1295
1602
  .replace(/^-\s*File:\s*/i, '').replace(/^\[File:\s*/i, '')
@@ -1343,6 +1650,12 @@ export function apply(ctx) {
1343
1650
  function chatSplitHistoryBlock(body) {
1344
1651
  const out = [];
1345
1652
  let cur = null;
1653
+ // ⚠️ 2026-09-10 修复(主人实测: 当前消息的语音被挂到了上一条历史消息上):
1654
+ // Layer 4 的媒体元数据行(`- Attachment URLs:` / `- File:` …)排在 `[Current message]` 之后、
1655
+ // 带昵称的当前消息之前 —— 原逻辑"无昵称行并入上一个气泡"会把它算到上一条历史消息头上。
1656
+ // 改为: 这类 `- ` 开头的元数据行先暂存(pendingMeta), 等下一个带昵称的消息出现时归属它
1657
+ // (即"元数据行属于它下面那条消息"), 找不到归属时才丢弃。
1658
+ let pendingMeta = [];
1346
1659
  const flush = () => {
1347
1660
  if (cur) {
1348
1661
  const t = cur.lines.join('\n').trim();
@@ -1355,9 +1668,12 @@ export function apply(ctx) {
1355
1668
  if (!s) continue;
1356
1669
  if (/^\[Chat history begins\]$/.test(s) || /^\[Chat history ends\]$/.test(s) || /^\[Current message\]$/.test(s) || /^\[当前时间/.test(s)) continue;
1357
1670
  if (/^\[系统提示\]/.test(s)) { flush(); return out; } // 系统注入段, 之后都不属于群聊内容
1671
+ // 媒体元数据行: 暂存, 等归属给下面那条带昵称的消息
1672
+ // (Layer 4 现输出 `- Image:/- Video:/- Voice:/- File:` 单数前缀, 此处一并覆盖)
1673
+ if (/^-\s*(Attachment URLs|Files?|Images?|ASR|Voices?|Videos?)\s*[::]/i.test(s)) { pendingMeta.push(s); continue; }
1358
1674
  const m = s.match(/^\[([^\]\n]*?)\s*\([A-Za-z0-9_-]{6,}\)\](.*)$/);
1359
- if (m) { flush(); cur = { sender: m[1].trim(), lines: [m[2].trim()].filter(Boolean) }; }
1360
- else { if (cur) cur.lines.push(s); else { flush(); cur = { sender: '', lines: [s] }; } }
1675
+ if (m) { flush(); cur = { sender: m[1].trim(), lines: [m[2].trim(), ...pendingMeta].filter(Boolean) }; pendingMeta = []; }
1676
+ else { if (cur) cur.lines.push(...pendingMeta, s); else { flush(); cur = { sender: '', lines: [...pendingMeta, s] }; } pendingMeta = []; }
1361
1677
  }
1362
1678
  flush();
1363
1679
  return out;
@@ -1376,6 +1692,15 @@ export function apply(ctx) {
1376
1692
  const mm = chatSplitMedia(chatDisplayClean(b, nameByMid));
1377
1693
  return { sender, mm };
1378
1694
  }
1695
+ // 🔍 临时诊断(2026-09-10): 把含媒体标记的入站原文落盘到 ~/.dsh/qqbot-chat-raw.jsonl ——
1696
+ // 用于核对 Layer1/Layer4 实际注入格式与 dock 解析结果(主人验收通过后删除本函数与调用)。
1697
+ function chatRawDiag(raw0) {
1698
+ try {
1699
+ const s = String(raw0 || '');
1700
+ if (!/(Image|Video|Voice|File|Attachment|图片|视频|语音|文件|附件)|multimedia\.nt\.qq\.com|qpic\.cn/i.test(s)) return;
1701
+ appendFileSync(join(homedir(), '.dsh', 'qqbot-chat-raw.jsonl'), JSON.stringify({ t: Date.now(), body: s.slice(0, 5000) }) + '\n');
1702
+ } catch { /* 诊断失败不影响主流程 */ }
1703
+ }
1379
1704
  // 解码一条会话事件为聊天条目(群打包会展开成多条); 非聊天事件/噪声返回 null
1380
1705
  function chatDecodeEvent(ev, scope, nameByMid, fallbackSender) {
1381
1706
  if (!ev || typeof ev.type !== 'string') return null;
@@ -1386,6 +1711,7 @@ export function apply(ctx) {
1386
1711
  if (src.kind === 'plugin') return null; // runtime context / 系统注入等, 非真人对话
1387
1712
  const raw0 = chatTextOf(data.content);
1388
1713
  if (!raw0) return null;
1714
+ chatRawDiag(raw0);
1389
1715
  const raw = chatPeelTimeHead(raw0);
1390
1716
  const isRelay = /^用户代你发送: /.test(raw);
1391
1717
  // 后台任务/面板大文件完成通知([系统] 后台任务…)→ 显示为 bot 侧气泡并打来源标; 其余系统注入滤掉
@@ -1499,7 +1825,22 @@ export function apply(ctx) {
1499
1825
  if (low === 0) { reachedEnd = true; break; }
1500
1826
  high = low - 1;
1501
1827
  }
1502
- items.reverse(); // 升序(旧→新)
1828
+ // ⚠️ 2026-09-10 修复(主人实测"同一批次的历史消息会倒序"):
1829
+ // 原为 items.reverse() —— 倒扫时同一打包块([Chat history begins]…[Chat history ends])
1830
+ // 拆出的多条共用同一个 ev.seq, 块内本就是旧→新; 整体 reverse 会把"块内顺序"也翻反,
1831
+ // 表现为"整批之间顺序对、批内倒序"。
1832
+ // 改为: 按 seq 分块 → 块间倒序(旧→新), 块内保持原顺序。
1833
+ {
1834
+ const blocks = [];
1835
+ for (const it of items) {
1836
+ const last = blocks[blocks.length - 1];
1837
+ if (last && last.seq === it.seq) last.arr.push(it);
1838
+ else blocks.push({ seq: it.seq, arr: [it] });
1839
+ }
1840
+ blocks.reverse();
1841
+ items.length = 0;
1842
+ for (const b of blocks) for (const one of b.arr) items.push(one);
1843
+ }
1503
1844
  // QQ 媒体域的语音 URL → 经 /chat/voice-play 转 mp3 播放(浏览器解不了 SILK)
1504
1845
  for (const it of items) {
1505
1846
  if (!Array.isArray(it.images)) continue;
@@ -2010,4 +2351,44 @@ const WHY_MAP = { busy: '目标会话回合活跃,已等待至回合结束仍超
2010
2351
  } catch (e) { writeJson(res, 500, { error: String((e && e.message) || e) }); }
2011
2352
  });
2012
2353
 
2354
+ // ── 🚀 群发任务「后台自动推进」(2026-09-10 修: 原设计靠 client 每次 list 驱动, 页面不动就永久卡 queued) ──
2355
+ // 每 5s 扫全部实例, 有 queued/sending 任务就推进一步(串行 → 天然限频); 单实例失败不影响其他实例。
2356
+ try {
2357
+ const BC_TICK_MS = 5000;
2358
+ let bcTickRunning = false; // 定时器自身防重入: 上一轮未跑完不叠加(2026-09-10)
2359
+ const bcTick = async () => {
2360
+ if (bcTickRunning) return;
2361
+ bcTickRunning = true;
2362
+ try {
2363
+ let bots = [];
2364
+ try { bots = parsePatch().bots || []; } catch { return; }
2365
+ for (const b of bots) {
2366
+ try {
2367
+ if (b.disabled) continue;
2368
+ const bot = nsBot(b.id);
2369
+ if (!bot || !bot.cwd) continue;
2370
+ const bm = await broadcastMod();
2371
+ const tasks = bm.listTasks(bot.cwd);
2372
+ const active = tasks.find((t) => t.state === 'queued' || t.state === 'sending');
2373
+ if (!active) continue;
2374
+ const gc = await groupClientOf(b.id);
2375
+ if (!gc) continue;
2376
+ await bm.advanceTask(bot.cwd, active.task_id, gc.client);
2377
+ audit(bot.cwd, { ev: 'group.broadcast.auto-advance', ns: b.id, task_id: active.task_id });
2378
+ } catch (e) {
2379
+ try {
2380
+ const bot2 = nsBot(b.id);
2381
+ if (bot2 && bot2.cwd) audit(bot2.cwd, { ev: 'group.broadcast.auto-advance-failed', ns: b.id, error: String((e && e.message) || e) });
2382
+ } catch { /* ignore */ }
2383
+ }
2384
+ }
2385
+ } finally { bcTickRunning = false; }
2386
+ };
2387
+ ctx.effect(() => {
2388
+ const timer = setInterval(() => { bcTick().catch(() => {}); }, BC_TICK_MS);
2389
+ timer.unref?.();
2390
+ return () => { try { clearInterval(timer); } catch { /* ignore */ } };
2391
+ }, 'qqbot-settings: 群发任务后台自动推进');
2392
+ } catch { /* 定时器失败不影响插件 */ }
2393
+
2013
2394
  }