@zaofan/dsh-qqbot 0.6.0 → 0.8.0

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 (41) hide show
  1. package/README.md +17 -0
  2. package/README_EN.md +17 -0
  3. package/client/qqbot-settings.js +1385 -28
  4. package/dist/api/group-admin.d.ts +13 -0
  5. package/dist/api/group-admin.d.ts.map +1 -1
  6. package/dist/api/group-admin.js +23 -0
  7. package/dist/api/group-admin.js.map +1 -1
  8. package/dist/channel-tools.d.ts.map +1 -1
  9. package/dist/channel-tools.js +58 -1
  10. package/dist/channel-tools.js.map +1 -1
  11. package/dist/config.d.ts +2 -0
  12. package/dist/config.d.ts.map +1 -1
  13. package/dist/config.js +1 -0
  14. package/dist/config.js.map +1 -1
  15. package/dist/features/chat-ledger.d.ts.map +1 -1
  16. package/dist/features/chat-ledger.js +54 -3
  17. package/dist/features/chat-ledger.js.map +1 -1
  18. package/dist/features/qq-approval.d.ts +43 -1
  19. package/dist/features/qq-approval.d.ts.map +1 -1
  20. package/dist/features/qq-approval.js +167 -17
  21. package/dist/features/qq-approval.js.map +1 -1
  22. package/dist/features/qq-user-questions.d.ts +69 -0
  23. package/dist/features/qq-user-questions.d.ts.map +1 -0
  24. package/dist/features/qq-user-questions.js +214 -0
  25. package/dist/features/qq-user-questions.js.map +1 -0
  26. package/dist/features/session-registry.d.ts +51 -0
  27. package/dist/features/session-registry.d.ts.map +1 -0
  28. package/dist/features/session-registry.js +95 -0
  29. package/dist/features/session-registry.js.map +1 -0
  30. package/dist/gateway/bootstrap.d.ts.map +1 -1
  31. package/dist/gateway/bootstrap.js +88 -1
  32. package/dist/gateway/bootstrap.js.map +1 -1
  33. package/dist/session/session-manager.d.ts.map +1 -1
  34. package/dist/session/session-manager.js +9 -14
  35. package/dist/session/session-manager.js.map +1 -1
  36. package/dist/transport/outbound-buffer.d.ts +2 -0
  37. package/dist/transport/outbound-buffer.d.ts.map +1 -1
  38. package/dist/transport/outbound-buffer.js.map +1 -1
  39. package/package.json +6 -3
  40. package/settings-host.js +962 -5
  41. package/voice-convert.mjs +45 -0
package/settings-host.js CHANGED
@@ -3,11 +3,12 @@
3
3
  * ②表情包图库管理 API(列表/缩略图/批量/导入), 与 dsh-qqbot 共享同进程 store 单例。
4
4
  * 仅 web profile 装配; 同源 fence 抄 modsearch。
5
5
  */
6
- import { readFileSync, writeFileSync, rmSync, mkdirSync, existsSync, readdirSync, statSync, cpSync } from 'node:fs';
7
- import { extname, join, resolve, dirname } from 'node:path';
6
+ import { readFileSync, writeFileSync, rmSync, mkdirSync, existsSync, readdirSync, statSync, cpSync, renameSync, openSync, readSync, closeSync, createWriteStream } from 'node:fs';
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';
10
10
  import { homedir } from 'node:os';
11
+ import { createHash } from 'node:crypto';
11
12
  import { getStickerStore } from '@zaofan/dsh-qqbot/sticker-store';
12
13
  import { getScheduleStore } from '@zaofan/dsh-qqbot/schedule-store';
13
14
 
@@ -100,12 +101,13 @@ const MIME = { jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'im
100
101
 
101
102
  /** 注册一条路由, 统一 fence + method 检查 */
102
103
  function route(ctx, method, path, handler) {
104
+ const methods = Array.isArray(method) ? method : [method];
103
105
  ctx.effect(() => ctx.webServer.register({
104
106
  kind: 'exact',
105
107
  path,
106
108
  handler: async (req, res) => {
107
109
  if (!isTrusted(req)) return writeJson(res, 403, { error: 'refused: same-origin loopback only' });
108
- if (req.method !== method) return writeJson(res, 405, { error: 'method not allowed' });
110
+ if (!methods.includes(req.method)) return writeJson(res, 405, { error: 'method not allowed' });
109
111
  try { await handler(req, res); } catch (e) { writeJson(res, 500, { error: String(e?.message ?? e) }); }
110
112
  },
111
113
  }), `qqbot-settings: ${path}`);
@@ -199,7 +201,18 @@ export function apply(ctx) {
199
201
  nextFireAt: store.nextFireAt(j, now),
200
202
  }));
201
203
  }
