@zaofan/dsh-qqbot 1.1.0 → 1.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client/qqbot-settings.js +571 -52
- package/dist/api/group-admin.d.ts +22 -0
- package/dist/api/group-admin.d.ts.map +1 -1
- package/dist/api/group-admin.js +32 -0
- package/dist/api/group-admin.js.map +1 -1
- package/dist/channel-tools.d.ts.map +1 -1
- package/dist/channel-tools.js +192 -77
- package/dist/channel-tools.js.map +1 -1
- package/dist/commands/botplay.d.ts.map +1 -1
- package/dist/commands/botplay.js +6 -2
- package/dist/commands/botplay.js.map +1 -1
- package/dist/commands/index.d.ts.map +1 -1
- package/dist/commands/index.js +6 -2
- package/dist/commands/index.js.map +1 -1
- package/dist/commands/inject.d.ts +17 -0
- package/dist/commands/inject.d.ts.map +1 -0
- package/dist/commands/inject.js +70 -0
- package/dist/commands/inject.js.map +1 -0
- package/dist/commands/misc.d.ts +1 -1
- package/dist/commands/misc.d.ts.map +1 -1
- package/dist/commands/misc.js +25 -3
- package/dist/commands/misc.js.map +1 -1
- package/dist/commands/session.d.ts +2 -0
- package/dist/commands/session.d.ts.map +1 -1
- package/dist/commands/session.js +41 -3
- package/dist/commands/session.js.map +1 -1
- package/dist/config.d.ts +6 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +2 -1
- package/dist/config.js.map +1 -1
- package/dist/features/botplay-store.d.ts +13 -0
- package/dist/features/botplay-store.d.ts.map +1 -0
- package/dist/features/botplay-store.js +99 -0
- package/dist/features/botplay-store.js.map +1 -0
- package/dist/features/botplay.d.ts +8 -0
- package/dist/features/botplay.d.ts.map +1 -1
- package/dist/features/botplay.js +22 -7
- package/dist/features/botplay.js.map +1 -1
- package/dist/features/broadcast.d.ts +63 -0
- package/dist/features/broadcast.d.ts.map +1 -0
- package/dist/features/broadcast.js +225 -0
- package/dist/features/broadcast.js.map +1 -0
- package/dist/features/card-callback.d.ts +52 -0
- package/dist/features/card-callback.d.ts.map +1 -0
- package/dist/features/card-callback.js +133 -0
- package/dist/features/card-callback.js.map +1 -0
- package/dist/features/group-hub.d.ts +2 -0
- package/dist/features/group-hub.d.ts.map +1 -1
- package/dist/features/group-hub.js +4 -5
- package/dist/features/group-hub.js.map +1 -1
- package/dist/features/group-join-request.d.ts.map +1 -1
- package/dist/features/group-join-request.js +7 -1
- package/dist/features/group-join-request.js.map +1 -1
- package/dist/features/preset-switcher.d.ts +39 -0
- package/dist/features/preset-switcher.d.ts.map +1 -0
- package/dist/features/preset-switcher.js +172 -0
- package/dist/features/preset-switcher.js.map +1 -0
- package/dist/features/session-registry.d.ts +1 -1
- package/dist/features/session-registry.d.ts.map +1 -1
- package/dist/features/session-registry.js +30 -1
- package/dist/features/session-registry.js.map +1 -1
- package/dist/gateway/bootstrap.d.ts.map +1 -1
- package/dist/gateway/bootstrap.js +51 -4
- package/dist/gateway/bootstrap.js.map +1 -1
- package/dist/gateway/debounce.d.ts.map +1 -1
- package/dist/gateway/debounce.js +7 -0
- package/dist/gateway/debounce.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +24 -5
- package/dist/index.js.map +1 -1
- package/dist/middleware/media-history.d.ts.map +1 -1
- package/dist/middleware/media-history.js +23 -6
- package/dist/middleware/media-history.js.map +1 -1
- package/dist/session/session-manager.d.ts +10 -0
- package/dist/session/session-manager.d.ts.map +1 -1
- package/dist/session/session-manager.js +45 -0
- package/dist/session/session-manager.js.map +1 -1
- package/dist/setup.d.ts +15 -3
- package/dist/setup.d.ts.map +1 -1
- package/dist/setup.js +51 -32
- package/dist/setup.js.map +1 -1
- package/dist/transport/inbound.d.ts.map +1 -1
- package/dist/transport/inbound.js +46 -50
- package/dist/transport/inbound.js.map +1 -1
- package/dist/transport/inject-rules.d.ts.map +1 -1
- package/dist/transport/inject-rules.js +8 -2
- package/dist/transport/inject-rules.js.map +1 -1
- package/dist/transport/media-kind.d.ts +37 -0
- package/dist/transport/media-kind.d.ts.map +1 -0
- package/dist/transport/media-kind.js +60 -0
- package/dist/transport/media-kind.js.map +1 -0
- package/dist/transport/outbound-buffer.d.ts +20 -2
- package/dist/transport/outbound-buffer.d.ts.map +1 -1
- package/dist/transport/outbound-buffer.js +25 -5
- package/dist/transport/outbound-buffer.js.map +1 -1
- package/dist/transport/outbound.d.ts.map +1 -1
- package/dist/transport/outbound.js +68 -4
- package/dist/transport/outbound.js.map +1 -1
- package/package.json +1 -1
- package/settings-host.js +538 -44
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
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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 {
|
|
@@ -253,9 +282,9 @@ export function apply(ctx) {
|
|
|
253
282
|
/** 解析 patch: 返回 {raw, bots:[{id,start,end,ins,cfg:{appId,appSecret,preset,cwd},disabled}], hasFile}
|
|
254
283
|
* patch 语义(宿主 dsh-app-boot applyEntryPatches): 顶层 `- id: X` 只能覆盖已存在 id(bundle insert 建的);
|
|
255
284
|
* 新增实例必须 `- insert:` 包装(其下 4 空格 `- id: Y`)。本文件里两种形态都识别。 */
|
|
256
|
-
function
|
|
285
|
+
function parsePatchFrom(rawText) {
|
|
257
286
|
const hasFile = existsSync(PATCH_FILE);
|
|
258
|
-
const raw = hasFile ? readFileSync(PATCH_FILE, 'utf8') : '';
|
|
287
|
+
const raw = rawText !== undefined && rawText !== null ? String(rawText) : (hasFile ? readFileSync(PATCH_FILE, 'utf8') : '');
|
|
259
288
|
// 行尾容错: 兼容 CRLF(2026-09-08 曾因 patch 被写成 CRLF 导致整段解析失败)
|
|
260
289
|
const lines = raw.split(/\r?\n/);
|
|
261
290
|
const bots = [];
|
|
@@ -280,7 +309,7 @@ export function apply(ctx) {
|
|
|
280
309
|
}
|
|
281
310
|
j += 1;
|
|
282
311
|
}
|
|
283
|
-
bots.push({ id, start, end: j, ins: false, cfg, disabled });
|
|
312
|
+
bots.push({ id, start, end: j, ins: false, cfg, disabled, headLine: start });
|
|
284
313
|
i = j;
|
|
285
314
|
} else if (isTopLine(t) && /^\s*-\s*insert:\s*$/.test(t)) {
|
|
286
315
|
// insert 形态: 找其下 4 空格 `- id: im-qqbot*`
|
|
@@ -313,7 +342,7 @@ export function apply(ctx) {
|
|
|
313
342
|
if (isTopLine(lines[j])) break;
|
|
314
343
|
j += 1;
|
|
315
344
|
}
|
|
316
|
-
bots.push({ id, start: insStart, end: j, ins: true, cfg, disabled });
|
|
345
|
+
bots.push({ id, start: insStart, end: j, ins: true, cfg, disabled, headLine: found.line });
|
|
317
346
|
i = j;
|
|
318
347
|
} else {
|
|
319
348
|
i += 1;
|
|
@@ -324,6 +353,8 @@ export function apply(ctx) {
|
|
|
324
353
|
}
|
|
325
354
|
return { raw, hasFile, bots };
|
|
326
355
|
}
|
|
356
|
+
/** 从磁盘读取并解析(parsePatchFrom 的包装) */
|
|
357
|
+
function parsePatch() { return parsePatchFrom(undefined); }
|
|
327
358
|
|
|
328
359
|
/** 渲染一个实例块。主 im-qqbot(bundle 已 insert)→ 顶层覆盖; 新实例 → `- insert:` 包装(4/6/8 缩进) */
|
|
329
360
|
function renderBotBlock(inst) {
|
|
@@ -350,47 +381,158 @@ export function apply(ctx) {
|
|
|
350
381
|
return out.join('\n');
|
|
351
382
|
}
|
|
352
383
|
|
|
384
|
+
/** 在已有条目块内只更新指定键,保留原结构(缩进/位置/未知字段如 groupAdmin、dataRoot、name)。
|
|
385
|
+
* ⚠️ 2026-09-10 新增: 原实现用 renderBotBlock 整块重建 —— 会把 insert: 下的 im-qqbot-2 提成顶层
|
|
386
|
+
* `- id: im-qqbot`(新版无此 entry, 直接让整棵插件树加载失败), 并丢掉 name/settingsNs/historyLimit/
|
|
387
|
+
* groupAdmin/dataRoot 等未在渲染里列出的字段。改为原地增量更新后结构不再被破坏。
|
|
388
|
+
* ⚠️ 2026-09-10 二次修复: 除更新已存在的键外, 还要**补写缺失的键**(如原本没有 cwd 的实例),
|
|
389
|
+
* 否则前端填了 cwd 也保存不进去(实测 im-qqbot-3 只更新 3/4 个键)。disabled 走块头层(children of id)。
|
|
390
|
+
* @param {string[]} lines 文件全部行(会被原地修改)
|
|
391
|
+
* @param {number} start 块首行索引(含, 即 `- id:` 或 `- insert:` 行)
|
|
392
|
+
* @param {number} end 块结束行索引(不含)
|
|
393
|
+
* @param {Record<string,string>} kv 要写入的键值(值为字符串); 已存在则改, 缺失则按规矩新增
|
|
394
|
+
* @returns {number} 实际写入的键数 */
|
|
395
|
+
function updateKeysInBlock(lines, start, end, kv) {
|
|
396
|
+
// 块头行: insert 包装时块首是 `- insert:`, 真正的 `- id:` 在下一行
|
|
397
|
+
let headIdx = start;
|
|
398
|
+
for (let i = start; i < Math.min(start + 3, end); i += 1) {
|
|
399
|
+
if (/^\s*-\s*id:/.test(lines[i])) { headIdx = i; break; }
|
|
400
|
+
}
|
|
401
|
+
const headIndent = ((lines[headIdx] ?? '').match(/^\s*/) ?? [''])[0].length;
|
|
402
|
+
const childIndent = headIndent + 2; // name: / disabled: / config: 的缩进
|
|
403
|
+
|
|
404
|
+
const pending = new Map(Object.entries(kv));
|
|
405
|
+
let touched = 0;
|
|
406
|
+
// ① 已存在的键: 原地改写(保留原缩进)
|
|
407
|
+
for (let i = headIdx + 1; i < end && pending.size > 0; i += 1) {
|
|
408
|
+
const m = /^(\s*)([A-Za-z_][A-Za-z0-9_]*):\s*(.*)$/.exec(lines[i]);
|
|
409
|
+
if (!m) continue;
|
|
410
|
+
const key = m[2];
|
|
411
|
+
if (!pending.has(key)) continue;
|
|
412
|
+
lines[i] = `${m[1]}${key}: ${pending.get(key)}`;
|
|
413
|
+
pending.delete(key);
|
|
414
|
+
touched += 1;
|
|
415
|
+
}
|
|
416
|
+
if (pending.size === 0) return touched;
|
|
417
|
+
|
|
418
|
+
// ② 缺失的键: 找本条目自己的 `config:` 行(缩进 = childIndent, 避免命中嵌套的 groupAdmin 等)
|
|
419
|
+
let cfgLine = -1;
|
|
420
|
+
for (let i = headIdx + 1; i < end; i += 1) {
|
|
421
|
+
if (new RegExp(`^\\s{${childIndent}}config:\\s*$`).test(lines[i])) { cfgLine = i; break; }
|
|
422
|
+
}
|
|
423
|
+
const keyIndent = childIndent + 2; // config 下子键的缩进
|
|
424
|
+
const addLines = [];
|
|
425
|
+
for (const [key, val] of pending) {
|
|
426
|
+
// disabled 属于条目层(childIndent), 也放这里 —— YAML 不要求键顺序
|
|
427
|
+
addLines.push(`${' '.repeat(key === 'disabled' ? childIndent : keyIndent)}${key}: ${val}`);
|
|
428
|
+
}
|
|
429
|
+
if (cfgLine >= 0) {
|
|
430
|
+
// 找 config: 之下最后一个同级键(含其子块), 插在后面。
|
|
431
|
+
// 注意: 同级键的缩进 == keyIndent(不是 >), 用 >= 才能走到最后一个键;
|
|
432
|
+
// 带子块的键(如 groupAdmin:) 其子行缩进 > keyIndent 也要一并跨过。
|
|
433
|
+
let scan = cfgLine + 1;
|
|
434
|
+
let lastLine = cfgLine;
|
|
435
|
+
while (scan < end) {
|
|
436
|
+
const blank = lines[scan].trim() === '';
|
|
437
|
+
const ind = ((lines[scan].match(/^\s*/) ?? [''])[0]).length;
|
|
438
|
+
if (!blank && ind >= keyIndent) { lastLine = scan; scan += 1; continue; }
|
|
439
|
+
break;
|
|
440
|
+
}
|
|
441
|
+
lines.splice(lastLine + 1, 0, ...addLines);
|
|
442
|
+
} else {
|
|
443
|
+
// 没有 config: —— 在块头后补一个 config: 再放键
|
|
444
|
+
let insertAt = end;
|
|
445
|
+
for (let i = headIdx + 1; i < end; i += 1) {
|
|
446
|
+
const ind = ((lines[i].match(/^\s*/) ?? [''])[0]).length;
|
|
447
|
+
if (lines[i].trim() !== '' && ind <= headIndent && /- /.test(lines[i])) { insertAt = i; break; }
|
|
448
|
+
}
|
|
449
|
+
lines.splice(insertAt, 0, `${' '.repeat(childIndent)}config:`, ...addLines);
|
|
450
|
+
}
|
|
451
|
+
return touched + addLines.length;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
|
|
353
455
|
/** 保存实例清单(全量同步; 行级重建, 其它插件行/注释原样保留)
|
|
354
456
|
* 借鉴 dsh-qqbot-panel(2026-09-06): appSecret 空值/掩码(********) = 保留原值,
|
|
355
457
|
* 只有提供全新非掩码值才覆盖 —— 前端只回显掩码, 不会因漏传/未改而误清 secret。
|
|
356
458
|
* ⚠️ 2026-09-09: dataRoot 同理 —— 前端不编辑它, 保存时必须保留原值,
|
|
357
|
-
* 否则 accounts/save 会把 patch.yml 里的 dataRoot 覆盖掉(图库路径回退 cwd 的根因)。
|
|
459
|
+
* 否则 accounts/save 会把 patch.yml 里的 dataRoot 覆盖掉(图库路径回退 cwd 的根因)。
|
|
460
|
+
* ⚠️ 2026-09-10: 已存在的条目改为"原地只更新字段", 不再整块重建(见 updateKeysInBlock 注释)。 */
|
|
358
461
|
function saveInstances(instances) {
|
|
359
462
|
const { raw, hasFile, bots } = parsePatch();
|
|
360
463
|
if (!hasFile) return { ok: false, error: '找不到 cordis.patch.yml(仅 web profile 支持)' };
|
|
361
464
|
const lines = raw.split('\n');
|
|
362
|
-
const
|
|
465
|
+
const removals = [];
|
|
466
|
+
const updates = [];
|
|
467
|
+
const appends = [];
|
|
363
468
|
const targetIds = new Set(instances.filter((x) => !x.remove).map((x) => String(x.id)));
|
|
364
|
-
|
|
365
|
-
|
|
469
|
+
// 删除: 只删「没被提交的机器人条目」或显式 remove 的条目, 且范围严格限定在该条目自身:
|
|
470
|
+
// 起点 = 它自己的 `- id:` 行(headLine, 不是包裹它的 `- insert:` 行),
|
|
471
|
+
// 终点 = 下一个同级机器人条目的 headLine(或该 insert 块结束)。
|
|
472
|
+
// ⚠️ 2026-09-10 事故一: 原用 { start: b.start }(insert 包装时为 `- insert:` 行) 且 end 取块末,
|
|
473
|
+
// 导致同一 insert 下的其它插件条目(mcp-chrome)被连带删除。现改为按 headLine 精确切分。
|
|
474
|
+
// ⚠️ 2026-09-10 事故二: 前端把"空骨架实例"(如用于禁用幽灵 im-qqbot 的 `- id: im-qqbot`
|
|
475
|
+
// + disabled: true, appId/appSecret 均为空)过滤掉不提交 → 会被当成"要删除"而误删,
|
|
476
|
+
// 删掉后下次启动又触发扫码。故: 无凭据(appId/appSecret 皆空)的条目一律保留, 不参与自动删除。
|
|
477
|
+
const removeIds = new Set(instances.filter((x) => x.remove).map((x) => String(x.id)));
|
|
478
|
+
const doomed = bots.filter((b) => {
|
|
479
|
+
if (removeIds.has(b.id)) return true;
|
|
480
|
+
if (targetIds.has(b.id)) return false;
|
|
481
|
+
const hasCred = !!(b.cfg?.appId || b.cfg?.appSecret);
|
|
482
|
+
return hasCred;
|
|
483
|
+
});
|
|
484
|
+
for (const b of doomed) {
|
|
485
|
+
const sameGroup = bots
|
|
486
|
+
.filter((o) => o !== b && o.ins === b.ins && o.start === b.start && o.headLine > b.headLine)
|
|
487
|
+
.sort((x, y) => x.headLine - y.headLine);
|
|
488
|
+
const nextHead = sameGroup.length ? sameGroup[0].headLine : b.end;
|
|
489
|
+
let tail = nextHead;
|
|
490
|
+
while (tail > b.headLine + 1 && (lines[tail - 1] ?? '').trim() === '') tail -= 1;
|
|
491
|
+
removals.push({ start: b.headLine, end: tail });
|
|
366
492
|
}
|
|
367
493
|
for (const inst of instances) {
|
|
368
494
|
if (inst.remove) continue;
|
|
369
|
-
const
|
|
370
|
-
const orig = bots.find((b) => b.id === inst.id);
|
|
495
|
+
const orig = bots.find((b) => b.id === String(inst.id));
|
|
371
496
|
// 掩码/空 → 保留原 secret(仅当原值存在; 全新账号本就无原值则维持空)
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
else inst.appSecret = '';
|
|
375
|
-
}
|
|
497
|
+
let secret = String(inst.appSecret ?? '');
|
|
498
|
+
if (!secret || secret === SECRET_MASK) secret = orig?.cfg?.appSecret ? String(orig.cfg.appSecret) : '';
|
|
376
499
|
// 前端不编辑 dataRoot → 保留原值(2026-09-09: 曾因保存覆盖丢失导致图库路径回退 cwd)
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
500
|
+
let dataRoot = inst.dataRoot;
|
|
501
|
+
if (dataRoot === undefined && orig?.cfg?.dataRoot) dataRoot = orig.cfg.dataRoot;
|
|
502
|
+
|
|
503
|
+
if (orig) {
|
|
504
|
+
const kv = {};
|
|
505
|
+
if (inst.appId !== undefined) kv.appId = yq(String(inst.appId));
|
|
506
|
+
if (secret) kv.appSecret = ysec(secret);
|
|
507
|
+
if (inst.preset !== undefined && String(inst.preset) !== '') kv.preset = yq(String(inst.preset));
|
|
508
|
+
if (inst.cwd !== undefined && String(inst.cwd) !== '') kv.cwd = yq(String(inst.cwd));
|
|
509
|
+
if (inst.disabled !== undefined) kv.disabled = inst.disabled ? 'true' : 'false';
|
|
510
|
+
updates.push({ id: String(inst.id), kv });
|
|
511
|
+
} else {
|
|
512
|
+
appends.push(renderBotBlock({ ...inst, appSecret: secret, dataRoot }));
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
// 先删块(倒序), 再逐条原地改字段。
|
|
516
|
+
// ⚠️ 每次更新前重新 parsePatch 取最新下标: splice 过(删除或插入新键)之后,
|
|
517
|
+
// 之前算好的 start/end 会错位, 曾导致插入落到错误缩进上把 YAML 写坏。
|
|
518
|
+
for (const r of removals.sort((a, b) => b.start - a.start)) lines.splice(r.start, r.end - r.start);
|
|
519
|
+
for (const u of updates) {
|
|
520
|
+
let fresh = null;
|
|
521
|
+
try {
|
|
522
|
+
const reparsed = parsePatchFrom(lines.join('\n'));
|
|
523
|
+
fresh = reparsed.bots.find((b) => b.id === u.id) ?? null;
|
|
524
|
+
} catch { fresh = null; }
|
|
525
|
+
if (!fresh) continue;
|
|
526
|
+
updateKeysInBlock(lines, fresh.start, fresh.end, u.kv);
|
|
381
527
|
}
|
|
382
|
-
edits.filter((e) => e.start !== undefined).sort((a, b) => b.start - a.start).forEach((e) => {
|
|
383
|
-
if (e.text === null) lines.splice(e.start, e.end - e.start);
|
|
384
|
-
else lines.splice(e.start, e.end - e.start, ...e.text.split('\n'));
|
|
385
|
-
});
|
|
386
528
|
let out = lines.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd() + '\n';
|
|
387
|
-
|
|
388
|
-
if (appendParts.length) out += (out ? '\n' : '') + appendParts.join('\n') + '\n';
|
|
529
|
+
if (appends.length) out += (out.trimEnd() ? '\n' : '') + appends.join('\n') + '\n';
|
|
389
530
|
writeFileSync(`${PATCH_FILE}.bak`, raw, 'utf8');
|
|
390
531
|
writeFileSync(PATCH_FILE, out, 'utf8');
|
|
391
532
|
return { ok: true, file: PATCH_FILE, needRestart: true };
|
|
392
533
|
}
|
|
393
534
|
|
|
535
|
+
|
|
394
536
|
// 账号列表(多账号: 每条带 settings ns 与数据目录, 供二级 UI 按账号读写)
|
|
395
537
|
// 借鉴 zhengjy01/dsh-qqbot-panel(2026-09-06): appSecret 只回显掩码(不泄露明文),
|
|
396
538
|
// 并带 hasSecret 供前端区分"已保存"与"真空"; 保存时空值/掩码 = 保留原 secret。
|
|
@@ -848,6 +990,30 @@ export function apply(ctx) {
|
|
|
848
990
|
function readPendingJson(cwd) {
|
|
849
991
|
try { return JSON.parse(readFileSync(join(cwd, '.qqbot', 'join-pending.json'), 'utf8') || '{}'); } catch { return {}; }
|
|
850
992
|
}
|
|
993
|
+
// ── botplay 事件独立文件存储(2026-09-10 M4.3): {dataRoot}/botplay-events.json
|
|
994
|
+
// (主人定: 事件配置放工作目录根, 和「表情包」等平级; 曾放 .qqbot 子目录, 读时兼容迁移)
|
|
995
|
+
function readBotplayEventsFile(cwd) {
|
|
996
|
+
try {
|
|
997
|
+
const root = join(cwd, 'botplay-events.json');
|
|
998
|
+
if (existsSync(root)) return JSON.parse(readFileSync(root, 'utf8') || '{}');
|
|
999
|
+
const legacy = join(cwd, '.qqbot', 'botplay-events.json');
|
|
1000
|
+
if (existsSync(legacy)) { try { renameSync(legacy, root); } catch { /* 迁移失败继续读旧 */ } return JSON.parse(readFileSync(legacy, 'utf8') || '{}'); }
|
|
1001
|
+
return {};
|
|
1002
|
+
} catch { return {}; }
|
|
1003
|
+
}
|
|
1004
|
+
function writeBotplayEventsFile(cwd, events) {
|
|
1005
|
+
try {
|
|
1006
|
+
const f = join(cwd, 'botplay-events.json');
|
|
1007
|
+
const tmp = f + '.tmp-' + Date.now();
|
|
1008
|
+
writeFileSync(tmp, JSON.stringify({ version: 1, events: Array.isArray(events) ? events : [] }, null, 2), 'utf8');
|
|
1009
|
+
renameSync(tmp, f);
|
|
1010
|
+
return true;
|
|
1011
|
+
} catch { return false; }
|
|
1012
|
+
}
|
|
1013
|
+
// 从 settings 现值取 botplayEvents(迁移/兜底用)
|
|
1014
|
+
function botplayEventsFromSettings(ns) {
|
|
1015
|
+
try { const v = viewOf(ctx.settings, ns); const ev = v && v.value && v.value.botplayEvents; return Array.isArray(ev) ? ev : []; } catch { return []; }
|
|
1016
|
+
}
|
|
851
1017
|
const NSQ = (u) => (u.searchParams.get('ns') || '').trim() || undefined;
|
|
852
1018
|
const GQ = (u) => (u.searchParams.get('gid') || '').trim();
|
|
853
1019
|
|
|
@@ -1156,6 +1322,246 @@ export function apply(ctx) {
|
|
|
1156
1322
|
} catch (e) { writeJson(res, 500, { error: String((e && e.message) || e) }); }
|
|
1157
1323
|
});
|
|
1158
1324
|
|
|
1325
|
+
// ── 自定义卡片按钮(2026-09-10 主人要求: 卡片按钮要能写「回文本 / 跳链接 / 执行命令」) ──
|
|
1326
|
+
// client 传结构化 cardButtons=[{label, action:'text'|'cmd'|'url', payload}]。
|
|
1327
|
+
// 这里生成 cardId、把按钮动作写进 {dataRoot}/.qqbot/card-callbacks.json —— 格式与 dist 侧
|
|
1328
|
+
// src/features/card-callback.ts 严格一致(改格式两边都要改); 按钮 data 编 `bpk:<cardId>:<btnId>`:
|
|
1329
|
+
// action=url → action.type=0 官方跳转(客户端直接跳, 不走回调)
|
|
1330
|
+
// 其余 → action.type=1 回调 → 点击后 QQ 推 INTERACTION_CREATE → bootstrap 的
|
|
1331
|
+
// cardCallbackController 查表执行(text 回文本 / cmd 走命令执行器)
|
|
1332
|
+
function writeCardCallbacks(dataRoot, card) {
|
|
1333
|
+
try {
|
|
1334
|
+
const p = join(dataRoot, '.qqbot', 'card-callbacks.json');
|
|
1335
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
1336
|
+
let f = { version: 1, cards: [] };
|
|
1337
|
+
try { if (existsSync(p)) f = JSON.parse(readFileSync(p, 'utf8')) || f; } catch { f = { version: 1, cards: [] }; }
|
|
1338
|
+
const now = Date.now();
|
|
1339
|
+
const kept = (Array.isArray(f.cards) ? f.cards : []).filter(c => c && c.cardId !== card.cardId && (c.expireAt || 0) > now);
|
|
1340
|
+
kept.push(card);
|
|
1341
|
+
writeFileSync(p, JSON.stringify({ version: 1, cards: kept.slice(-200) }, null, 1), 'utf8');
|
|
1342
|
+
} catch { /* 写失败只影响按钮回调, 不阻断发卡 */ }
|
|
1343
|
+
}
|
|
1344
|
+
function buildCardKeyboardFromPairs(dataRoot, pairs, meta) {
|
|
1345
|
+
const list = (Array.isArray(pairs) ? pairs : []).filter(b => b && b.label).slice(0, 25);
|
|
1346
|
+
if (!list.length) return undefined;
|
|
1347
|
+
const cardId = 'c' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
|
1348
|
+
const buttons = list.map((b, i) => ({
|
|
1349
|
+
id: 'b' + (i + 1),
|
|
1350
|
+
label: String(b.label).slice(0, 20),
|
|
1351
|
+
action: b.action === 'url' ? 'url' : (b.action === 'cmd' ? 'cmd' : 'text'),
|
|
1352
|
+
payload: String(b.payload == null ? '' : b.payload),
|
|
1353
|
+
}));
|
|
1354
|
+
const rows = buttons.map(b => ({
|
|
1355
|
+
buttons: [{
|
|
1356
|
+
id: b.id,
|
|
1357
|
+
render_data: { label: b.label, style: 1 },
|
|
1358
|
+
action: {
|
|
1359
|
+
type: b.action === 'url' ? 0 : 1, // 0=跳转 1=回调
|
|
1360
|
+
permission: { type: 2 },
|
|
1361
|
+
data: 'bpk:' + cardId + ':' + b.id,
|
|
1362
|
+
unsupport_tips: '请在支持的客户端点击',
|
|
1363
|
+
},
|
|
1364
|
+
}],
|
|
1365
|
+
}));
|
|
1366
|
+
writeCardCallbacks(dataRoot, {
|
|
1367
|
+
cardId,
|
|
1368
|
+
buttons,
|
|
1369
|
+
createdAt: Date.now(),
|
|
1370
|
+
expireAt: Date.now() + 7 * 24 * 3600 * 1000,
|
|
1371
|
+
scope: meta && meta.scope,
|
|
1372
|
+
targetId: meta && meta.targetId,
|
|
1373
|
+
});
|
|
1374
|
+
return { content: { rows } };
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
// ── 自定义 markdown 卡片发送(2026-09-10 主人实测: markdown 嵌网络图+按钮可渲染):
|
|
1378
|
+
// 接收 {ns?, gid | openid, markdown, keyboard?} → 群用 sendGroupCard / 私聊用 sendC2cCard;
|
|
1379
|
+
// 群发走 broadcast/create(type=markdown)。openid 支持为 2026-09-10 主人要求(卡片可选私聊目标)。
|
|
1380
|
+
route(ctx, 'POST', '/api/qqbot-settings/chat/send-card', async (req, res) => {
|
|
1381
|
+
const body = await readJsonBody(req);
|
|
1382
|
+
if (!body || typeof body !== 'object') return writeJson(res, 400, { error: 'bad body' });
|
|
1383
|
+
const gid = String(body.gid || '').trim();
|
|
1384
|
+
const openid = String(body.openid || '').trim();
|
|
1385
|
+
const md = String(body.markdown || '').trim();
|
|
1386
|
+
if (!gid && !openid) return writeJson(res, 400, { error: 'gid 或 openid 必填' });
|
|
1387
|
+
if (!md) return writeJson(res, 400, { error: 'markdown 必填' });
|
|
1388
|
+
if (md.length > 8000) return writeJson(res, 400, { error: 'markdown 过长(最多 8000 字符)' });
|
|
1389
|
+
try {
|
|
1390
|
+
const gc = await groupClientOf(String(body.ns || ''));
|
|
1391
|
+
if (!gc) return writeJson(res, 400, { error: '找不到该账号实例' });
|
|
1392
|
+
// 结构化按钮(新路径) → 生成回调表 + bpk: 键盘; 也兼容旧路径直接给 keyboard
|
|
1393
|
+
let kb = body.keyboard && body.keyboard.content && Array.isArray(body.keyboard.content.rows) ? body.keyboard : undefined;
|
|
1394
|
+
if (!kb && Array.isArray(body.cardButtons) && body.cardButtons.length) {
|
|
1395
|
+
kb = buildCardKeyboardFromPairs(gc.bot.cwd, body.cardButtons, { scope: openid ? 'c2c' : 'group', targetId: openid || gid });
|
|
1396
|
+
}
|
|
1397
|
+
const r = openid
|
|
1398
|
+
? await gc.client.sendC2cCard(openid, md, kb)
|
|
1399
|
+
: await gc.client.sendGroupCard(gid, md, kb);
|
|
1400
|
+
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) });
|
|
1401
|
+
writeJson(res, 200, r.ok
|
|
1402
|
+
? { ok: true, msg: '✅ 卡片已发送' + (openid ? '(私聊)' : '(群)'), id: r.data && r.data.id }
|
|
1403
|
+
: { ok: false, err: r.err });
|
|
1404
|
+
} catch (e) { writeJson(res, 500, { error: String((e && e.message) || e) }); }
|
|
1405
|
+
});
|
|
1406
|
+
|
|
1407
|
+
// ── botplay 事件独立文件存储(M4.3): GET 读 / POST 写, 均按 ns 取 dataRoot ──
|
|
1408
|
+
// ⚠️ 2026-09-10 修复: NSQ 参数是 URL 对象(内部调 u.searchParams.get), 原写法 NSQ(req.url)
|
|
1409
|
+
// 传了字符串 → "Cannot read properties of undefined (reading 'get')" 500。
|
|
1410
|
+
route(ctx, 'GET', '/api/qqbot-settings/group/botplay-events', async (req, res) => {
|
|
1411
|
+
const ns = NSQ(new URL(req.url ?? '/', 'http://x'));
|
|
1412
|
+
const bot = nsBot(ns);
|
|
1413
|
+
if (!bot || !bot.cwd) return writeJson(res, 400, { error: '找不到该账号实例' });
|
|
1414
|
+
const fromFile = readBotplayEventsFile(bot.cwd);
|
|
1415
|
+
let events = Array.isArray(fromFile.events) ? fromFile.events : (Array.isArray(fromFile) ? fromFile : []);
|
|
1416
|
+
// 文件缺失/空 → 迁移 settings 旧数据(botplay 事件曾存 settings.yaml, 2026-09-10 前)
|
|
1417
|
+
if (events.length === 0) {
|
|
1418
|
+
const legacy = botplayEventsFromSettings(bot.ns);
|
|
1419
|
+
if (Array.isArray(legacy) && legacy.length > 0) { writeBotplayEventsFile(bot.cwd, legacy); events = legacy; }
|
|
1420
|
+
}
|
|
1421
|
+
writeJson(res, 200, { ok: true, events, source: 'file' });
|
|
1422
|
+
});
|
|
1423
|
+
|
|
1424
|
+
route(ctx, 'POST', '/api/qqbot-settings/group/botplay-events', async (req, res) => {
|
|
1425
|
+
const body = await readJsonBody(req);
|
|
1426
|
+
if (!body || typeof body !== 'object') return writeJson(res, 400, { error: 'bad body' });
|
|
1427
|
+
const ns = String(body.ns || 'im-qqbot').trim();
|
|
1428
|
+
const bot = nsBot(ns);
|
|
1429
|
+
if (!bot || !bot.cwd) return writeJson(res, 400, { error: '找不到该账号实例' });
|
|
1430
|
+
if (!Array.isArray(body.events)) return writeJson(res, 400, { error: 'events 必须为数组' });
|
|
1431
|
+
const okFile = writeBotplayEventsFile(bot.cwd, body.events);
|
|
1432
|
+
// settings 现值同步清空(2026-09-10: 事件已独立文件, settings 里残留旧数组会与新文件重复/歧义 → 置空)
|
|
1433
|
+
let okSettings = true;
|
|
1434
|
+
try {
|
|
1435
|
+
const cur = viewOf(ctx.settings, bot.ns);
|
|
1436
|
+
const curVal = (cur && cur.value) || {};
|
|
1437
|
+
if (curVal.botplayEvents !== undefined && Array.isArray(curVal.botplayEvents) && curVal.botplayEvents.length > 0) {
|
|
1438
|
+
const patch = Object.assign({}, curVal, { botplayEvents: [] });
|
|
1439
|
+
await ctx.settings.update(bot.ns, patch, (cur && cur.revision) || undefined);
|
|
1440
|
+
}
|
|
1441
|
+
} catch { okSettings = false; }
|
|
1442
|
+
audit(bot.cwd, { ev: 'botplay.events-save', ns: bot.ns, n: body.events.length, okFile, okSettings });
|
|
1443
|
+
writeJson(res, 200, { ok: okFile, msg: okFile ? '✅ 已保存(独立文件, live 热更已生效)' : '写入文件失败' });
|
|
1444
|
+
});
|
|
1445
|
+
|
|
1446
|
+
// ── M3 群发任务队列(2026-09-10): 持久化状态机 draft→queued→sending→done, 二次确认, 可中止/撤回 ──
|
|
1447
|
+
// 数据: {dataRoot}/.qqbot/broadcast-tasks.json(broadcast.ts 管理); 推进由每次 list 请求驱动(串行, 天然限频)。
|
|
1448
|
+
// 路由: broadcast/create(草稿) → broadcast/confirm(二次确认后入队) → broadcast/list(推进+展示) →
|
|
1449
|
+
// broadcast/cancel(中止) → broadcast/recall(撤回某目标已发消息)。
|
|
1450
|
+
async function broadcastMod() {
|
|
1451
|
+
return import('./dist/features/broadcast.js');
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
// 创建群发草稿 {ns?, type:'text'|'markdown', text, targets:[{scope,peerId,name?}]}
|
|
1455
|
+
route(ctx, 'POST', '/api/qqbot-settings/group/broadcast/create', async (req, res) => {
|
|
1456
|
+
const body = await readJsonBody(req);
|
|
1457
|
+
if (!body || typeof body !== 'object') return writeJson(res, 400, { error: 'bad body' });
|
|
1458
|
+
const text = String(body.text || '').trim();
|
|
1459
|
+
const targets = Array.isArray(body.targets) ? body.targets : [];
|
|
1460
|
+
// 卡片群发(2026-09-10 主人要求): type=card 时 text 为 markdown 正文, 另带 keyboard
|
|
1461
|
+
const wantCard = body.type === 'card';
|
|
1462
|
+
if (!text) return writeJson(res, 400, { error: wantCard ? 'markdown 必填' : 'text 必填' });
|
|
1463
|
+
if (text.length > (wantCard ? 8000 : 2000)) return writeJson(res, 400, { error: wantCard ? 'markdown 过长(最多 8000 字符)' : '文本过长(最多 2000 字符)' });
|
|
1464
|
+
// 卡片群发(2026-09-10 主人要求): type=card 时 text 为 markdown 正文, 另带 keyboard;
|
|
1465
|
+
// 优先用结构化 cardButtons 现生成(回调表+bpk: 键盘, 与 chat/send-card 同一套), 兼容直接给 keyboard
|
|
1466
|
+
let kb = wantCard && body.keyboard && body.keyboard.content && Array.isArray(body.keyboard.content.rows) ? body.keyboard : undefined;
|
|
1467
|
+
if (targets.length === 0) return writeJson(res, 400, { error: 'targets 至少一个(勾选群/私聊)' });
|
|
1468
|
+
if (targets.length > 200) return writeJson(res, 400, { error: '目标太多(最多 200)' });
|
|
1469
|
+
for (const t of targets) {
|
|
1470
|
+
if (!t || (t.scope !== 'group' && t.scope !== 'c2c') || !t.peerId) return writeJson(res, 400, { error: 'targets 每项需 scope(group|c2c)+peerId' });
|
|
1471
|
+
}
|
|
1472
|
+
try {
|
|
1473
|
+
const bot = nsBot(String(body.ns || ''));
|
|
1474
|
+
if (!bot) return writeJson(res, 400, { error: '找不到该账号实例(请先在账号页配置 appId/appSecret)' });
|
|
1475
|
+
// 结构化按钮 → 现生成回调表 + bpk: 键盘(与 chat/send-card 同一套; 群发各目标共用这张表)
|
|
1476
|
+
if (wantCard && !kb && Array.isArray(body.cardButtons) && body.cardButtons.length) {
|
|
1477
|
+
kb = buildCardKeyboardFromPairs(bot.cwd, body.cardButtons, { scope: 'group', targetId: '' });
|
|
1478
|
+
}
|
|
1479
|
+
const bm = await broadcastMod();
|
|
1480
|
+
const task = bm.createTask(bot.cwd, {
|
|
1481
|
+
type: wantCard ? 'card' : (body.type === 'markdown' ? 'markdown' : 'text'),
|
|
1482
|
+
content: text,
|
|
1483
|
+
keyboard: kb,
|
|
1484
|
+
targets: targets.map((t) => ({ scope: t.scope, peerId: t.peerId, name: t.name ? String(t.name) : undefined })),
|
|
1485
|
+
created_by: 'dock',
|
|
1486
|
+
});
|
|
1487
|
+
audit(bot.cwd, { ev: 'group.broadcast.create', ns: bot.id, task_id: task.task_id, targets: targets.length, type: wantCard ? 'card' : 'text' });
|
|
1488
|
+
writeJson(res, 200, { ok: true, task });
|
|
1489
|
+
} catch (e) { writeJson(res, 500, { error: String((e && e.message) || e) }); }
|
|
1490
|
+
});
|
|
1491
|
+
|
|
1492
|
+
// 确认群发(二次确认后调用) {ns?, task_id}
|
|
1493
|
+
route(ctx, 'POST', '/api/qqbot-settings/group/broadcast/confirm', async (req, res) => {
|
|
1494
|
+
const body = await readJsonBody(req);
|
|
1495
|
+
if (!body || typeof body !== 'object') return writeJson(res, 400, { error: 'bad body' });
|
|
1496
|
+
const taskId = String(body.task_id || '');
|
|
1497
|
+
if (!taskId) return writeJson(res, 400, { error: 'task_id 必填' });
|
|
1498
|
+
try {
|
|
1499
|
+
const bot = nsBot(String(body.ns || ''));
|
|
1500
|
+
if (!bot) return writeJson(res, 400, { error: '找不到该账号实例' });
|
|
1501
|
+
const bm = await broadcastMod();
|
|
1502
|
+
const t = bm.confirmTask(bot.cwd, taskId);
|
|
1503
|
+
if (!t) return writeJson(res, 400, { error: '任务不存在' });
|
|
1504
|
+
if (t.state !== 'queued') return writeJson(res, 200, { ok: false, msg: `任务状态为 ${t.state}, 无法确认` });
|
|
1505
|
+
audit(bot.cwd, { ev: 'group.broadcast.confirm', ns: bot.id, task_id: taskId });
|
|
1506
|
+
writeJson(res, 200, { ok: true, task: t });
|
|
1507
|
+
} catch (e) { writeJson(res, 500, { error: String((e && e.message) || e) }); }
|
|
1508
|
+
});
|
|
1509
|
+
|
|
1510
|
+
// 群发任务列表(读时推进一个目标) {ns?}
|
|
1511
|
+
route(ctx, 'GET', '/api/qqbot-settings/group/broadcast/list', async (req, res) => {
|
|
1512
|
+
try {
|
|
1513
|
+
const u = new URL(req.url ?? '/', 'http://x');
|
|
1514
|
+
const bot = nsBot(NSQ(u));
|
|
1515
|
+
if (!bot) return writeJson(res, 400, { error: '找不到该账号实例' });
|
|
1516
|
+
const bm = await broadcastMod();
|
|
1517
|
+
// 推进一步: 找第一个 queued/sending 的任务推进(串行限频)
|
|
1518
|
+
const gc = await groupClientOf(NSQ(u));
|
|
1519
|
+
if (gc) {
|
|
1520
|
+
const tasks = bm.listTasks(bot.cwd);
|
|
1521
|
+
const active = tasks.find((t) => t.state === 'queued' || t.state === 'sending');
|
|
1522
|
+
if (active) await bm.advanceTask(bot.cwd, active.task_id, gc.client);
|
|
1523
|
+
}
|
|
1524
|
+
const after = bm.listTasks(bot.cwd);
|
|
1525
|
+
writeJson(res, 200, { ok: true, tasks: after });
|
|
1526
|
+
} catch (e) { writeJson(res, 500, { error: String((e && e.message) || e) }); }
|
|
1527
|
+
});
|
|
1528
|
+
|
|
1529
|
+
// 中止群发 {ns?, task_id}
|
|
1530
|
+
route(ctx, 'POST', '/api/qqbot-settings/group/broadcast/cancel', async (req, res) => {
|
|
1531
|
+
const body = await readJsonBody(req);
|
|
1532
|
+
if (!body || typeof body !== 'object') return writeJson(res, 400, { error: 'bad body' });
|
|
1533
|
+
const taskId = String(body.task_id || '');
|
|
1534
|
+
if (!taskId) return writeJson(res, 400, { error: 'task_id 必填' });
|
|
1535
|
+
try {
|
|
1536
|
+
const bot = nsBot(String(body.ns || ''));
|
|
1537
|
+
if (!bot) return writeJson(res, 400, { error: '找不到该账号实例' });
|
|
1538
|
+
const bm = await broadcastMod();
|
|
1539
|
+
const t = bm.cancelTask(bot.cwd, taskId, 'dock');
|
|
1540
|
+
if (!t) return writeJson(res, 400, { error: '任务不存在' });
|
|
1541
|
+
audit(bot.cwd, { ev: 'group.broadcast.cancel', ns: bot.id, task_id: taskId });
|
|
1542
|
+
writeJson(res, 200, { ok: true, task: t });
|
|
1543
|
+
} catch (e) { writeJson(res, 500, { error: String((e && e.message) || e) }); }
|
|
1544
|
+
});
|
|
1545
|
+
|
|
1546
|
+
// 撤回群发中某目标已发的消息(2 分钟窗口) {ns?, task_id, peerId}
|
|
1547
|
+
route(ctx, 'POST', '/api/qqbot-settings/group/broadcast/recall', async (req, res) => {
|
|
1548
|
+
const body = await readJsonBody(req);
|
|
1549
|
+
if (!body || typeof body !== 'object') return writeJson(res, 400, { error: 'bad body' });
|
|
1550
|
+
const taskId = String(body.task_id || '');
|
|
1551
|
+
const peerId = String(body.peerId || '');
|
|
1552
|
+
if (!taskId || !peerId) return writeJson(res, 400, { error: 'task_id 与 peerId 必填' });
|
|
1553
|
+
try {
|
|
1554
|
+
const bot = nsBot(String(body.ns || ''));
|
|
1555
|
+
const gc = await groupClientOf(String(body.ns || ''));
|
|
1556
|
+
if (!bot || !gc) return writeJson(res, 400, { error: '找不到该账号实例' });
|
|
1557
|
+
const bm = await broadcastMod();
|
|
1558
|
+
const r = await bm.recallTaskMessage(bot.cwd, taskId, peerId, gc.client);
|
|
1559
|
+
audit(bot.cwd, { ev: 'group.broadcast.recall', ns: bot.id, task_id: taskId, peerId, ok: r.ok });
|
|
1560
|
+
writeJson(res, 200, r);
|
|
1561
|
+
} catch (e) { writeJson(res, 500, { error: String((e && e.message) || e) }); }
|
|
1562
|
+
});
|
|
1563
|
+
|
|
1564
|
+
|
|
1159
1565
|
// ── 悬浮球「💬 聊天视图」(2026-09-07): 读某 QQ 会话最近的入站/出站消息, 只读不建会话 ──
|
|
1160
1566
|
// 会话事件结构(宿主 dsh-session): {type, seq, time, data}; user/message 的 data=消息体根(data.content),
|
|
1161
1567
|
// assistant/message 的 data={turn,step,message:{role,content,…}}。只收这两型:
|
|
@@ -1221,10 +1627,19 @@ export function apply(ctx) {
|
|
|
1221
1627
|
function chatLocalRawUrl(p) {
|
|
1222
1628
|
return '/api/qqbot-settings/chat/raw-media?p=' + encodeURIComponent(p);
|
|
1223
1629
|
}
|
|
1224
|
-
// 附件行/URL 归属类型:
|
|
1225
|
-
//
|
|
1630
|
+
// 附件行/URL 归属类型: 行内**显式类型标签**优先, 其次文件名/URL fname 扩展名, 最后行标签与上下文
|
|
1631
|
+
// ⚠️ 2026-09-10 根因修复(主人实测: "视频还是附件形式, 图片也变成附件了"):
|
|
1632
|
+
// QQ 群聊里图片/视频的 content_type 实测可能是 `'file'`(不是 image/jpeg) —— 官方取值表:
|
|
1633
|
+
// voice / image/jpeg|png|gif / video/mp4 / file(=群文件)。端上(media-kind.ts)已按
|
|
1634
|
+
// MIME→扩展名→宽高→URL 特征(appid/orgfmt) 推断真实类型, 并以 `- Image: 名 → url` 注入 Layer 4。
|
|
1635
|
+
// 这里必须认这些**显式前缀**, 否则 `- File:` 会把图片/视频判成 📎 附件(图片不显示、视频不能播)。
|
|
1226
1636
|
function chatAttachmentKind(line, hasVoiceCtx, defaultKind) {
|
|
1227
1637
|
const l = String(line || '');
|
|
1638
|
+
// ① 显式类型标签(我方注入格式, 最权威)
|
|
1639
|
+
if (/^-\s*(?:Image|图片)\s*:/i.test(l) || /^\[(?:Image|图片)\s*:/i.test(l)) return 'image';
|
|
1640
|
+
if (/^-\s*(?:Video|视频)\s*:/i.test(l) || /^\[(?:Video|视频)\s*:/i.test(l)) return 'video';
|
|
1641
|
+
if (/^-\s*(?:Voice|语音|音频)\s*:/i.test(l) || /^\[(?:Voice|语音|音频)\s*:/i.test(l)) return 'voice';
|
|
1642
|
+
if (/^-\s*(?:File|文件)\s*:/i.test(l) || /^\[(?:File|文件)\s*:/i.test(l)) return 'file';
|
|
1228
1643
|
// 从行内或 URL fname 参数取文件名
|
|
1229
1644
|
let name = '';
|
|
1230
1645
|
const fm = l.match(/fname=([^&\s]+)/);
|
|
@@ -1279,8 +1694,9 @@ export function apply(ctx) {
|
|
|
1279
1694
|
const lk = chatLocalExtKind(lp[1]);
|
|
1280
1695
|
if (lk) { images.push({ url: chatLocalRawUrl(lp[1]), kind: lk }); continue; }
|
|
1281
1696
|
}
|
|
1282
|
-
// 附件描述行: "-
|
|
1283
|
-
|
|
1697
|
+
// 附件描述行: "- Image: …" / "- Video: …" / "- Voice: …" / "- File: …" / "- Attachment URLs: …"
|
|
1698
|
+
// / "[图片: …]" / "[视频: …]" / "[附件: …]" / "[Attachment: name -> …]" / Layer1 的英文标签行
|
|
1699
|
+
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
1700
|
const urls = raw.match(QQ_URL_RE) || [];
|
|
1285
1701
|
let got = 0;
|
|
1286
1702
|
for (const u of urls) {
|
|
@@ -1290,6 +1706,10 @@ export function apply(ctx) {
|
|
|
1290
1706
|
else if (QQ_MEDIA_RE.test(u)) { images.push({ url: u, kind: k }); got++; }
|
|
1291
1707
|
}
|
|
1292
1708
|
if (!got) {
|
|
1709
|
+
// Layer 1 的英文描述行(`[Image: 名 800×600]` / `[Video: 名]` / `[File: 名 (1.2MB)]`)不含 URL,
|
|
1710
|
+
// 其媒体由 Layer 4 的 `- Image:/- Video:/- File:` 行承载 → 直接丢弃, 避免显示成
|
|
1711
|
+
// `📎 [Image: 名 800×600]` 这种丑文本(主人 2026-09-10 实测反馈)。
|
|
1712
|
+
if (/^\[(Image|Video|File):/i.test(raw)) continue;
|
|
1293
1713
|
// 无 URL 的 File/附件描述行(如 "- File: a.zip (3.0MB)")→ 保留可读文本, 不给空占位
|
|
1294
1714
|
const desc = raw
|
|
1295
1715
|
.replace(/^-\s*File:\s*/i, '').replace(/^\[File:\s*/i, '')
|
|
@@ -1343,6 +1763,12 @@ export function apply(ctx) {
|
|
|
1343
1763
|
function chatSplitHistoryBlock(body) {
|
|
1344
1764
|
const out = [];
|
|
1345
1765
|
let cur = null;
|
|
1766
|
+
// ⚠️ 2026-09-10 修复(主人实测: 当前消息的语音被挂到了上一条历史消息上):
|
|
1767
|
+
// Layer 4 的媒体元数据行(`- Attachment URLs:` / `- File:` …)排在 `[Current message]` 之后、
|
|
1768
|
+
// 带昵称的当前消息之前 —— 原逻辑"无昵称行并入上一个气泡"会把它算到上一条历史消息头上。
|
|
1769
|
+
// 改为: 这类 `- ` 开头的元数据行先暂存(pendingMeta), 等下一个带昵称的消息出现时归属它
|
|
1770
|
+
// (即"元数据行属于它下面那条消息"), 找不到归属时才丢弃。
|
|
1771
|
+
let pendingMeta = [];
|
|
1346
1772
|
const flush = () => {
|
|
1347
1773
|
if (cur) {
|
|
1348
1774
|
const t = cur.lines.join('\n').trim();
|
|
@@ -1355,9 +1781,12 @@ export function apply(ctx) {
|
|
|
1355
1781
|
if (!s) continue;
|
|
1356
1782
|
if (/^\[Chat history begins\]$/.test(s) || /^\[Chat history ends\]$/.test(s) || /^\[Current message\]$/.test(s) || /^\[当前时间/.test(s)) continue;
|
|
1357
1783
|
if (/^\[系统提示\]/.test(s)) { flush(); return out; } // 系统注入段, 之后都不属于群聊内容
|
|
1784
|
+
// 媒体元数据行: 暂存, 等归属给下面那条带昵称的消息
|
|
1785
|
+
// (Layer 4 现输出 `- Image:/- Video:/- Voice:/- File:` 单数前缀, 此处一并覆盖)
|
|
1786
|
+
if (/^-\s*(Attachment URLs|Files?|Images?|ASR|Voices?|Videos?)\s*[::]/i.test(s)) { pendingMeta.push(s); continue; }
|
|
1358
1787
|
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] }; } }
|
|
1788
|
+
if (m) { flush(); cur = { sender: m[1].trim(), lines: [m[2].trim(), ...pendingMeta].filter(Boolean) }; pendingMeta = []; }
|
|
1789
|
+
else { if (cur) cur.lines.push(...pendingMeta, s); else { flush(); cur = { sender: '', lines: [...pendingMeta, s] }; } pendingMeta = []; }
|
|
1361
1790
|
}
|
|
1362
1791
|
flush();
|
|
1363
1792
|
return out;
|
|
@@ -1376,6 +1805,15 @@ export function apply(ctx) {
|
|
|
1376
1805
|
const mm = chatSplitMedia(chatDisplayClean(b, nameByMid));
|
|
1377
1806
|
return { sender, mm };
|
|
1378
1807
|
}
|
|
1808
|
+
// 🔍 临时诊断(2026-09-10): 把含媒体标记的入站原文落盘到 ~/.dsh/qqbot-chat-raw.jsonl ——
|
|
1809
|
+
// 用于核对 Layer1/Layer4 实际注入格式与 dock 解析结果(主人验收通过后删除本函数与调用)。
|
|
1810
|
+
function chatRawDiag(raw0) {
|
|
1811
|
+
try {
|
|
1812
|
+
const s = String(raw0 || '');
|
|
1813
|
+
if (!/(Image|Video|Voice|File|Attachment|图片|视频|语音|文件|附件)|multimedia\.nt\.qq\.com|qpic\.cn/i.test(s)) return;
|
|
1814
|
+
appendFileSync(join(homedir(), '.dsh', 'qqbot-chat-raw.jsonl'), JSON.stringify({ t: Date.now(), body: s.slice(0, 5000) }) + '\n');
|
|
1815
|
+
} catch { /* 诊断失败不影响主流程 */ }
|
|
1816
|
+
}
|
|
1379
1817
|
// 解码一条会话事件为聊天条目(群打包会展开成多条); 非聊天事件/噪声返回 null
|
|
1380
1818
|
function chatDecodeEvent(ev, scope, nameByMid, fallbackSender) {
|
|
1381
1819
|
if (!ev || typeof ev.type !== 'string') return null;
|
|
@@ -1386,6 +1824,7 @@ export function apply(ctx) {
|
|
|
1386
1824
|
if (src.kind === 'plugin') return null; // runtime context / 系统注入等, 非真人对话
|
|
1387
1825
|
const raw0 = chatTextOf(data.content);
|
|
1388
1826
|
if (!raw0) return null;
|
|
1827
|
+
chatRawDiag(raw0);
|
|
1389
1828
|
const raw = chatPeelTimeHead(raw0);
|
|
1390
1829
|
const isRelay = /^用户代你发送: /.test(raw);
|
|
1391
1830
|
// 后台任务/面板大文件完成通知([系统] 后台任务…)→ 显示为 bot 侧气泡并打来源标; 其余系统注入滤掉
|
|
@@ -1499,7 +1938,22 @@ export function apply(ctx) {
|
|
|
1499
1938
|
if (low === 0) { reachedEnd = true; break; }
|
|
1500
1939
|
high = low - 1;
|
|
1501
1940
|
}
|
|
1502
|
-
|
|
1941
|
+
// ⚠️ 2026-09-10 修复(主人实测"同一批次的历史消息会倒序"):
|
|
1942
|
+
// 原为 items.reverse() —— 倒扫时同一打包块([Chat history begins]…[Chat history ends])
|
|
1943
|
+
// 拆出的多条共用同一个 ev.seq, 块内本就是旧→新; 整体 reverse 会把"块内顺序"也翻反,
|
|
1944
|
+
// 表现为"整批之间顺序对、批内倒序"。
|
|
1945
|
+
// 改为: 按 seq 分块 → 块间倒序(旧→新), 块内保持原顺序。
|
|
1946
|
+
{
|
|
1947
|
+
const blocks = [];
|
|
1948
|
+
for (const it of items) {
|
|
1949
|
+
const last = blocks[blocks.length - 1];
|
|
1950
|
+
if (last && last.seq === it.seq) last.arr.push(it);
|
|
1951
|
+
else blocks.push({ seq: it.seq, arr: [it] });
|
|
1952
|
+
}
|
|
1953
|
+
blocks.reverse();
|
|
1954
|
+
items.length = 0;
|
|
1955
|
+
for (const b of blocks) for (const one of b.arr) items.push(one);
|
|
1956
|
+
}
|
|
1503
1957
|
// QQ 媒体域的语音 URL → 经 /chat/voice-play 转 mp3 播放(浏览器解不了 SILK)
|
|
1504
1958
|
for (const it of items) {
|
|
1505
1959
|
if (!Array.isArray(it.images)) continue;
|
|
@@ -2010,4 +2464,44 @@ const WHY_MAP = { busy: '目标会话回合活跃,已等待至回合结束仍超
|
|
|
2010
2464
|
} catch (e) { writeJson(res, 500, { error: String((e && e.message) || e) }); }
|
|
2011
2465
|
});
|
|
2012
2466
|
|
|
2467
|
+
// ── 🚀 群发任务「后台自动推进」(2026-09-10 修: 原设计靠 client 每次 list 驱动, 页面不动就永久卡 queued) ──
|
|
2468
|
+
// 每 5s 扫全部实例, 有 queued/sending 任务就推进一步(串行 → 天然限频); 单实例失败不影响其他实例。
|
|
2469
|
+
try {
|
|
2470
|
+
const BC_TICK_MS = 5000;
|
|
2471
|
+
let bcTickRunning = false; // 定时器自身防重入: 上一轮未跑完不叠加(2026-09-10)
|
|
2472
|
+
const bcTick = async () => {
|
|
2473
|
+
if (bcTickRunning) return;
|
|
2474
|
+
bcTickRunning = true;
|
|
2475
|
+
try {
|
|
2476
|
+
let bots = [];
|
|
2477
|
+
try { bots = parsePatch().bots || []; } catch { return; }
|
|
2478
|
+
for (const b of bots) {
|
|
2479
|
+
try {
|
|
2480
|
+
if (b.disabled) continue;
|
|
2481
|
+
const bot = nsBot(b.id);
|
|
2482
|
+
if (!bot || !bot.cwd) continue;
|
|
2483
|
+
const bm = await broadcastMod();
|
|
2484
|
+
const tasks = bm.listTasks(bot.cwd);
|
|
2485
|
+
const active = tasks.find((t) => t.state === 'queued' || t.state === 'sending');
|
|
2486
|
+
if (!active) continue;
|
|
2487
|
+
const gc = await groupClientOf(b.id);
|
|
2488
|
+
if (!gc) continue;
|
|
2489
|
+
await bm.advanceTask(bot.cwd, active.task_id, gc.client);
|
|
2490
|
+
audit(bot.cwd, { ev: 'group.broadcast.auto-advance', ns: b.id, task_id: active.task_id });
|
|
2491
|
+
} catch (e) {
|
|
2492
|
+
try {
|
|
2493
|
+
const bot2 = nsBot(b.id);
|
|
2494
|
+
if (bot2 && bot2.cwd) audit(bot2.cwd, { ev: 'group.broadcast.auto-advance-failed', ns: b.id, error: String((e && e.message) || e) });
|
|
2495
|
+
} catch { /* ignore */ }
|
|
2496
|
+
}
|
|
2497
|
+
}
|
|
2498
|
+
} finally { bcTickRunning = false; }
|
|
2499
|
+
};
|
|
2500
|
+
ctx.effect(() => {
|
|
2501
|
+
const timer = setInterval(() => { bcTick().catch(() => {}); }, BC_TICK_MS);
|
|
2502
|
+
timer.unref?.();
|
|
2503
|
+
return () => { try { clearInterval(timer); } catch { /* ignore */ } };
|
|
2504
|
+
}, 'qqbot-settings: 群发任务后台自动推进');
|
|
2505
|
+
} catch { /* 定时器失败不影响插件 */ }
|
|
2506
|
+
|
|
2013
2507
|
}
|