202
- route(ctx, 'GET', '/api/qqbot-settings/timers', async (req, res) => {
204
+ route(ctx, ['GET', 'POST'], '/api/qqbot-settings/timers', async (req, res) => {
205
+ // POST = 旧版客户端统一 action: body {id, schedDir?, enabled?} —— enabled 存在=开关, 缺省=删除
206
+ if (req.method === 'POST') {
207
+ const body = await readJsonBody(req);
208
+ if (!body || typeof body.id !== 'string') return writeJson(res, 400, { error: 'id required' });
209
+ const store = typeof body.schedDir === 'string' && body.schedDir ? getScheduleStore(body.schedDir) : getScheduleStore();
210
+ const removing = body.enabled === undefined;
211
+ const ok = removing ? store.remove(body.id) : store.setEnabled(body.id, body.enabled === true);
212
+ try { store.flush(); } catch { /* ignore */ }
213
+ if (!ok) return writeJson(res, 404, { error: '任务不存在' });
214
+ return writeJson(res, 200, { ok: true });
215
+ }
203
216
  const u = new URL(req.url ?? '/', 'http://x');
204
217
  writeJson(res, 200, { jobs: timerJobs(dirOf(u, 'schedDir')) });
205
218
  });
@@ -379,6 +392,7 @@ export function apply(ctx) {
379
392
  route(ctx, 'GET', '/api/qqbot-settings/accounts', async (_req, res) => {
380
393
  try {
381
394
  const { bots, hasFile } = parsePatch();
395
+ const reg = await import('./dist/features/session-registry.js');
382
396
  writeJson(res, 200, {
383
397
  hasFile,
384
398
  instances: bots.map((b) => {
@@ -392,6 +406,7 @@ export function apply(ctx) {
392
406
  preset: b.cfg?.preset || '',
393
407
  cwd,
394
408
  disabled: !!b.disabled,
409
+ online: typeof reg.isBotOnline === 'function' ? reg.isBotOnline(b.id) : false, // 在线状态(bot ws ready 事件驱动)
395
410
  // 账号数据目录(各号各库各定时): 图库={cwd}/表情包, 定时={cwd}/.qqbot
396
411
  dataDir: cwd ? join(cwd, '表情包') : '',
397
412
  schedDir: cwd ? join(cwd, '.qqbot') : '',
@@ -568,7 +583,94 @@ export function apply(ctx) {
568
583
  } catch (e) { writeJson(res, 500, { error: String(e?.message ?? e) }); }
569
584
  });
570
585
 
571
- // ── 扫码绑定注册流程(每个账号走 QQ 官方绑定, 拿平台下发的 appId/appSecret) ──
586
+
587
+ // ── 预设人格文件浏览/编辑(2026-09-07): "展开改写人格"。安全: 仅 PRESET_ROOT/{id} 内白名单文件;
588
+ // 写权限仅限"复制出来带QQ工具标记"的副本(防误改内置/半成品); 大小上限 200KB。
589
+ const PRESET_EDIT_EXT = /.(yml|yaml|json|mjs|js|md|txt)$/i;
590
+ const PRESET_EDIT_MAX = 200 * 1024;
591
+ function presetDirSafe(id) {
592
+ if (!ID_RE.test(String(id || ''))) return null;
593
+ const dir = resolve(PRESET_ROOT, id);
594
+ if (!dir.startsWith(resolve(PRESET_ROOT) + sep)) return null;
595
+ if (!existsSync(join(dir, 'agent.cordis.yml'))) return null;
596
+ return dir;
597
+ }
598
+ function presetWritable(id) {
599
+ // 安全规则(主人定): 仅"复制出来带 QQ 工具标记"的预设可改写(liangshen 等底层/实验预设保持只读)。
600
+ const p = scanPresets().find((x) => x.id === id);
601
+ return !!(p && p.hasChannelTools);
602
+ }
603
+ route(ctx, 'GET', '/api/qqbot-settings/presets/files', async (req, res) => {
604
+ const q = new URL(req.url, 'http://x').searchParams;
605
+ const dir = presetDirSafe(q.get('id') || '');
606
+ if (!dir) return writeJson(res, 404, { error: '预设不存在' });
607
+ try {
608
+ const files = readdirSync(dir, { withFileTypes: true })
609
+ .filter((d) => d.isFile() && PRESET_EDIT_EXT.test(d.name))
610
+ .map((d) => { const st = statSync(join(dir, d.name)); return { name: d.name, size: st.size, mtime: st.mtimeMs }; })
611
+ .sort((a, b) => (b.name === 'agent.cordis.yml' ? 1 : 0) - (a.name === 'agent.cordis.yml' ? 1 : 0) || a.name.localeCompare(b.name));
612
+ writeJson(res, 200, { id: q.get('id'), dir, writable: presetWritable(q.get('id')), files });
613
+ } catch (e) { writeJson(res, 500, { error: String(e?.message ?? e) }); }
614
+ });
615
+ route(ctx, ['GET', 'PUT'], '/api/qqbot-settings/presets/file', async (req, res) => {
616
+ if (req.method === 'PUT') {
617
+ const body = await readJsonBody(req);
618
+ const id = String(body?.id ?? '');
619
+ const dir = presetDirSafe(id);
620
+ const name = String(body?.name ?? '');
621
+ if (!dir || !PRESET_EDIT_EXT.test(name)) return writeJson(res, 400, { error: '参数不合法' });
622
+ const p = join(dir, name);
623
+ if (!existsSync(p) || !resolve(p).startsWith(resolve(dir) + sep)) return writeJson(res, 404, { error: '文件不存在' });
624
+ if (!presetWritable(id)) return writeJson(res, 403, { error: '安全限制: 仅"带QQ工具标记"的复制预设可改写(liangshen 等底层预设只读)' });
625
+ const content = String(body?.content ?? '');
626
+ if (Buffer.byteLength(content, 'utf8') > PRESET_EDIT_MAX) return writeJson(res, 400, { error: '内容过大(上限 200KB)' });
627
+ try {
628
+ const tmp = p + '.tmp-' + Date.now();
629
+ writeFileSync(tmp, content, 'utf8');
630
+ renameSync(tmp, p);
631
+ writeJson(res, 200, { ok: true, name, size: Buffer.byteLength(content, 'utf8') });
632
+ } catch (e) { writeJson(res, 500, { error: String(e?.message ?? e) }); }
633
+ return;
634
+ }
635
+ const q = new URL(req.url, 'http://x').searchParams;
636
+ const id = q.get('id') || '';
637
+ const dir = presetDirSafe(id);
638
+ const name = String(q.get('name') || '');
639
+ if (!dir || !PRESET_EDIT_EXT.test(name)) return writeJson(res, 400, { error: '参数不合法' });
640
+ const p = join(dir, name);
641
+ if (!existsSync(p) || !resolve(p).startsWith(resolve(dir) + sep)) return writeJson(res, 404, { error: '文件不存在' });
642
+ try { writeJson(res, 200, { id, name, content: readFileSync(p, 'utf8') }); }
643
+ catch (e) { writeJson(res, 500, { error: String(e?.message ?? e) }); }
644
+ });
645
+
646
+ // ── QQ 菜单/指令面板管理(2026-09-07): 经 QQBot.api 网关转发官方接口(v2/menu、v2/panels CRUD+target)。
647
+ // 面板=机器人在会话/群里的指令入口; 菜单=长按/下拉快捷菜单。白名单 path, 仅本机 loopback 可调。
648
+ const QQ_API_PATH_RE = new RegExp('^/v2/(menu|panels)(/[A-Za-z0-9_-]+)?(/target)?$');
649
+ route(ctx, 'POST', '/api/qqbot-settings/qq/proxy', async (req, res) => {
650
+ const body = await readJsonBody(req);
651
+ const ns = String(body?.ns ?? '');
652
+ const method = String(body?.method ?? 'GET').toUpperCase();
653
+ const path = String(body?.path ?? '');
654
+ if (!QQ_API_PATH_RE.test(path)) return writeJson(res, 400, { error: '仅支持 /v2/menu 与 /v2/panels 相关接口' });
655
+ if (!['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) return writeJson(res, 400, { error: '不支持的方法' });
656
+ try {
657
+ const gc = await groupClientOf(ns);
658
+ const apiG = gc && gc.client && gc.client.api;
659
+ if (!apiG || typeof apiG.get !== 'function') return writeJson(res, 400, { error: '找不到该账号实例(未配置/未连接)' });
660
+ const fn = apiG[method.toLowerCase()];
661
+ if (typeof fn !== 'function') return writeJson(res, 400, { error: '网关不支持 ' + method });
662
+ const out = (method === 'GET' || method === 'DELETE')
663
+ ? await fn(path, body && body.query)
664
+ : await fn(path, body && body.body);
665
+ writeJson(res, 200, out ?? { ok: true });
666
+ } catch (e) {
667
+ const msg = e instanceof Error ? e.message : String(e);
668
+ const code = e && e.code;
669
+ writeJson(res, 200, { ok: false, err: { code: code || 'QQ_API_ERROR', human: msg } });
670
+ }
671
+ });
672
+
673
+ // ── 扫码绑定注册流程(每个账号走 QQ 官方绑定, 拿平台下发的 appId/appSecret) ──(每个账号走 QQ 官方绑定, 拿平台下发的 appId/appSecret) ──
572
674
  // 前端拿 qrUrl 打开授权页(手机 QQ 扫码) → host 轮询 → 完成回填。displayQrCodeToConsole=false。
573
675
  const bindSessions = new Map(); // id -> {url, creds, error, stop}
574
676
  let bindSeq = 0;
@@ -741,6 +843,66 @@ export function apply(ctx) {
741
843
  const NSQ = (u) => (u.searchParams.get('ns') || '').trim() || undefined;
742
844
  const GQ = (u) => (u.searchParams.get('gid') || '').trim();
743
845
 
846
+ // ── Web 会话 → QQ 目标反查(悬浮球自动选目标用) ──
847
+ // dsh-qqbot 的 sessionId = sha256(`qqbot:{appId}:{scope}:{peerId}`) 前 32 hex 排成 UUID。
848
+ // 输入 web 当前会话 sessionId, 遍历所有实例的注册表群 + 台账私聊, 命中即返回归属。
849
+ function deriveSessionIdOf(appId, scope, peerId) {
850
+ const hash = createHash('sha256').update(`qqbot:${appId}:${scope}:${peerId}`).digest('hex');
851
+ return `${hash.slice(0, 8)}-${hash.slice(8, 12)}-${hash.slice(12, 16)}-${hash.slice(16, 20)}-${hash.slice(20, 32)}`;
852
+ }
853
+ function chatLedgerNames(dataDir) {
854
+ const out = new Map(); // key=scope:id -> {name, ts}
855
+ try {
856
+ const raw = readFileSync(join(dataDir, 'known-chats.jsonl'), 'utf8');
857
+ for (const l of raw.split('\n')) {
858
+ const t = l.trim(); if (!t) continue;
859
+ try {
860
+ const o = JSON.parse(t);
861
+ if (o && typeof o.id === 'string' && (o.scope === 'group' || o.scope === 'c2c') && o.id) {
862
+ const key = o.scope + ':' + o.id;
863
+ const cur = out.get(key);
864
+ if (!cur || (o.ts || 0) > cur.ts) out.set(key, { name: o.name || '', ts: o.ts || 0 });
865
+ }
866
+ } catch { /* 坏行 */ }
867
+ }
868
+ } catch { /* 无台账 */ }
869
+ return out;
870
+ }
871
+ route(ctx, 'GET', '/api/qqbot-settings/session-lookup', async (req, res) => {
872
+ try {
873
+ const u = new URL(req.url ?? '/', 'http://x');
874
+ const sessionId = (u.searchParams.get('sessionId') || '').trim();
875
+ if (!sessionId) return writeJson(res, 400, { error: 'sessionId 必填' });
876
+ const { bots } = parsePatch();
877
+ const hits = [];
878
+ for (const bot of bots) {
879
+ if (bot.disabled || !bot.cfg?.appId || !bot.cfg?.cwd) continue;
880
+ const appId = bot.cfg.appId;
881
+ const cwd = bot.cfg.cwd;
882
+ const ns = bot.id;
883
+ const ledger = chatLedgerNames(join(cwd, '表情包'));
884
+ // 群: 注册表 + 台账
885
+ const groups = readGroupsJson(cwd);
886
+ const gids = new Set([...Object.keys(groups), ...[...ledger.keys()].filter((k) => k.startsWith('group:')).map((k) => k.slice(6))]);
887
+ for (const gid of gids) {
888
+ if (!gid) continue;
889
+ if (deriveSessionIdOf(appId, 'group', gid) !== sessionId) continue;
890
+ const regMeta = groups[gid];
891
+ const ld = ledger.get('group:' + gid);
892
+ hits.push({ ns, scope: 'group', peerId: gid, name: (regMeta && regMeta.name) || (ld && ld.name) || '' });
893
+ }
894
+ // 私聊: 台账 c2c
895
+ for (const [key, ld] of ledger) {
896
+ if (!key.startsWith('c2c:')) continue;
897
+ const openid = key.slice(4);
898
+ if (!openid || deriveSessionIdOf(appId, 'c2c', openid) !== sessionId) continue;
899
+ hits.push({ ns, scope: 'c2c', peerId: openid, name: ld.name || '' });
900
+ }
901
+ }
902
+ writeJson(res, 200, { ok: true, sessionId, hits });
903
+ } catch (e) { writeJson(res, 500, { error: String((e && e.message) || e) }); }
904
+ });
905
+
744
906
  // 群列表(注册表 + pending 出现过的群 + 台账群; 逐个调官方 info 校验有效性+补群名, 11255=群已注销/不存在则标记失效并过滤)
745
907
  route(ctx, 'GET', '/api/qqbot-settings/group/accounts', async (req, res) => {
746
908
  try {
@@ -907,6 +1069,755 @@ export function apply(ctx) {
907
1069
  } catch (e) { writeJson(res, 500, { error: String((e && e.message) || e) }); }
908
1070
  });
909
1071
 
1072
+ // 面板代发消息: 以机器人身份向群发文本 {ns?, gid, text, insertContext?}
1073
+ // insertContext=true → 发完后往该 QQ 会话 append 一条 user/message(模拟用户消息, 内容以
1074
+ // 「用户代你发送: 」开头, web 流可见、不唤醒、不开回合——与入群申请通知同款姿势)。
1075
+ // 该文本只进 web 流, QQ 群里收到的是干净原文。
1076
+ route(ctx, 'POST', '/api/qqbot-settings/group/send', async (req, res) => {
1077
+ const body = await readJsonBody(req);
1078
+ if (!body || typeof body !== 'object') return writeJson(res, 400, { error: 'bad body' });
1079
+ const gid = String(body.gid || '').trim();
1080
+ const text = String(body.text || '').trim();
1081
+ if (!gid || !text) return writeJson(res, 400, { error: 'gid 与 text 必填' });
1082
+ if (text.length > 2000) return writeJson(res, 400, { error: '文本过长(最多 2000 字符)' });
1083
+ try {
1084
+ const gc = await groupClientOf(String(body.ns || ''));
1085
+ if (!gc) return writeJson(res, 400, { error: '找不到该账号实例(请先在账号页配置 appId/appSecret)' });
1086
+ const r = await gc.client.sendGroupText(gid, text);
1087
+ audit(gc.bot.cwd, { ev: 'group.send', ns: gc.bot.id, gid, text: text.slice(0, 120), ok: r.ok, code: r.ok ? undefined : (r.err && r.err.code) });
1088
+ let ctxNote = '';
1089
+ if (r.ok && body.insertContext === true) {
1090
+ const why = await appendUserRelayToPeer(String(body.ns || 'im-qqbot'), 'group', gid, text).catch((e) => { audit(gc.bot.cwd, { ev: 'group.send.insertContext-failed', ns: gc.bot.id, error: String((e && e.message) || e) }); return 'failed'; });
1091
+ if (why) audit(gc.bot.cwd, { ev: 'group.send.insertContext-skipped', ns: gc.bot.id, gid, reason: why });
1092
+ ctxNote = why ? '(' + (WHY_MAP[why] || why) + ')' : '(已写入上下文: 用户代你发送)';
1093
+ }
1094
+ writeJson(res, r.ok ? 200 : 200, r.ok ? { ok: true, msg: '已发送 ✓ ' + ctxNote } : { ok: false, err: r.err });
1095
+ } catch (e) { writeJson(res, 500, { error: String((e && e.message) || e) }); }
1096
+ });
1097
+
1098
+ // 面板代发私聊: 以机器人身份向用户发文本 {ns?, openid, text, insertContext?}(悬浮球 dock 发消息用)
1099
+ route(ctx, 'POST', '/api/qqbot-settings/chat/send', async (req, res) => {
1100
+ const body = await readJsonBody(req);
1101
+ if (!body || typeof body !== 'object') return writeJson(res, 400, { error: 'bad body' });
1102
+ const openid = String(body.openid || '').trim();
1103
+ const text = String(body.text || '').trim();
1104
+ if (!openid || !text) return writeJson(res, 400, { error: 'openid 与 text 必填' });
1105
+ if (text.length > 2000) return writeJson(res, 400, { error: '文本过长(最多 2000 字符)' });
1106
+ try {
1107
+ const gc = await groupClientOf(String(body.ns || ''));
1108
+ if (!gc) return writeJson(res, 400, { error: '找不到该账号实例(请先在账号页配置 appId/appSecret)' });
1109
+ const r = await gc.client.sendC2cText(openid, text);
1110
+ audit(gc.bot.cwd, { ev: 'chat.send', ns: gc.bot.id, openid, text: text.slice(0, 120), ok: r.ok, code: r.ok ? undefined : (r.err && r.err.code) });
1111
+ let ctxNote = '';
1112
+ if (r.ok && body.insertContext === true) {
1113
+ const why = await appendUserRelayToPeer(String(body.ns || 'im-qqbot'), 'c2c', openid, text).catch((e) => { audit(gc.bot.cwd, { ev: 'chat.send.insertContext-failed', ns: gc.bot.id, error: String((e && e.message) || e) }); return 'failed'; });
1114
+ if (why) audit(gc.bot.cwd, { ev: 'chat.send.insertContext-skipped', ns: gc.bot.id, openid, reason: why });
1115
+ ctxNote = why ? '(' + (WHY_MAP[why] || why) + ')' : '(已写入上下文: 用户代你发送)';
1116
+ }
1117
+ writeJson(res, r.ok ? 200 : 200, r.ok ? { ok: true, msg: '已发送 ✓ ' + ctxNote } : { ok: false, err: r.err });
1118
+ } catch (e) { writeJson(res, 500, { error: String((e && e.message) || e) }); }
1119
+ });
1120
+
1121
+ // ── 悬浮球「💬 聊天视图」(2026-09-07): 读某 QQ 会话最近的入站/出站消息, 只读不建会话 ──
1122
+ // 会话事件结构(宿主 dsh-session): {type, seq, time, data}; user/message 的 data=消息体根(data.content),
1123
+ // assistant/message 的 data={turn,step,message:{role,content,…}}。只收这两型:
1124
+ // · user/message — 群友/对方发言(左气泡)。滤 source.kind='plugin'(runtime context/系统注入)与
1125
+ // 伪造系统通知(入群申请/定时, 内容无 [当前时间 头); 剥「[当前时间 …]」头、
1126
+ // QQ 入站「[昵称 (openid)]」外壳、<@openid> 提及(换成群成员昵称)、尾部 (@you)、
1127
+ // 长附件 URL(折叠成 [图片] 等)。面板代发(「用户代你发送: 」)标 tag=面板代发。
1128
+ // · assistant/message — 机器人回复(右气泡)。只取 content 里 type=text 块; 纯思考/纯工具步(无正文)跳过。
1129
+ // chunk/tool-call/tool-result/step/turn/request 等一律不取 → 天然滤掉流式与工具噪声。
1130
+ // 分页: 事件 seq 全序(含 chunk 等), 倒扫快进后只保留聊天两型, 每页默认 50 条升序返回 + hasMore。
1131
+ function chatTextOf(blocks) {
1132
+ if (!Array.isArray(blocks)) return '';
1133
+ const parts = [];
1134
+ for (const b of blocks) if (b && b.type === 'text' && typeof b.text === 'string' && b.text) parts.push(b.text);
1135
+ return parts.join('\n').trim();
1136
+ }
1137
+ function chatPeelTimeHead(text) {
1138
+ // QQ 入站文本头形如 "[当前时间 2026-09-05 周六 19:08]\n\n"(可能 \r\n), 整体剥掉
1139
+ return String(text || '').replace(/^\s*\[当前时间[^\]]*\]\s*\r?\n?/, '');
1140
+ }
1141
+ function chatDisplayClean(text, nameByMid) {
1142
+ let s = String(text || '');
1143
+ // <@openid> 提及 → @昵称(群成员表有则换名, 否则 @短id)
1144
+ s = s.replace(/<@([A-Za-z0-9_-]{6,})>/g, (all, id) => {
1145
+ const nm = (nameByMid && nameByMid.get && nameByMid.get(id)) || '';
1146
+ return nm ? ('@' + nm) : ('@' + id.slice(0, 6));
1147
+ });
1148
+ // 行尾 (@you) 等点名标记剥掉(那是给 LLM 的, QQ 界面不显示)
1149
+ s = s.replace(/\s*\(@you\)\s*$/, '');
1150
+ return s.trim();
1151
+ }
1152
+ const QQ_MEDIA_RE = /(?:multimedia\.nt\.qq\.com\.cn|multimedia\.qq\.com|qpic\.cn|qlogo\.cn)/i;
1153
+ const QQ_URL_RE = /https?:\/\/[^\s\]\))]+/g;
1154
+ // 从聊天文本抽离 QQ 媒体 URL(图/附件直显用), 附件描述行整行吞掉; 返回 {text, images}
1155
+ // 面板代发媒体标记 "[MEDIA:类型|来源]": 网络图直接当图; 本机路径图/音频/视频转成 raw-media 直出 URL
1156
+ function chatMediaSrcToImg(kind, src) {
1157
+ const k = String(kind || '')
1158
+ .replace(/图片|img/gi, 'image').replace(/视频|video/gi, 'video')
1159
+ .replace(/语音|音频|voice/gi, 'voice').replace(/文件|file/gi, 'file');
1160
+ const s = String(src || '').trim();
1161
+ if (!s) return null;
1162
+ if (/^https?:\/\//i.test(s)) return { url: s, kind: k };
1163
+ if (k === 'image' || k === 'voice' || k === 'video') {
1164
+ return { url: '/api/qqbot-settings/chat/raw-media?p=' + encodeURIComponent(s), kind: k };
1165
+ }
1166
+ return null; // 本地文件类型不确定时不渲染, 保留文字
1167
+ }
1168
+ // 按扩展名判本机文件类型(整行本地路径的文本消息也能看图/播音频)
1169
+ function chatLocalExtKind(path) {
1170
+ const ext = String(path || '').split('?')[0].split('.').pop().toLowerCase();
1171
+ if (/^(jpe?g|png|gif|webp|bmp)$/.test(ext)) return 'image';
1172
+ if (/^(mp3|wav|ogg|opus|m4a|aac|amr|silk)$/.test(ext)) return 'voice';
1173
+ if (/^(mp4|webm|mov|m4v)$/.test(ext)) return 'video';
1174
+ return null;
1175
+ }
1176
+ // 来源短名(标注用): 路径/URL 尾段去 query
1177
+ function chatSrcShortName(src) {
1178
+ let s = String(src || '').trim();
1179
+ const i = Math.max(s.lastIndexOf('/'), s.lastIndexOf('\\'));
1180
+ if (i >= 0) s = s.slice(i + 1);
1181
+ return s.replace(/[?#].*$/, '') || src || '';
1182
+ }
1183
+ function chatLocalRawUrl(p) {
1184
+ return '/api/qqbot-settings/chat/raw-media?p=' + encodeURIComponent(p);
1185
+ }
1186
+ // 附件行/URL 归属类型: 文件名/URL fname 扩展名优先(voice/image/file), 无扩展名看行标签与上下文
1187
+ // 语音上下文(- ASR:/[Voice message])→ voice; 默认 image(QQ 图消息常走 [附件: url] 无扩展名)
1188
+ function chatAttachmentKind(line, hasVoiceCtx, defaultKind) {
1189
+ const l = String(line || '');
1190
+ // 从行内或 URL fname 参数取文件名
1191
+ let name = '';
1192
+ const fm = l.match(/fname=([^&\s]+)/);
1193
+ if (fm) { try { name = decodeURIComponent(fm[1]); } catch { name = fm[1]; } }
1194
+ const am = l.match(/\[Attachment:\s*([^\s\]]+)/i);
1195
+ if (am) name = am[1];
1196
+ const extM = name.match(/\.([a-z0-9]{2,5})$/i);
1197
+ const ext = extM ? extM[1].toLowerCase() : '';
1198
+ if (/^(ogg|amr|silk|mp3|wav|opus|m4a|aac)$/.test(ext) || /\[语音|\[音频|\[Voice message\]/i.test(l)) return 'voice';
1199
+ if (/^(jpe?g|png|gif|webp|bmp)$/.test(ext)) return 'image';
1200
+ if (/^(mp4|webm|mov|m4v)$/.test(ext)) return 'video';
1201
+ if (/^(zip|rar|7z|tar|gz|pdf|doc|docx|xls|xlsx|ppt|pptx|txt|csv|apk|exe|iso|json|md|html?|js|py|ts|xml)$/.test(ext)) return 'file';
1202
+ if (/\[文件:|\[File:|\[附件:|- File:|\bFile:/i.test(l)) return 'file';
1203
+ if (hasVoiceCtx) return 'voice';
1204
+ return defaultKind || 'image';
1205
+ }
1206
+ // 文件显示名: URL fname 参数解码优先, 否则路径尾名
1207
+ function chatFileDisplayName(u) {
1208
+ try { const q = new URL(u).searchParams.get('fname'); if (q) return decodeURIComponent(q); } catch { /* 继续 */ }
1209
+ return chatSrcShortName(u) || '文件';
1210
+ }
1211
+ function chatSplitMedia(text) {
1212
+ const images = [];
1213
+ const lines = String(text || '').split('\n');
1214
+ const out = [];
1215
+ // 语音消息上下文: 该条消息含 ASR/Voice message/语音标记 → 附件 URL 按音频渲染(可播放)
1216
+ const hasVoiceCtx = /- ASR:|\[Voice message\]|\[语音|\[音频|voice/i.test(String(text || ''));
1217
+ for (const line of lines) {
1218
+ const raw = line.trim();
1219
+ if (!raw) { out.push(''); continue; }
1220
+ // QQ 语音转文字: "- ASR: 内容" → 当消息正文保留
1221
+ if (/^- ASR:/i.test(raw)) { out.push(raw.replace(/^- ASR:\s*/i, '').trim()); continue; }
1222
+ // "[Voice message] 内容" → 剥壳当正文(与 ASR 重复时可留, dock 不丢文字)
1223
+ if (/^\[Voice message\]/i.test(raw)) { const vt = raw.replace(/^\[Voice message\]\s*/i, '').trim(); if (vt) out.push(vt); continue; }
1224
+ // 面板代发媒体标记(用户代发上下文格式): [MEDIA:图片|D:\…] / [MEDIA:图片|http…]
1225
+ const md = raw.match(/^\[MEDIA:([^\]|]+)\|([^\]]+)\]$/i);
1226
+ if (md) {
1227
+ const got = chatMediaSrcToImg(md[1], md[2]);
1228
+ const sn = chatSrcShortName(md[2]);
1229
+ if (got) {
1230
+ images.push({ url: got.url, kind: got.kind === 'image' ? 'image' : got.kind });
1231
+ const icon = got.kind === 'voice' ? '🎵' : got.kind === 'video' ? '🎬' : '📷';
1232
+ out.push(icon + ' ' + sn);
1233
+ } else {
1234
+ out.push('📎 ' + sn);
1235
+ }
1236
+ continue;
1237
+ }
1238
+ // 整行就是一个本机绝对路径(主人贴 D:\…\a.jpg / a.mp3 文本) → 按扩展名转可看/可播
1239
+ const lp = raw.match(/^([A-Za-z]:[\\/].+)$/);
1240
+ if (lp) {
1241
+ const lk = chatLocalExtKind(lp[1]);
1242
+ if (lk) { images.push({ url: chatLocalRawUrl(lp[1]), kind: lk }); continue; }
1243
+ }
1244
+ // 附件描述行: "- Attachment URLs: …" / "- File: …" / "[附件: …]" / "[文件: …]" / "[图片: …]" / "[Attachment: name -> …]"
1245
+ if (/^-\s*Attachment URLs:/i.test(raw) || /^-\s*File:/i.test(raw) || /^\[(图片|附件|文件|语音|视频|音频):/i.test(raw) || /^\[Attachment:|^\[File:/i.test(raw)) {
1246
+ const urls = raw.match(QQ_URL_RE) || [];
1247
+ let got = 0;
1248
+ for (const u of urls) {
1249
+ const k = chatAttachmentKind(raw, hasVoiceCtx, undefined);
1250
+ // 图片/音频须是 QQ 媒体域; 文件(ftn.qq.com 等任意域)直接收
1251
+ if (k === 'file') { images.push({ url: u, kind: 'file', name: chatFileDisplayName(u) }); got++; }
1252
+ else if (QQ_MEDIA_RE.test(u)) { images.push({ url: u, kind: k }); got++; }
1253
+ }
1254
+ if (!got) {
1255
+ // 无 URL 的 File/附件描述行(如 "- File: a.zip (3.0MB)")→ 保留可读文本, 不给空占位
1256
+ const desc = raw
1257
+ .replace(/^-\s*File:\s*/i, '').replace(/^\[File:\s*/i, '')
1258
+ .replace(/^\[(图片|附件|文件|语音|视频|音频):\s*/i, '').trim().replace(/\]$/, '').trim();
1259
+ if (desc && !/^https?:/i.test(desc) && !/^Attachment\s*:/i.test(desc)) out.push('📎 ' + desc);
1260
+ }
1261
+ continue;
1262
+ }
1263
+ // 整行就是一个 QQ 媒体 URL(纯图片/音频消息体) → 当媒体
1264
+ const urls = raw.match(QQ_URL_RE) || [];
1265
+ if (urls.length === 1 && QQ_MEDIA_RE.test(raw) && raw === urls[0]) {
1266
+ images.push({ url: urls[0], kind: chatAttachmentKind(raw, hasVoiceCtx, 'image') });
1267
+ continue;
1268
+ }
1269
+ // 普通行: 行内嵌的 QQ 媒体长 URL 抽走(防撑爆气泡), 其余保留
1270
+ if (urls.length) {
1271
+ let rest = raw;
1272
+ for (const u of urls) {
1273
+ if (QQ_MEDIA_RE.test(u)) { images.push({ url: u, kind: chatAttachmentKind(raw, hasVoiceCtx, 'image') }); rest = rest.split(u).join(''); }
1274
+ }
1275
+ rest = rest.trim();
1276
+ if (rest) out.push(rest);
1277
+ continue;
1278
+ }
1279
+ out.push(raw);
1280
+ }
1281
+ // 去重保序
1282
+ // 去重: 同一 URL 在多行出现(描述行/标签行/Attachment 行)时保留最精确的类型
1283
+ // 权重: voice/video(扩展名实锤)=3 > image(图片扩展名)/file(标签/扩展名)=2 > 默认 image=1
1284
+ const kindW = (x) => {
1285
+ const k = typeof x === 'string' ? 'image' : ((x && x.kind) || 'image');
1286
+ if (k === 'voice' || k === 'video') return 3;
1287
+ if (k === 'file') return 2;
1288
+ return 2; // image(含默认)
1289
+ };
1290
+ const byUrl = new Map();
1291
+ for (const x of images) {
1292
+ const key = typeof x === 'string' ? x : (x && x.url);
1293
+ if (!key) continue;
1294
+ const prev = byUrl.get(key);
1295
+ if (!prev || kindW(x) >= kindW(prev)) byUrl.set(key, x);
1296
+ }
1297
+ const uniq = [...byUrl.values()];
1298
+ // 正文行去重(ASR 与 [Voice message] 常同文, 不重复显示)
1299
+ const rawLines = out.join('\n').split('\n');
1300
+ const textOut = rawLines.filter((l, i) => rawLines.indexOf(l) === i).join('\n').trim();
1301
+ return { text: textOut, images: uniq };
1302
+ }
1303
+ // 群延迟/冷却历史打包的分行器: [Chat history begins]…[Chat history ends] + [Current message]
1304
+ // 每行 "[昵称 (openid)] 内容" 开新气泡, 后续行并入该气泡; 系统提示段起截断(后面是给 LLM 的注入)
1305
+ function chatSplitHistoryBlock(body) {
1306
+ const out = [];
1307
+ let cur = null;
1308
+ const flush = () => {
1309
+ if (cur) {
1310
+ const t = cur.lines.join('\n').trim();
1311
+ out.push({ sender: cur.sender, text: t });
1312
+ }
1313
+ cur = null;
1314
+ };
1315
+ for (const line of String(body || '').split('\n')) {
1316
+ const s = line.trim();
1317
+ if (!s) continue;
1318
+ if (/^\[Chat history begins\]$/.test(s) || /^\[Chat history ends\]$/.test(s) || /^\[Current message\]$/.test(s) || /^\[当前时间/.test(s)) continue;
1319
+ if (/^\[系统提示\]/.test(s)) { flush(); return out; } // 系统注入段, 之后都不属于群聊内容
1320
+ const m = s.match(/^\[([^\]\n]*?)\s*\([A-Za-z0-9_-]{6,}\)\](.*)$/);
1321
+ if (m) { flush(); cur = { sender: m[1].trim(), lines: [m[2].trim()].filter(Boolean) }; }
1322
+ else { if (cur) cur.lines.push(s); else { flush(); cur = { sender: '', lines: [s] }; } }
1323
+ }
1324
+ flush();
1325
+ return out;
1326
+ }
1327
+ // 单条/子条通用清洗: 去 [Current message] 标记、引用块折叠、提取昵称标签、剥 [系统提示]、
1328
+ // 去提及/@you、抽离 QQ 媒体 URL。返回 { sender, mm:{text,images} }
1329
+ function chatPolishOne(body, fallbackSender, nameByMid) {
1330
+ let sender = fallbackSender;
1331
+ let b = String(body || '');
1332
+ b = b.replace(/\[Current message\]\s*/g, '');
1333
+ b = b.replace(/\[Quoted message begins\]\s*[\s\S]*?\[Quoted message ends\]\s*/g, '[引用]');
1334
+ const tagM = b.match(/\[([^\]\n]*?)\s*\([A-Za-z0-9_-]{6,}\)\]/);
1335
+ if (tagM) { if (tagM[1].trim()) sender = tagM[1].trim(); b = b.replace(tagM[0], ''); }
1336
+ const sysIdx = b.indexOf('[系统提示]');
1337
+ if (sysIdx >= 0) b = b.slice(0, sysIdx).replace(/\s*$/, '');
1338
+ const mm = chatSplitMedia(chatDisplayClean(b, nameByMid));
1339
+ return { sender, mm };
1340
+ }
1341
+ // 解码一条会话事件为聊天条目(群打包会展开成多条); 非聊天事件/噪声返回 null
1342
+ function chatDecodeEvent(ev, scope, nameByMid, fallbackSender) {
1343
+ if (!ev || typeof ev.type !== 'string') return null;
1344
+ const data = ev.data && typeof ev.data === 'object' ? ev.data : {};
1345
+ const time = typeof ev.time === 'number' ? ev.time : 0;
1346
+ if (ev.type === 'user/message') {
1347
+ const src = data.source && typeof data.source === 'object' ? data.source : {};
1348
+ if (src.kind === 'plugin') return null; // runtime context / 系统注入等, 非真人对话
1349
+ const raw0 = chatTextOf(data.content);
1350
+ if (!raw0) return null;
1351
+ const raw = chatPeelTimeHead(raw0);
1352
+ const isRelay = /^用户代你发送: /.test(raw);
1353
+ // 后台任务/面板大文件完成通知([系统] 后台任务…)→ 显示为 bot 侧气泡并打来源标; 其余系统注入滤掉
1354
+ const isBg = /^\[系统\]\s*后台任务/.test(raw0);
1355
+ // 伪造/系统注入(无 [当前时间 头且非 web 直聊): 入群申请/定时 → 滤(QQ 里并没有这句话)
1356
+ if (!/^\[当前时间 /.test(raw0) && !src.rpcId) {
1357
+ if (/^\[(入群申请|定时|到点)/.test(raw0)) return null;
1358
+ if (/^\[系统\]/.test(raw0) && !isBg) return null;
1359
+ }
1360
+ const isWeb = !!src.rpcId; // web 直聊(同一会话, 主人手打)
1361
+ let sender = fallbackSender;
1362
+ let body = raw;
1363
+ if (isRelay) body = body.replace(/^用户代你发送:\s*/, '');
1364
+ if (isBg) body = body.replace(/^\[系统\]\s*/, '');
1365
+ // 群延迟/冷却历史打包: [Chat history begins]…[Chat history ends] + [Current message]
1366
+ // 历史段里每条都是真实发生过的群消息 → 逐条拆成独立气泡(不裁不丢), 返回多条
1367
+ if (body.indexOf('[Chat history begins]') >= 0) {
1368
+ const parts = chatSplitHistoryBlock(body);
1369
+ const out = [];
1370
+ for (const p of parts) {
1371
+ const polished = chatPolishOne(p.text, p.sender || fallbackSender, nameByMid);
1372
+ if (!polished.mm.text && polished.mm.images.length === 0) continue;
1373
+ out.push({ seq: ev.seq, time, dir: 'in', sender: polished.sender || '', text: polished.mm.text, images: polished.mm.images, tag: '' });
1374
+ }
1375
+ return out.length ? out : null;
1376
+ }
1377
+ // 普通单条(去标记/剥昵称壳/剥系统提示/抽媒体)
1378
+ const polished = chatPolishOne(body, sender, nameByMid);
1379
+ sender = polished.sender;
1380
+ const mm = polished.mm;
1381
+ if (!mm.text && mm.images.length === 0) return null;
1382
+ // 面板代发/后台任务完成 = bot 侧事实 → 右气泡 + 来源标(面板代发 / 后台任务)
1383
+ return {
1384
+ seq: ev.seq, time,
1385
+ dir: (isRelay || isBg) ? 'out' : 'in',
1386
+ sender: isBg ? '' : (sender || ''),
1387
+ text: mm.text,
1388
+ images: mm.images,
1389
+ tag: isBg ? '后台任务' : (isRelay ? '面板代发' : (isWeb ? 'Web' : '')),
1390
+ };
1391
+ }
1392
+ if (ev.type === 'assistant/message') {
1393
+ const msg = data.message && typeof data.message === 'object' ? data.message : null;
1394
+ if (!msg) return null;
1395
+ const text0 = chatTextOf(msg.content);
1396
+ if (!text0) return null; // 纯思考/纯工具步(没对群友说话)跳过
1397
+ const text = chatDisplayClean(text0, nameByMid);
1398
+ const mm = chatSplitMedia(text);
1399
+ if (!mm.text && mm.images.length === 0) return null;
1400
+ return { seq: ev.seq, time, dir: 'out', sender: '', text: mm.text, images: mm.images, tag: '' };
1401
+ }
1402
+ return null;
1403
+ }
1404
+ // GET /chat/history?ns&scope&peerId&beforeSeq&limit —— 聊天视图数据源
1405
+ route(ctx, 'GET', '/api/qqbot-settings/chat/history', async (req, res) => {
1406
+ try {
1407
+ const u = new URL(req.url ?? '/', 'http://x');
1408
+ const ns = String(u.searchParams.get('ns') || '').trim() || undefined;
1409
+ const scope = String(u.searchParams.get('scope') || '').trim();
1410
+ const peerId = String(u.searchParams.get('peerId') || '').trim();
1411
+ if (scope !== 'group' && scope !== 'c2c') return writeJson(res, 400, { error: 'scope 必须为 group|c2c' });
1412
+ if (!peerId) return writeJson(res, 400, { error: 'peerId 必填' });
1413
+ const limit = Math.max(1, Math.min(200, Math.round(Number(u.searchParams.get('limit'))) || 50));
1414
+ const bRaw = Number(u.searchParams.get('beforeSeq'));
1415
+ const beforeSeq = Number.isFinite(bRaw) && bRaw > 0 ? Math.floor(bRaw) : undefined;
1416
+ const bot = nsBot(ns);
1417
+ if (!bot) return writeJson(res, 400, { error: '找不到该账号实例(请先在账号页配置 appId/appSecret)' });
1418
+ const reg = await import('./dist/features/session-registry.js');
1419
+ const rec = typeof reg.findRecordByPeerWeb === 'function'
1420
+ ? reg.findRecordByPeerWeb(String(ns || 'im-qqbot'), scope, peerId) : undefined;
1421
+ if (!rec || !rec.agent) {
1422
+ return writeJson(res, 200, { ok: true, code: 'no-session', items: [], hasMore: false, msg: '该目标暂无活跃会话' });
1423
+ }
1424
+ const agent = rec.agent;
1425
+ const sess = agent && (agent.session || (agent.ctx && agent.ctx.session));
1426
+ if (!sess || typeof sess.seq !== 'number') {
1427
+ return writeJson(res, 200, { ok: true, code: 'no-session', items: [], hasMore: false, msg: '会话未就绪' });
1428
+ }
1429
+ const snap = typeof sess.snapshotEvents === 'function' ? sess.snapshotEvents.bind(sess) : undefined;
1430
+ // 名字映射: c2c 用台账兜底 sender; group 读群成员表把 <@openid> 换成昵称
1431
+ const dataDir = join(bot.cwd, '表情包');
1432
+ const ledger = chatLedgerNames(dataDir);
1433
+ const nameByMid = new Map();
1434
+ if (scope === 'group') {
1435
+ try {
1436
+ const lm = await import('./dist/features/chat-ledger.js');
1437
+ const mems = (lm.readGroupMembers && lm.readGroupMembers(dataDir, peerId)) || [];
1438
+ for (const m of mems) if (m && m.mid && m.name) nameByMid.set(m.mid, m.name);
1439
+ } catch { /* 无成员表不阻断 */ }
1440
+ }
1441
+ const fallbackSender = scope === 'c2c' ? ((ledger.get('c2c:' + peerId) || {}).name || '') : '';
1442
+ // 尾部倒扫: 默认从最新一条事件 seq 往前; beforeSeq=加载更早(beforeSeq 之前的)
1443
+ const items = [];
1444
+ let high = beforeSeq !== undefined ? Math.max(0, beforeSeq - 1) : Math.max(0, sess.seq - 1);
1445
+ let reachedEnd = false;
1446
+ const STEP = 500;
1447
+ while (high >= 0 && items.length < limit) {
1448
+ const low = Math.max(0, high - STEP + 1);
1449
+ let evs = [];
1450
+ if (snap) { try { evs = snap(low, high + 1) || []; } catch { evs = []; } }
1451
+ if (!evs.length) { const all = sess.events; if (Array.isArray(all) && all.length && all.length > low) evs = all.slice(low, high + 1); }
1452
+ for (let i = evs.length - 1; i >= 0; i--) {
1453
+ const got = chatDecodeEvent(evs[i], scope, nameByMid, fallbackSender);
1454
+ if (got) {
1455
+ if (Array.isArray(got)) { for (const g of got) items.push(g); } // 群打包历史 → 多条
1456
+ else items.push(got);
1457
+ }
1458
+ if (items.length >= limit) { reachedEnd = !(i > 0 || low > 0); break; }
1459
+ }
1460
+ if (items.length >= limit) break;
1461
+ if (low === 0) { reachedEnd = true; break; }
1462
+ high = low - 1;
1463
+ }
1464
+ items.reverse(); // 升序(旧→新)
1465
+ // QQ 媒体域的语音 URL → 经 /chat/voice-play 转 mp3 播放(浏览器解不了 SILK)
1466
+ for (const it of items) {
1467
+ if (!Array.isArray(it.images)) continue;
1468
+ for (const m of it.images) {
1469
+ if (m && typeof m === 'object' && m.kind === 'voice' && typeof m.url === 'string'
1470
+ && /^https?:/i.test(m.url) && QQ_MEDIA_RE.test(m.url)) {
1471
+ m.url = '/api/qqbot-settings/chat/voice-play?u=' + encodeURIComponent(m.url);
1472
+ }
1473
+ }
1474
+ }
1475
+ // 群图片/附件消息不带昵称壳 → 用之前最近一条有名字的群友文本消息推断发送者(只影响展示)
1476
+ {
1477
+ let prev = '';
1478
+ for (const it of items) {
1479
+ if (it.dir === 'in') {
1480
+ if (!it.sender && it.tag !== 'Web') it.sender = prev;
1481
+ if (it.text && it.tag !== 'Web') prev = it.sender;
1482
+ }
1483
+ }
1484
+ }
1485
+ writeJson(res, 200, { ok: true, items, hasMore: !reachedEnd, tailSeq: sess.seq });
1486
+ } catch (e) { writeJson(res, 500, { error: String((e && e.message) || e) }); }
1487
+ });
1488
+
1489
+ // 面板富媒体发送: dock 聊天输入框 bbcode [MEDIA:kind|src] → 以机器人身份向群/私聊发富媒体。
1490
+ // src = http(s) 网络图/文件, 或本机绝对路径(D:\xxx\a.jpg)。复用 agentCtx 的
1491
+ // qqChannel.sender.sendMedia(与通道 send_media 工具同一咽喉, 含表情包闸门/撤回记账)。
1492
+ let hostBgSeq = 0; // dock 面板大文件后台任务序号(与通道工具后台任务同款语义)
1493
+ route(ctx, 'POST', '/api/qqbot-settings/chat/media', async (req, res) => {
1494
+ const body = await readJsonBody(req);
1495
+ if (!body || typeof body !== 'object') return writeJson(res, 400, { error: 'bad body' });
1496
+ const scope = String(body.scope || '');
1497
+ const peerId = String(body.peerId || '').trim();
1498
+ const kind = String(body.kind || '');
1499
+ const url = String(body.url || '').trim();
1500
+ const localPath = String(body.localPath || '').trim();
1501
+ if (scope !== 'group' && scope !== 'c2c') return writeJson(res, 400, { error: 'scope 必须为 group|c2c' });
1502
+ if (!peerId) return writeJson(res, 400, { error: 'peerId 必填' });
1503
+ if (!['image', 'video', 'voice', 'file'].includes(kind)) return writeJson(res, 400, { error: 'kind 必须为 image|video|voice|file' });
1504
+ const isUrl = /^https?:\/\//i.test(url);
1505
+ if (!isUrl && !localPath) return writeJson(res, 400, { error: 'url(http 链接) 或 localPath(本机路径) 至少给一个' });
1506
+ if (localPath) {
1507
+ // 本机直读: 面板和宿主同机, 文件存在才发(防手误/路径注入)
1508
+ try {
1509
+ const st = statSync(localPath);
1510
+ if (!st.isFile()) return writeJson(res, 200, { ok: false, msg: '本地文件不存在或不是文件: ' + localPath });
1511
+ if (st.size <= 0) return writeJson(res, 200, { ok: false, msg: '本地文件为空(0 字节)' });
1512
+ } catch {
1513
+ return writeJson(res, 200, { ok: false, msg: '读不到本地文件: ' + localPath + '(确认路径存在)' });
1514
+ }
1515
+ }
1516
+ const ns = String(body.ns || 'im-qqbot').trim();
1517
+ const kindLbl = kind === 'image' ? '图片' : kind === 'video' ? '视频' : kind === 'voice' ? '语音' : '文件';
1518
+ try {
1519
+ const reg = await import('./dist/features/session-registry.js');
1520
+ const rec = typeof reg.findRecordByPeerWeb === 'function' ? reg.findRecordByPeerWeb(ns, scope, peerId) : undefined;
1521
+ if (!rec || !rec.agent) return writeJson(res, 200, { ok: false, msg: '该目标暂无活跃会话(让机器人先聊过几句)' });
1522
+ const aCtx = rec.agent && (rec.agent.ctx || (rec.agent).agentCtx);
1523
+ const ch = aCtx && typeof aCtx.get === 'function' ? aCtx.get('qqChannel') : undefined;
1524
+ const sm = ch && ch.sender && typeof ch.sender.sendMedia === 'function' ? ch.sender : undefined;
1525
+ if (!sm) return writeJson(res, 200, { ok: false, msg: '机器人通道未就绪(给该目标发条消息后再试)' });
1526
+ const srcDesc = localPath ? localPath : url.slice(0, 120);
1527
+ // 本地大文件(≥5MB): 后台异步发送(分片耗时), 立即返回; 完成/失败往会话写 [系统] 后台任务通知(dock 可见+来源标)
1528
+ let localSize = 0;
1529
+ if (localPath) { try { localSize = statSync(localPath).size; } catch { localSize = 0; } }
1530
+ const FILE_ASYNC_MIN = 5 * 1024 * 1024;
1531
+ if (localPath && localSize >= FILE_ASYNC_MIN) {
1532
+ const seq = ++hostBgSeq;
1533
+ const fname = localPath.split(/[\\/]/).pop() || 'file';
1534
+ const fnameClean = String(fname).split('?')[0];
1535
+ const doSend = () => sm.sendMedia({ scope, targetId: peerId }, kind, { localPath });
1536
+ void (async () => {
1537
+ let okNote = '成功';
1538
+ try {
1539
+ await doSend();
1540
+ if (body.insertContext === true) {
1541
+ try {
1542
+ const relayText = String(body.relayText || '').slice(0, 500) || localPath;
1543
+ await appendUserRelayToPeer(ns, scope, peerId, '[MEDIA:' + kindLbl + '|' + relayText + ']');
1544
+ } catch { /* 上下文写入失败不影响发送结果提示 */ }
1545
+ }
1546
+ } catch (e) {
1547
+ okNote = '失败: ' + String((e && e.message) || e).slice(0, 120);
1548
+ const bot2 = nsBot(ns || undefined);
1549
+ if (bot2 && bot2.cwd) audit(bot2.cwd, { ev: 'chat.media.bg', ns, scope, peerId, kind, src: srcDesc, ok: false, error: okNote });
1550
+ }
1551
+ await appendNoticeToPeer(ns, scope, peerId, '[系统] 后台任务 #' + seq + ' ' + (okNote === '成功' ? ('完成: 已发送 ' + fnameClean + '(' + Math.round(localSize / 1048576 * 10) / 10 + 'MB)。') : ('失败: ' + okNote)));
1552
+ })();
1553
+ const bot0 = nsBot(ns || undefined);
1554
+ if (bot0 && bot0.cwd) audit(bot0.cwd, { ev: 'chat.media.bg-start', ns, scope, peerId, kind, src: srcDesc, size: localSize });
1555
+ return writeJson(res, 200, { ok: true, msg: '📤 大文件已提交后台任务 #' + seq + '(' + Math.round(localSize / 1048576 * 10) / 10 + 'MB), 分片上传中… 完成后会有通知', id: undefined });
1556
+ }
1557
+ const r = await sm.sendMedia({ scope, targetId: peerId }, kind, localPath ? { localPath } : { url });
1558
+ const bot = nsBot(ns || undefined);
1559
+ if (bot && bot.cwd) audit(bot.cwd, { ev: 'chat.media', ns, scope, peerId, kind, src: srcDesc, ok: true });
1560
+ // 与文本发送一致: insertContext=true → 往该会话写「用户代你发送: …」模拟消息(web 流/dock 可见, 不唤醒不开回合)
1561
+ // 文本用 [MEDIA:类型|来源] 结构化 → dock 能渲染本地上传图, bot 上下文也知道发了哪个来源
1562
+ let ctxNote = '';
1563
+ if (body.insertContext === true) {
1564
+ const relayText = String(body.relayText || '').slice(0, 500);
1565
+ const inner = relayText || srcDesc || kindLbl;
1566
+ const why = await appendUserRelayToPeer(ns, scope, peerId, '[MEDIA:' + kindLbl + '|' + inner + ']').catch(() => 'failed');
1567
+ if (why) { if (bot && bot.cwd) audit(bot.cwd, { ev: 'chat.media.relay-skip', ns, reason: why }); ctxNote = ' (' + (WHY_MAP[why] || why) + ')'; }
1568
+ else ctxNote = ' (已记入上下文)';
1569
+ }
1570
+ writeJson(res, 200, { ok: true, msg: '已发送' + kindLbl + ' ✓' + ctxNote, id: r && r.id });
1571
+ } catch (e) {
1572
+ const msg = String((e && e.message) || e);
1573
+ const bot = nsBot(ns || undefined);
1574
+ if (bot && bot.cwd) audit(bot.cwd, { ev: 'chat.media', ns, scope, peerId, kind, src: localPath || url.slice(0, 120), ok: false, error: msg });
1575
+ writeJson(res, 200, { ok: false, msg: '发送失败: ' + msg });
1576
+ }
1577
+ });
1578
+
1579
+ // 面板文件选择器后端(小文件 base64 版): dock 旧客户端/小文件用, 落 dock-uploads。
1580
+ route(ctx, 'POST', '/api/qqbot-settings/chat/upload', async (req, res) => {
1581
+ const body = await readJsonBody(req);
1582
+ if (!body || typeof body !== 'object') return writeJson(res, 400, { error: 'bad body' });
1583
+ const bot = nsBot(String(body.ns || '').trim() || undefined);
1584
+ if (!bot || !bot.cwd) return writeJson(res, 400, { error: '找不到该账号实例(请先在账号页配置 appId/appSecret)' });
1585
+ const data = String(body.data || '');
1586
+ const m = data.match(/^data:([^;]+);base64,(.*)$/s) || data.match(/^base64:(.*)$/s);
1587
+ const b64 = m ? (m[2] || '') : data;
1588
+ if (!b64) return writeJson(res, 400, { error: 'data(base64) 必填' });
1589
+ let ext = String(body.ext || '').replace(/[^a-z0-9]/gi, '').toLowerCase();
1590
+ if (!ext) ext = 'bin';
1591
+ if (ext.length > 6) return writeJson(res, 400, { error: '扩展名不合法' });
1592
+ try {
1593
+ const buf = Buffer.from(b64, 'base64');
1594
+ if (buf.length <= 0) return writeJson(res, 200, { ok: false, msg: '空文件' });
1595
+ const dir = join(bot.cwd, '.qqbot', 'dock-uploads');
1596
+ mkdirSync(dir, { recursive: true });
1597
+ const name = 'up' + Date.now() + '-' + Math.floor(Math.random() * 1e6) + '.' + ext;
1598
+ const target = join(dir, name);
1599
+ writeFileSync(target, buf);
1600
+ writeJson(res, 200, { ok: true, path: target });
1601
+ } catch (e) {
1602
+ writeJson(res, 500, { error: String((e && e.message) || e) });
1603
+ }
1604
+ });
1605
+
1606
+ // 面板文件选择器后端(大文件流式版): dock 📎 选中的任意大小文件直接以 octet-stream body 上传,
1607
+ // 流式写盘(不占内存上限 readJsonBody 的 8MB), 落 cwd/.qqbot/dock-uploads → client 插 [MEDIA:kind|path]。
1608
+ // 参数走 query: ns(账号实例), ext(扩展名); body = 原始文件字节。
1609
+ route(ctx, 'POST', '/api/qqbot-settings/chat/upload-raw', async (req, res) => {
1610
+ try {
1611
+ const u = new URL(req.url ?? '/', 'http://x');
1612
+ const bot = nsBot(String(u.searchParams.get('ns') || '').trim() || undefined);
1613
+ if (!bot || !bot.cwd) return writeJson(res, 400, { error: '找不到该账号实例(请先在账号页配置 appId/appSecret)' });
1614
+ let ext = String(u.searchParams.get('ext') || '').replace(/[^a-z0-9]/gi, '').toLowerCase().slice(0, 6);
1615
+ if (!ext) ext = 'bin';
1616
+ const dir = join(bot.cwd, '.qqbot', 'dock-uploads');
1617
+ mkdirSync(dir, { recursive: true });
1618
+ const name = 'up' + Date.now() + '-' + Math.floor(Math.random() * 1e6) + '.' + ext;
1619
+ const target = join(dir, name);
1620
+ const ws = createWriteStream(target);
1621
+ const MAX = 300 * 1024 * 1024;
1622
+ let size = 0;
1623
+ let failed = null;
1624
+ try {
1625
+ for await (const chunk of req) {
1626
+ size += chunk.length;
1627
+ if (size > MAX) { failed = 'too large(>300MB)'; break; }
1628
+ if (!ws.write(chunk)) await new Promise((r) => ws.once('drain', r));
1629
+ }
1630
+ } catch (e) {
1631
+ failed = String((e && e.message) || e);
1632
+ }
1633
+ await new Promise((r) => ws.end(r));
1634
+ if (failed || size <= 0) {
1635
+ try { rmSync(target, { force: true }); } catch { /* 忽略 */ }
1636
+ return writeJson(res, failed ? 413 : 400, { error: failed || '空文件' });
1637
+ }
1638
+ writeJson(res, 200, { ok: true, path: target, size });
1639
+ } catch (e) {
1640
+ writeJson(res, 500, { error: String((e && e.message) || e) });
1641
+ }
1642
+ });
1643
+
1644
+ // dock 本地图片直出(渲染用户代发/本地上传图): 读本机文件回 bytes。
1645
+ // 仅本机同源可访问(route fence); <img> 不能跨域读取内容, 只作图片预览用。
1646
+ route(ctx, 'GET', '/api/qqbot-settings/chat/raw-media', async (req, res) => {
1647
+ try {
1648
+ const u = new URL(req.url ?? '/', 'http://x');
1649
+ const p = String(u.searchParams.get('p') || '').trim();
1650
+ if (!p) return writeJson(res, 400, { error: 'p 必填' });
1651
+ const st = statSync(p);
1652
+ if (!st.isFile()) return writeJson(res, 404, { error: 'file not found' });
1653
+ if (st.size > 200 * 1024 * 1024) return writeJson(res, 413, { error: 'too large(>200MB)' });
1654
+ const ext = extname(p).slice(1).toLowerCase();
1655
+ const AUDIO = { ogg: 'audio/ogg', opus: 'audio/ogg', mp3: 'audio/mpeg', wav: 'audio/wav', m4a: 'audio/mp4', aac: 'audio/aac', amr: 'audio/amr', silk: 'audio/silk', webm: 'audio/webm' };
1656
+ const VIDEO = { mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime', m4v: 'video/mp4' };
1657
+ const type = MIME[ext] || AUDIO[ext] || VIDEO[ext] || 'application/octet-stream';
1658
+ // 支持 Range(视频/音频拖动播放需要): bytes=a-b
1659
+ let start = 0;
1660
+ let end = st.size - 1;
1661
+ let status = 200;
1662
+ const range = req.headers && req.headers.range;
1663
+ if (range) {
1664
+ const m = /bytes=(\d*)-(\d*)/.exec(String(range));
1665
+ if (m) {
1666
+ const s1 = m[1] === '' ? undefined : parseInt(m[1], 10);
1667
+ const e1 = m[2] === '' ? undefined : parseInt(m[2], 10);
1668
+ if (s1 !== undefined && s1 < st.size) start = s1;
1669
+ if (e1 !== undefined && e1 < st.size) end = e1;
1670
+ if (end < start) end = Math.min(start + 1024 * 1024, st.size - 1);
1671
+ status = 206;
1672
+ }
1673
+ }
1674
+ const len = end - start + 1;
1675
+ const fd = openSync(p, 'r');
1676
+ const buf = Buffer.alloc(len);
1677
+ try { readSync(fd, buf, 0, len, start); } finally { closeSync(fd); }
1678
+ const headers = {
1679
+ 'content-type': type,
1680
+ 'content-length': len,
1681
+ 'cache-control': 'no-store',
1682
+ 'accept-ranges': 'bytes',
1683
+ };
1684
+ if (status === 206) headers['content-range'] = 'bytes ' + start + '-' + end + '/' + st.size;
1685
+ res.writeHead(status, headers);
1686
+ res.end(buf);
1687
+ } catch (e) {
1688
+ writeJson(res, 404, { error: String((e && e.message) || e) });
1689
+ }
1690
+ });
1691
+
1692
+ // dock QQ 语音播放: 下载 SILK → 纯 JS(silk-wasm + lamejs-fixed)转 mp3 → 返回(带磁盘缓存)。
1693
+ // 零系统依赖(不依赖 ffmpeg/外部工具), 通用插件可直接打包; 浏览器播放 mp3 兼容性最好。
1694
+ route(ctx, 'GET', '/api/qqbot-settings/chat/voice-play', async (req, res) => {
1695
+ try {
1696
+ const u = new URL(req.url ?? '/', 'http://x');
1697
+ const src = String(u.searchParams.get('u') || '').trim();
1698
+ if (!/^https?:\/\//i.test(src) || !QQ_MEDIA_RE.test(src)) return writeJson(res, 400, { error: 'u 必须为 QQ 媒体 URL' });
1699
+ const bot = nsBot(undefined);
1700
+ const cacheDir = join((bot && bot.cwd) || homedir(), '.qqbot', 'voice-cache');
1701
+ mkdirSync(cacheDir, { recursive: true });
1702
+ const key = createHash('sha256').update(src).digest('hex');
1703
+ const cf = join(cacheDir, key + '.mp3');
1704
+ if (!existsSync(cf)) {
1705
+ const resp = await fetch(src, { headers: { 'user-agent': 'Mozilla/5.0' } });
1706
+ if (!resp.ok) return writeJson(res, 502, { error: '语音下载失败(HTTP ' + resp.status + ')' });
1707
+ const buf = Buffer.from(await resp.arrayBuffer());
1708
+ const mod = await import('./voice-convert.mjs');
1709
+ if (!mod.isSilk(buf)) return writeJson(res, 415, { error: '该语音非 SILK 格式(QQ 语音均为 SILK)' });
1710
+ const { mp3 } = await mod.silkToMp3(buf);
1711
+ try { writeFileSync(cf, mp3); } catch { /* 缓存失败不影响播放 */ }
1712
+ }
1713
+ const mp3 = readFileSync(cf);
1714
+ res.writeHead(200, { 'content-type': 'audio/mpeg', 'content-length': mp3.length, 'cache-control': 'public, max-age=86400' });
1715
+ res.end(mp3);
1716
+ } catch (e) {
1717
+ writeJson(res, 500, { error: String((e && e.message) || e) });
1718
+ }
1719
+ });
1720
+
1721
+ const WHY_MAP = { busy: '目标会话回合活跃(思考/流式中),已跳过注入', 'no-session': '未找到该群/私聊的活跃会话', 'no-msg': '构造消息失败', failed: '写入失败' };
1722
+
1723
+ // 安全闸: 目标会话 LLM 回合活跃(turn/start 已开、turn/end 未闭合——覆盖思考中/流式输出/工具执行)
1724
+ // 时禁止外部往该会话插入任何消息(含模拟用户消息), 防止把正在生成的回合流搞乱(曾因此写坏会话)。
1725
+ // 查不到则放行(append 自身有 reenter 兜底)。
1726
+ function sessionTurnActive(sess) {
1727
+ try {
1728
+ const seq = typeof sess?.seq === 'number' ? sess.seq : -1;
1729
+ if (seq <= 0 || typeof sess.snapshotEvents !== 'function') return false;
1730
+ const tail = sess.snapshotEvents(Math.max(0, seq - 400), seq);
1731
+ let lastStart = -1;
1732
+ let lastEnd = -1;
1733
+ for (const ev of tail) {
1734
+ if (ev.type === 'turn/start') lastStart = ev.seq;
1735
+ else if (ev.type === 'turn/end') lastEnd = ev.seq;
1736
+ }
1737
+ return lastStart > lastEnd;
1738
+ } catch { return false; }
1739
+ }
1740
+
1741
+ // 线B(用户代发→插入上下文): 往目标 QQ 会话 append 一条 user/message 模拟用户消息, 不唤醒、不开回合。
1742
+ // 文本以「用户代你发送: 」开头(只进 web 流, QQ 收到干净原文); 主人下次真人消息开回合时, 该
1743
+ // user/message 作为历史被 deriveMessages 组装进上下文 → bot 自然看到"主人代我发了这句"。
1744
+ // 活跃回合/找不到会话时跳过并在 audit 留痕。
1745
+ async function appendUserRelayToPeer(ns, scope, peerId, text) {
1746
+ const reg = await import('./dist/features/session-registry.js');
1747
+ let rec = typeof reg.findRecordByPeerWeb === 'function' ? reg.findRecordByPeerWeb(ns, scope, peerId) : undefined;
1748
+ // 活跃表 miss(会话被 idle 回收/未建立)→ 与入群申请通知/定时任务同款: getOrCreate 恢复/重建会话(不开回合),
1749
+ // 保证 log 存在能 append(否则 findRecordByPeerWeb 永远 no-session, QQ 发出但 web 流无痕)。
1750
+ if (!rec && typeof reg.getOrCreateByPeerWeb === 'function') {
1751
+ try { rec = await reg.getOrCreateByPeerWeb(ns, scope, peerId, 'master'); } catch { /* 恢复失败按 no-session 走 */ }
1752
+ }
1753
+ if (!rec || !rec.agent) return 'no-session';
1754
+ const agent = rec.agent;
1755
+ const sess = agent && (agent.session || (agent.ctx && agent.ctx.session));
1756
+ const appendFn = sess && typeof sess.append === 'function' ? sess.append.bind(sess) : undefined;
1757
+ if (!appendFn) return 'no-session';
1758
+ if (sessionTurnActive(sess)) return 'busy'; // 🔒 LLM 回合活跃(思考/流式中), 不插入
1759
+ const llm = await import('@deepseek-ai/dsh-llm');
1760
+ const relayText = `用户代你发送: ${text}`;
1761
+ const msg = llm.createUserMessage
1762
+ ? llm.createUserMessage({ content: [{ type: 'text', text: relayText }], source: { kind: 'user' } })
1763
+ : undefined;
1764
+ if (!msg) return 'no-msg';
1765
+ // 与 agent-loop turn()/入群申请通知同款: user/message 的 data 就是消息体本身(不包 message 层)。
1766
+ appendFn('user/message', msg, { surfaceOp: 'append' });
1767
+ return null;
1768
+ }
1769
+
1770
+ // 后台任务结果通知写回会话(与通道工具 bgSend notifySession 同款形状: source kind user, 文本 [系统] 后台任务…)
1771
+ // dock 解码时按 isBg 显示为 bot 侧气泡 + 「后台任务」来源标。
1772
+ async function appendNoticeToPeer(ns, scope, peerId, text) {
1773
+ try {
1774
+ const reg = await import('./dist/features/session-registry.js');
1775
+ let rec = typeof reg.findRecordByPeerWeb === 'function' ? reg.findRecordByPeerWeb(ns, scope, peerId) : undefined;
1776
+ if (!rec && typeof reg.getOrCreateByPeerWeb === 'function') {
1777
+ try { rec = await reg.getOrCreateByPeerWeb(ns, scope, peerId, 'master'); } catch { /* 忽略 */ }
1778
+ }
1779
+ if (!rec || !rec.agent) return false;
1780
+ const agent = rec.agent;
1781
+ const sess = agent && (agent.session || (agent.ctx && agent.ctx.session));
1782
+ const appendFn = sess && typeof sess.append === 'function' ? sess.append.bind(sess) : undefined;
1783
+ if (!appendFn || sessionTurnActive(sess)) return false;
1784
+ appendFn('user/message', {
1785
+ id: 'bg-' + Date.now().toString(36) + '-' + Math.floor(Math.random() * 1e9).toString(36),
1786
+ role: 'user',
1787
+ content: [{ type: 'text', text: String(text || '') }],
1788
+ source: { kind: 'user' },
1789
+ }, { surfaceOp: 'append' });
1790
+ return true;
1791
+ } catch { return false; }
1792
+ }
1793
+
1794
+ // 入群申请红点汇总(悬浮球 dock 用): 遍历所有已配置实例, 返回各实例待审申请数(计数不上报明细)
1795
+ route(ctx, 'GET', '/api/qqbot-settings/group/join-summary', async (_req, res) => {
1796
+ try {
1797
+ const { bots } = parsePatch();
1798
+ const out = [];
1799
+ for (const bot of bots) {
1800
+ if (bot.disabled || !bot.cfg?.appId || !bot.cfg?.appSecret) continue;
1801
+ try {
1802
+ const ns = bot.id;
1803
+ const gc = await groupClientOf(ns);
1804
+ if (!gc) continue;
1805
+ const reg = readGroupsJson(bot.cwd);
1806
+ let pending = 0;
1807
+ for (const gid of Object.keys(reg)) {
1808
+ if (!gid) continue;
1809
+ try {
1810
+ const r = await gc.client.listJoinRequests(gid);
1811
+ if (r.ok && Array.isArray(r.data?.list)) pending += r.data.list.length;
1812
+ } catch { /* 单群失败不阻断 */ }
1813
+ }
1814
+ out.push({ ns, pending });
1815
+ } catch { /* 实例失败跳过 */ }
1816
+ }
1817
+ writeJson(res, 200, { ok: true, items: out });
1818
+ } catch (e) { writeJson(res, 500, { error: String((e && e.message) || e) }); }
1819
+ });
1820
+
910
1821
  // 绑定/登记群 {ns?, gid, name?}
911
1822
  route(ctx, 'POST', '/api/qqbot-settings/group/bind', async (req, res) => {
912
1823
  const body = await readJsonBody(req);
@@ -924,4 +1835,50 @@ export function apply(ctx) {
924
1835
  } catch (e) { writeJson(res, 500, { error: String((e && e.message) || e) }); }
925
1836
  });
926
1837
 
1838
+ // ── 审批双通道: Web 浮层同源路由(读待办 / 点按钮结算) ──
1839
+ // 数据来自各实例 QqApprovalController(经 registerApprovalController 注册表),
1840
+ // 与 QQ 按钮卡片/文本码共用同一 pending —— 先到先得, 两端天然同步。
1841
+ route(ctx, 'GET', '/api/qqbot-settings/approval/pending', async (_req, res) => {
1842
+ try {
1843
+ const mod = await import('./dist/features/qq-approval.js');
1844
+ const list = typeof mod.listAllPendingWeb === 'function' ? mod.listAllPendingWeb() : [];
1845
+ writeJson(res, 200, { ok: true, pending: list });
1846
+ } catch (e) { writeJson(res, 500, { error: String((e && e.message) || e) }); }
1847
+ });
1848
+ // {code, act:'allow'|'deny'}
1849
+ route(ctx, 'POST', '/api/qqbot-settings/approval/decide', async (req, res) => {
1850
+ const body = await readJsonBody(req);
1851
+ if (!body || typeof body !== 'object') return writeJson(res, 400, { error: 'bad body' });
1852
+ const code = String(body.code || '').toUpperCase();
1853
+ const act = String(body.act || '');
1854
+ if (!code || (act !== 'allow' && act !== 'deny')) return writeJson(res, 400, { error: 'code 与 act(allow|deny) 必填' });
1855
+ try {
1856
+ const mod = await import('./dist/features/qq-approval.js');
1857
+ const r = typeof mod.decideByWebAny === 'function' ? mod.decideByWebAny(code, act) : { ok: false, msg: '审批模块不可用' };
1858
+ writeJson(res, r.ok ? 200 : 404, r);
1859
+ } catch (e) { writeJson(res, 500, { error: String((e && e.message) || e) }); }
1860
+ });
1861
+
1862
+ // ── 提问双通道: Web 浮层同源路由(读待办问题 / 点选项结算) ──
1863
+ route(ctx, 'GET', '/api/qqbot-settings/questions/pending', async (_req, res) => {
1864
+ try {
1865
+ const mod = await import('./dist/features/qq-user-questions.js');
1866
+ const list = typeof mod.listAllPendingQuestionsWeb === 'function' ? mod.listAllPendingQuestionsWeb() : [];
1867
+ writeJson(res, 200, { ok: true, pending: list });
1868
+ } catch (e) { writeJson(res, 500, { error: String((e && e.message) || e) }); }
1869
+ });
1870
+ // {key, optIdx}
1871
+ route(ctx, 'POST', '/api/qqbot-settings/questions/decide', async (req, res) => {
1872
+ const body = await readJsonBody(req);
1873
+ if (!body || typeof body !== 'object') return writeJson(res, 400, { error: 'bad body' });
1874
+ const key = String(body.key || '');
1875
+ const optIdx = Number(body.optIdx);
1876
+ if (!key || !Number.isInteger(optIdx) || optIdx < 0) return writeJson(res, 400, { error: 'key 与 optIdx 必填' });
1877
+ try {
1878
+ const mod = await import('./dist/features/qq-user-questions.js');
1879
+ const r = typeof mod.decideQuestionByWebAny === 'function' ? mod.decideQuestionByWebAny(key, optIdx) : { ok: false, msg: '提问模块不可用' };
1880
+ writeJson(res, r.ok ? 200 : 404, r);
1881
+ } catch (e) { writeJson(res, 500, { error: String((e && e.message) || e) }); }
1882
+ });
1883
+
927
1884
  }