@zaofan/dsh-qqbot 0.6.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 (227) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +308 -0
  3. package/README_EN.md +273 -0
  4. package/client/qqbot-settings.js +1463 -0
  5. package/cordis.patch.yml +36 -0
  6. package/dist/api/group-admin.d.ts +112 -0
  7. package/dist/api/group-admin.d.ts.map +1 -0
  8. package/dist/api/group-admin.js +179 -0
  9. package/dist/api/group-admin.js.map +1 -0
  10. package/dist/channel-tools.d.ts +14 -0
  11. package/dist/channel-tools.d.ts.map +1 -0
  12. package/dist/channel-tools.js +880 -0
  13. package/dist/channel-tools.js.map +1 -0
  14. package/dist/commands/help.d.ts +11 -0
  15. package/dist/commands/help.d.ts.map +1 -0
  16. package/dist/commands/help.js +21 -0
  17. package/dist/commands/help.js.map +1 -0
  18. package/dist/commands/index.d.ts +13 -0
  19. package/dist/commands/index.d.ts.map +1 -0
  20. package/dist/commands/index.js +29 -0
  21. package/dist/commands/index.js.map +1 -0
  22. package/dist/commands/misc.d.ts +14 -0
  23. package/dist/commands/misc.d.ts.map +1 -0
  24. package/dist/commands/misc.js +39 -0
  25. package/dist/commands/misc.js.map +1 -0
  26. package/dist/commands/model.d.ts +7 -0
  27. package/dist/commands/model.d.ts.map +1 -0
  28. package/dist/commands/model.js +58 -0
  29. package/dist/commands/model.js.map +1 -0
  30. package/dist/commands/session.d.ts +10 -0
  31. package/dist/commands/session.d.ts.map +1 -0
  32. package/dist/commands/session.js +26 -0
  33. package/dist/commands/session.js.map +1 -0
  34. package/dist/commands/status.d.ts +8 -0
  35. package/dist/commands/status.d.ts.map +1 -0
  36. package/dist/commands/status.js +24 -0
  37. package/dist/commands/status.js.map +1 -0
  38. package/dist/commands/types.d.ts +12 -0
  39. package/dist/commands/types.d.ts.map +1 -0
  40. package/dist/commands/types.js +2 -0
  41. package/dist/commands/types.js.map +1 -0
  42. package/dist/config.d.ts +205 -0
  43. package/dist/config.d.ts.map +1 -0
  44. package/dist/config.js +203 -0
  45. package/dist/config.js.map +1 -0
  46. package/dist/features/chat-ledger.d.ts +53 -0
  47. package/dist/features/chat-ledger.d.ts.map +1 -0
  48. package/dist/features/chat-ledger.js +185 -0
  49. package/dist/features/chat-ledger.js.map +1 -0
  50. package/dist/features/face-tags.d.ts +17 -0
  51. package/dist/features/face-tags.d.ts.map +1 -0
  52. package/dist/features/face-tags.js +79 -0
  53. package/dist/features/face-tags.js.map +1 -0
  54. package/dist/features/group-join-request.d.ts +65 -0
  55. package/dist/features/group-join-request.d.ts.map +1 -0
  56. package/dist/features/group-join-request.js +190 -0
  57. package/dist/features/group-join-request.js.map +1 -0
  58. package/dist/features/history-store.d.ts +8 -0
  59. package/dist/features/history-store.d.ts.map +1 -0
  60. package/dist/features/history-store.js +24 -0
  61. package/dist/features/history-store.js.map +1 -0
  62. package/dist/features/qq-approval.d.ts +78 -0
  63. package/dist/features/qq-approval.d.ts.map +1 -0
  64. package/dist/features/qq-approval.js +165 -0
  65. package/dist/features/qq-approval.js.map +1 -0
  66. package/dist/features/schedule-store.d.ts +62 -0
  67. package/dist/features/schedule-store.d.ts.map +1 -0
  68. package/dist/features/schedule-store.js +253 -0
  69. package/dist/features/schedule-store.js.map +1 -0
  70. package/dist/features/scheduler.d.ts +9 -0
  71. package/dist/features/scheduler.d.ts.map +1 -0
  72. package/dist/features/scheduler.js +146 -0
  73. package/dist/features/scheduler.js.map +1 -0
  74. package/dist/features/sticker-gate.d.ts +90 -0
  75. package/dist/features/sticker-gate.d.ts.map +1 -0
  76. package/dist/features/sticker-gate.js +391 -0
  77. package/dist/features/sticker-gate.js.map +1 -0
  78. package/dist/features/sticker-store.d.ts +158 -0
  79. package/dist/features/sticker-store.d.ts.map +1 -0
  80. package/dist/features/sticker-store.js +676 -0
  81. package/dist/features/sticker-store.js.map +1 -0
  82. package/dist/features/sticker-tagger.d.ts +10 -0
  83. package/dist/features/sticker-tagger.d.ts.map +1 -0
  84. package/dist/features/sticker-tagger.js +139 -0
  85. package/dist/features/sticker-tagger.js.map +1 -0
  86. package/dist/gateway/bootstrap.d.ts +12 -0
  87. package/dist/gateway/bootstrap.d.ts.map +1 -0
  88. package/dist/gateway/bootstrap.js +346 -0
  89. package/dist/gateway/bootstrap.js.map +1 -0
  90. package/dist/gateway/debounce.d.ts +40 -0
  91. package/dist/gateway/debounce.d.ts.map +1 -0
  92. package/dist/gateway/debounce.js +262 -0
  93. package/dist/gateway/debounce.js.map +1 -0
  94. package/dist/gateway/index.d.ts +3 -0
  95. package/dist/gateway/index.d.ts.map +1 -0
  96. package/dist/gateway/index.js +3 -0
  97. package/dist/gateway/index.js.map +1 -0
  98. package/dist/gateway/middleware-setup.d.ts +12 -0
  99. package/dist/gateway/middleware-setup.d.ts.map +1 -0
  100. package/dist/gateway/middleware-setup.js +156 -0
  101. package/dist/gateway/middleware-setup.js.map +1 -0
  102. package/dist/index.d.ts +14 -0
  103. package/dist/index.d.ts.map +1 -0
  104. package/dist/index.js +187 -0
  105. package/dist/index.js.map +1 -0
  106. package/dist/middleware/attachment.d.ts +11 -0
  107. package/dist/middleware/attachment.d.ts.map +1 -0
  108. package/dist/middleware/attachment.js +17 -0
  109. package/dist/middleware/attachment.js.map +1 -0
  110. package/dist/middleware/media-history.d.ts +30 -0
  111. package/dist/middleware/media-history.d.ts.map +1 -0
  112. package/dist/middleware/media-history.js +54 -0
  113. package/dist/middleware/media-history.js.map +1 -0
  114. package/dist/middleware/sticker-capture.d.ts +17 -0
  115. package/dist/middleware/sticker-capture.d.ts.map +1 -0
  116. package/dist/middleware/sticker-capture.js +97 -0
  117. package/dist/middleware/sticker-capture.js.map +1 -0
  118. package/dist/model/index.d.ts +10 -0
  119. package/dist/model/index.d.ts.map +1 -0
  120. package/dist/model/index.js +9 -0
  121. package/dist/model/index.js.map +1 -0
  122. package/dist/model/model-resolver.d.ts +88 -0
  123. package/dist/model/model-resolver.d.ts.map +1 -0
  124. package/dist/model/model-resolver.js +151 -0
  125. package/dist/model/model-resolver.js.map +1 -0
  126. package/dist/model/prefs-store.d.ts +24 -0
  127. package/dist/model/prefs-store.d.ts.map +1 -0
  128. package/dist/model/prefs-store.js +126 -0
  129. package/dist/model/prefs-store.js.map +1 -0
  130. package/dist/model/settings-reader.d.ts +19 -0
  131. package/dist/model/settings-reader.d.ts.map +1 -0
  132. package/dist/model/settings-reader.js +76 -0
  133. package/dist/model/settings-reader.js.map +1 -0
  134. package/dist/model/types.d.ts +15 -0
  135. package/dist/model/types.d.ts.map +1 -0
  136. package/dist/model/types.js +5 -0
  137. package/dist/model/types.js.map +1 -0
  138. package/dist/session/idle-evictor.d.ts +18 -0
  139. package/dist/session/idle-evictor.d.ts.map +1 -0
  140. package/dist/session/idle-evictor.js +31 -0
  141. package/dist/session/idle-evictor.js.map +1 -0
  142. package/dist/session/index.d.ts +9 -0
  143. package/dist/session/index.d.ts.map +1 -0
  144. package/dist/session/index.js +8 -0
  145. package/dist/session/index.js.map +1 -0
  146. package/dist/session/session-manager.d.ts +97 -0
  147. package/dist/session/session-manager.d.ts.map +1 -0
  148. package/dist/session/session-manager.js +624 -0
  149. package/dist/session/session-manager.js.map +1 -0
  150. package/dist/session/types.d.ts +130 -0
  151. package/dist/session/types.d.ts.map +1 -0
  152. package/dist/session/types.js +2 -0
  153. package/dist/session/types.js.map +1 -0
  154. package/dist/session/workspace-attach.d.ts +22 -0
  155. package/dist/session/workspace-attach.d.ts.map +1 -0
  156. package/dist/session/workspace-attach.js +21 -0
  157. package/dist/session/workspace-attach.js.map +1 -0
  158. package/dist/setup.d.ts +25 -0
  159. package/dist/setup.d.ts.map +1 -0
  160. package/dist/setup.js +198 -0
  161. package/dist/setup.js.map +1 -0
  162. package/dist/shared/index.d.ts +7 -0
  163. package/dist/shared/index.d.ts.map +1 -0
  164. package/dist/shared/index.js +7 -0
  165. package/dist/shared/index.js.map +1 -0
  166. package/dist/shared/scope.d.ts +14 -0
  167. package/dist/shared/scope.d.ts.map +1 -0
  168. package/dist/shared/scope.js +12 -0
  169. package/dist/shared/scope.js.map +1 -0
  170. package/dist/shared/send-helper.d.ts +11 -0
  171. package/dist/shared/send-helper.d.ts.map +1 -0
  172. package/dist/shared/send-helper.js +13 -0
  173. package/dist/shared/send-helper.js.map +1 -0
  174. package/dist/shared/utils.d.ts +28 -0
  175. package/dist/shared/utils.d.ts.map +1 -0
  176. package/dist/shared/utils.js +143 -0
  177. package/dist/shared/utils.js.map +1 -0
  178. package/dist/transport/attachment.d.ts +18 -0
  179. package/dist/transport/attachment.d.ts.map +1 -0
  180. package/dist/transport/attachment.js +106 -0
  181. package/dist/transport/attachment.js.map +1 -0
  182. package/dist/transport/chunker.d.ts +14 -0
  183. package/dist/transport/chunker.d.ts.map +1 -0
  184. package/dist/transport/chunker.js +78 -0
  185. package/dist/transport/chunker.js.map +1 -0
  186. package/dist/transport/events.d.ts +80 -0
  187. package/dist/transport/events.d.ts.map +1 -0
  188. package/dist/transport/events.js +62 -0
  189. package/dist/transport/events.js.map +1 -0
  190. package/dist/transport/inbound.d.ts +8 -0
  191. package/dist/transport/inbound.d.ts.map +1 -0
  192. package/dist/transport/inbound.js +292 -0
  193. package/dist/transport/inbound.js.map +1 -0
  194. package/dist/transport/index.d.ts +10 -0
  195. package/dist/transport/index.d.ts.map +1 -0
  196. package/dist/transport/index.js +10 -0
  197. package/dist/transport/index.js.map +1 -0
  198. package/dist/transport/inject-rules.d.ts +24 -0
  199. package/dist/transport/inject-rules.d.ts.map +1 -0
  200. package/dist/transport/inject-rules.js +72 -0
  201. package/dist/transport/inject-rules.js.map +1 -0
  202. package/dist/transport/outbound-buffer.d.ts +56 -0
  203. package/dist/transport/outbound-buffer.d.ts.map +1 -0
  204. package/dist/transport/outbound-buffer.js +131 -0
  205. package/dist/transport/outbound-buffer.js.map +1 -0
  206. package/dist/transport/outbound.d.ts +30 -0
  207. package/dist/transport/outbound.d.ts.map +1 -0
  208. package/dist/transport/outbound.js +139 -0
  209. package/dist/transport/outbound.js.map +1 -0
  210. package/dist/transport/rich-media.d.ts +41 -0
  211. package/dist/transport/rich-media.d.ts.map +1 -0
  212. package/dist/transport/rich-media.js +104 -0
  213. package/dist/transport/rich-media.js.map +1 -0
  214. package/dist/transport/streaming-writer.d.ts +47 -0
  215. package/dist/transport/streaming-writer.d.ts.map +1 -0
  216. package/dist/transport/streaming-writer.js +110 -0
  217. package/dist/transport/streaming-writer.js.map +1 -0
  218. package/dist/transport/tool-presenter.d.ts +38 -0
  219. package/dist/transport/tool-presenter.d.ts.map +1 -0
  220. package/dist/transport/tool-presenter.js +121 -0
  221. package/dist/transport/tool-presenter.js.map +1 -0
  222. package/dist/types.d.ts +45 -0
  223. package/dist/types.d.ts.map +1 -0
  224. package/dist/types.js +5 -0
  225. package/dist/types.js.map +1 -0
  226. package/package.json +106 -0
  227. package/settings-host.js +927 -0
@@ -0,0 +1,927 @@
1
+ /**
2
+ * qqbot-settings — host 半边: ①im-qqbot settings 命名空间经同源路由暴露给设置面板;
3
+ * ②表情包图库管理 API(列表/缩略图/批量/导入), 与 dsh-qqbot 共享同进程 store 单例。
4
+ * 仅 web profile 装配; 同源 fence 抄 modsearch。
5
+ */
6
+ import { readFileSync, writeFileSync, rmSync, mkdirSync, existsSync, readdirSync, statSync, cpSync } from 'node:fs';
7
+ import { extname, join, resolve, dirname } from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { spawn } from 'node:child_process';
10
+ import { homedir } from 'node:os';
11
+ import { getStickerStore } from '@zaofan/dsh-qqbot/sticker-store';
12
+ import { getScheduleStore } from '@zaofan/dsh-qqbot/schedule-store';
13
+
14
+ export const name = 'qqbot-settings';
15
+ export const inject = ['settings', 'webServer', 'agentPresets'];
16
+
17
+ const NS = 'im-qqbot';
18
+ /**
19
+ * 定位宿主 profile 根(账号实例 patch 所在)。优先代码位置(实体安装: 包根上溯 3 级 = profile 根,
20
+ * 上溯层级在单包合并时已按包根位置修正); 若该位置没有 cordis.patch.yml(link/symlink 安装时
21
+ * 本文件被从源码目录加载), 则扫描 ~/.dsh/profiles/* 找装着 @zaofan/dsh-qqbot 的 profile。
22
+ */
23
+ function resolveProfileRoot() {
24
+ const byLocation = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
25
+ try {
26
+ if (existsSync(join(byLocation, 'cordis.patch.yml'))) return byLocation;
27
+ } catch { /* 继续扫描 */ }
28
+ try {
29
+ const base = join(homedir(), '.dsh', 'profiles');
30
+ for (const name of readdirSync(base)) {
31
+ const prof = resolve(base, name);
32
+ try {
33
+ if (!statSync(prof).isDirectory()) continue;
34
+ } catch { continue; }
35
+ try {
36
+ if (existsSync(join(prof, 'node_modules', '@zaofan', 'dsh-qqbot'))) return prof;
37
+ } catch { /* 走下一判据 */ }
38
+ try {
39
+ const pkg = JSON.parse(readFileSync(join(prof, 'package.json'), 'utf8'));
40
+ const blob = JSON.stringify(pkg.dsh?.profile?.bundles ?? []) + JSON.stringify(pkg.dependencies ?? {});
41
+ if (blob.includes('@zaofan/dsh-qqbot')) return prof;
42
+ } catch { /* 无 package.json, 跳过 */ }
43
+ }
44
+ } catch { /* profiles 目录不存在 */ }
45
+ return byLocation;
46
+ }
47
+ const PROFILE_ROOT = resolveProfileRoot();
48
+ /** cordis.patch.yml = 宿主插件装配文件(账号实例声明处; 改它需重启 dsh 生效) */
49
+ const PATCH_FILE = join(PROFILE_ROOT, 'cordis.patch.yml');
50
+ /** agent 预设根目录(用户可写; 每预设一个子目录, 目录名=预设 id) */
51
+ const PRESET_ROOT = join(homedir(), '.dsh', '.agent-presets');
52
+ /** 复制预设时排除的备份/压缩类文件(不把主人的隐私压缩包带进新预设) */
53
+ const COPY_SKIP = /\.(rar|zip|7z|bak|tmp|log|diag)$/i;
54
+ /** 预设 id 校验: 文件夹命名规范(字母数字开头, 允许 - _) */
55
+ const ID_RE = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
56
+
57
+ /** 同源回环 fence: 仅本机 loopback + 同源/无 Origin 放行 */
58
+ function isTrusted(req) {
59
+ const h = req.headers?.host;
60
+ if (typeof h !== 'string') return false;
61
+ let hostname;
62
+ try { hostname = new URL(`http://${h}`).hostname; } catch { return false; }
63
+ const loopback = /^(\[::1\]|localhost|127(\.\d{1,3}){3})$/.test(hostname);
64
+ if (!loopback) return false;
65
+ if (req.headers?.['sec-fetch-site'] === 'cross-site') return false;
66
+ const o = req.headers?.origin;
67
+ if (o === undefined) return true;
68
+ try { return new URL(o).host === req.headers.host; } catch { return false; }
69
+ }
70
+
71
+ function writeJson(res, status, body) {
72
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
73
+ res.end(JSON.stringify(body));
74
+ }
75
+
76
+ async function readJsonBody(req) {
77
+ const chunks = [];
78
+ let size = 0;
79
+ for await (const c of req) {
80
+ size += c.length;
81
+ if (size > 8 * 1024 * 1024) return undefined;
82
+ chunks.push(c);
83
+ }
84
+ try { return JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { return undefined; }
85
+ }
86
+
87
+ /** 读命名空间当前解析值 + revision(redact 防密钥外泄) */
88
+ function viewOf(settings, ns) {
89
+ const target = ns || NS;
90
+ const d = settings.describe({ redactSecrets: true }).find((x) => x.ns === target);
91
+ return d ? { value: d.value, revision: d.revision } : { value: undefined, revision: undefined };
92
+ }
93
+
94
+ /** 请求里的目录参数(dataDir 显式传; 空=primary 主账号) */
95
+ function dirOf(u, key) {
96
+ return (u?.searchParams?.get(key) || '').trim() || undefined;
97
+ }
98
+
99
+ const MIME = { jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp', bmp: 'image/bmp' };
100
+
101
+ /** 注册一条路由, 统一 fence + method 检查 */
102
+ function route(ctx, method, path, handler) {
103
+ ctx.effect(() => ctx.webServer.register({
104
+ kind: 'exact',
105
+ path,
106
+ handler: async (req, res) => {
107
+ 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' });
109
+ try { await handler(req, res); } catch (e) { writeJson(res, 500, { error: String(e?.message ?? e) }); }
110
+ },
111
+ }), `qqbot-settings: ${path}`);
112
+ }
113
+
114
+ export function apply(ctx) {
115
+ // ── 临时诊断(排查"设置读取失败"): settings 服务可用性 + 已注册命名空间 ──
116
+ route(ctx, 'GET', '/api/qqbot-settings/_debug', async (_req, res) => {
117
+ try {
118
+ const hasSettings = !!ctx.settings;
119
+ let nss = [];
120
+ let describeErr = null;
121
+ try {
122
+ const all = ctx.settings.describe({});
123
+ nss = (Array.isArray(all) ? all : []).map((x) => x.ns);
124
+ } catch (e) { describeErr = String(e?.message ?? e); }
125
+ writeJson(res, 200, { hasSettings, nss, describeErr });
126
+ } catch (e) { writeJson(res, 500, { error: String(e?.message ?? e) }); }
127
+ });
128
+
129
+ // ── 设置面板读写桥(多账号: ?ns= 选实例命名空间; 缺省 im-qqbot 主账号) ──
130
+ route(ctx, 'GET', '/api/qqbot-settings/read', async (req, res) => {
131
+ const u = new URL(req.url ?? '/', 'http://x');
132
+ writeJson(res, 200, viewOf(ctx.settings, u.searchParams.get('ns') || undefined));
133
+ });
134
+ route(ctx, 'POST', '/api/qqbot-settings/update', async (req, res) => {
135
+ const body = await readJsonBody(req);
136
+ if (!body || typeof body !== 'object') return writeJson(res, 400, { error: 'body must be JSON object' });
137
+ const ns = typeof body.ns === 'string' && body.ns ? body.ns : NS;
138
+ try {
139
+ await ctx.settings.update(ns, body.patch ?? {}, typeof body.expectedRevision === 'number' ? body.expectedRevision : undefined);
140
+ writeJson(res, 200, viewOf(ctx.settings, ns));
141
+ } catch (e) {
142
+ const code = e?.code ?? '';
143
+ if (code === 'SETTINGS_CONFLICT' || code === 'SettingsConflictError') return writeJson(res, 409, { error: e.message });
144
+ writeJson(res, 400, { error: String(e?.message ?? e) });
145
+ }
146
+ });
147
+
148
+ // ── 定时唤醒辅助: 机器人见过的聊天台账(群/私聊带昵称) ──
149
+ // 台账文件 {store.dataDir}/known-chats.jsonl 由 dsh-qqbot 中间件 append(见 dsh-qqbot src/features/chat-ledger.ts)。
150
+ // 用途: Web 设置④区下拉点选目标(免抄 openid)——群无群名, 用最近发言者昵称帮主人辨认。
151
+ // 多账号: ?dataDir= 指定账号图库目录(台账与之同目录); 缺省 primary。
152
+ route(ctx, 'GET', '/api/qqbot-settings/known-chats', async (req, res) => {
153
+ try {
154
+ const u = new URL(req.url ?? '/', 'http://x');
155
+ const store = dirOf(u, 'dataDir') ? getStickerStore(dirOf(u, 'dataDir')) : getStickerStore();
156
+ const dataDir = store.dataDir;
157
+ const lines = [];
158
+ try {
159
+ const raw = readFileSync(join(dataDir, 'known-chats.jsonl'), 'utf8');
160
+ for (const l of raw.split('\n')) {
161
+ const t = l.trim();
162
+ if (!t) continue;
163
+ try {
164
+ const o = JSON.parse(t);
165
+ if (o && typeof o.id === 'string' && (o.scope === 'group' || o.scope === 'c2c')) {
166
+ lines.push({ ts: typeof o.ts === 'number' ? o.ts : 0, scope: o.scope, id: o.id, name: typeof o.name === 'string' ? o.name : undefined });
167
+ }
168
+ } catch { /* 坏行跳过 */ }
169
+ }
170
+ } catch { /* 台账文件尚不存在 */ }
171
+ const map = new Map();
172
+ for (const l of lines) {
173
+ const key = l.scope + ':' + l.id;
174
+ const cur = map.get(key);
175
+ if (!cur) map.set(key, { scope: l.scope, id: l.id, name: l.name, lastSeen: l.ts, count: 1 });
176
+ else {
177
+ cur.count += 1;
178
+ if (l.ts > cur.lastSeen) { cur.lastSeen = l.ts; if (l.name) cur.name = l.name; }
179
+ }
180
+ }
181
+ const chats = [...map.values()].sort((a, b) => b.lastSeen - a.lastSeen).slice(0, 80);
182
+ writeJson(res, 200, { chats });
183
+ } catch (e) {
184
+ writeJson(res, 500, { error: String(e?.message ?? e) });
185
+ }
186
+ });
187
+
188
+ // ── 定时任务管理(会话内 AI 用 schedule_timer 建的: once/daily, .qqbot/timers.json) ──
189
+ // 多账号: ?schedDir= / body.schedDir 指定账号定时目录(join(cwd,'.qqbot')); 缺省 primary。
190
+ // 本页只管 查看/开关/删除(新建在 QQ 会话里让 AI 建)。
191
+ function timerJobs(schedDir) {
192
+ const store = schedDir ? getScheduleStore(schedDir) : getScheduleStore();
193
+ const now = Date.now();
194
+ return store.list().map((j) => ({
195
+ id: j.id, kind: j.kind, scope: j.scope, peerId: j.peerId,
196
+ atTime: j.atTime || null, dueAt: j.dueAt || null,
197
+ prompt: j.prompt, createdAt: j.createdAt, enabled: j.enabled,
198
+ lastRunDate: j.lastRunDate || null,
199
+ nextFireAt: store.nextFireAt(j, now),
200
+ }));
201
+ }
202
+ route(ctx, 'GET', '/api/qqbot-settings/timers', async (req, res) => {
203
+ const u = new URL(req.url ?? '/', 'http://x');
204
+ writeJson(res, 200, { jobs: timerJobs(dirOf(u, 'schedDir')) });
205
+ });
206
+ // 开关: {id, enabled, schedDir?}
207
+ route(ctx, 'POST', '/api/qqbot-settings/timers/toggle', async (req, res) => {
208
+ const body = await readJsonBody(req);
209
+ if (!body || typeof body.id !== 'string') return writeJson(res, 400, { error: 'id required' });
210
+ const store = typeof body.schedDir === 'string' && body.schedDir ? getScheduleStore(body.schedDir) : getScheduleStore();
211
+ const ok = store.setEnabled(body.id, body.enabled === true);
212
+ try { store.flush(); } catch { /* ignore */ }
213
+ if (!ok) return writeJson(res, 404, { error: '任务不存在' });
214
+ writeJson(res, 200, { ok: true });
215
+ });
216
+ // 删除: {id, schedDir?}
217
+ route(ctx, 'POST', '/api/qqbot-settings/timers/delete', async (req, res) => {
218
+ const body = await readJsonBody(req);
219
+ if (!body || typeof body.id !== 'string') return writeJson(res, 400, { error: 'id required' });
220
+ const store = typeof body.schedDir === 'string' && body.schedDir ? getScheduleStore(body.schedDir) : getScheduleStore();
221
+ const ok = store.remove(body.id);
222
+ try { store.flush(); } catch { /* ignore */ }
223
+ if (!ok) return writeJson(res, 404, { error: '任务不存在' });
224
+ writeJson(res, 200, { ok: true });
225
+ });
226
+
227
+ // ── 账号与预设管理(第4 tab) ──
228
+ // 账号实例声明在 cordis.patch.yml(改它需重启 dsh 生效); appSecret 明文存本机 profile, 勿提交 git。
229
+
230
+ /** YAML 单引号标量(含 ' 转义为 '') */
231
+ function yq(v) { return `'${String(v ?? '').replace(/'/g, "''")}'` }
232
+ /** appSecret 裸写规则: 纯安全字符直接裸, 否则单引号 */
233
+ function ysec(v) { const s = String(v ?? ''); return /^[A-Za-z0-9+/=_\-]+$/.test(s) ? s : yq(s) }
234
+ /** 顶层块行判断(0 缩进以 "- " 开头) */
235
+ function isTopLine(l) { return /^-\s/.test(l) }
236
+ /** 块头是 im-qqbot 实例? */
237
+ function isBotHead(l) { const m = /^-\s*id:\s*(\S+)\s*$/.exec(l.trim()); return m ? /^im-qqbot(-|$)/.test(m[1]) : false }
238
+ function botIdOf(headLine) { const m = /^-\s*id:\s*(\S+)\s*$/.exec(headLine.trim()); return m ? m[1] : '' }
239
+
240
+ /** 解析 patch: 返回 {raw, bots:[{id,start,end,ins,cfg:{appId,appSecret,preset,cwd},disabled}], hasFile}
241
+ * patch 语义(宿主 dsh-app-boot applyEntryPatches): 顶层 `- id: X` 只能覆盖已存在 id(bundle insert 建的);
242
+ * 新增实例必须 `- insert:` 包装(其下 4 空格 `- id: Y`)。本文件里两种形态都识别。 */
243
+ function parsePatch() {
244
+ const hasFile = existsSync(PATCH_FILE);
245
+ const raw = hasFile ? readFileSync(PATCH_FILE, 'utf8') : '';
246
+ const lines = raw.split('\n');
247
+ const bots = [];
248
+ let i = 0;
249
+ while (i < lines.length) {
250
+ const t = lines[i];
251
+ if (isTopLine(t) && isBotHead(t)) {
252
+ // 顶层覆盖形态: head 缩进 0
253
+ const id = botIdOf(t);
254
+ const start = i;
255
+ let j = i + 1;
256
+ const cfg = {};
257
+ let disabled = false;
258
+ let inCfg = false;
259
+ while (j < lines.length && (lines[j].trim() === '' || !isTopLine(lines[j]))) {
260
+ const c = lines[j];
261
+ if (/^\s{2}config:\s*$/.test(c)) inCfg = true;
262
+ else if (/^\s{2}disabled:\s*true\s*$/.test(c)) disabled = true;
263
+ else if (inCfg && /^\s{4}(\S[^:]*):\s*(.*)$/.test(c)) {
264
+ const km = /^\s{4}(\S[^:]*):\s*(.*)$/.exec(c);
265
+ cfg[km[1]] = km[2].trim().replace(/^'|'$/g, '').replace(/''/g, "'");
266
+ }
267
+ j += 1;
268
+ }
269
+ bots.push({ id, start, end: j, ins: false, cfg, disabled });
270
+ i = j;
271
+ } else if (isTopLine(t) && /^\s*-\s*insert:\s*$/.test(t)) {
272
+ // insert 形态: 找其下 4 空格 `- id: im-qqbot*`
273
+ const insStart = i;
274
+ let k = i + 1;
275
+ let found = null;
276
+ while (k < lines.length && (lines[k].trim() === '' || /^\s/.test(lines[k]) && !/^-\s/.test(lines[k]) || /^\s{2,}/.test(lines[k]))) {
277
+ // 在 insert 列表内(行以空白开头或为列表项), 找 4 空格 "- id:"
278
+ const c = lines[k];
279
+ const m4 = /^\s{4}-\s*id:\s*(\S+)\s*$/.exec(c);
280
+ if (m4 && /^im-qqbot(-|$)/.test(m4[1])) { found = { line: k, id: m4[1] }; break; }
281
+ if (/^-\s/.test(c) && !/^\s/.test(c)) break; // 下一个顶层条目
282
+ if (c.trim() !== '' && !/^\s/.test(c) && !/^-\s/.test(c)) break;
283
+ k += 1;
284
+ }
285
+ if (found) {
286
+ const id = found.id;
287
+ let j = found.line + 1;
288
+ const cfg = {};
289
+ let disabled = false;
290
+ let inCfg = false;
291
+ while (j < lines.length && (lines[j].trim() === '' || /^\s{2,}/.test(lines[j]) && !isTopLine(lines[j]))) {
292
+ const c = lines[j];
293
+ if (/^\s{6}config:\s*$/.test(c)) inCfg = true;
294
+ else if (/^\s{6}disabled:\s*true\s*$/.test(c)) disabled = true;
295
+ else if (inCfg && /^\s{8}(\S[^:]*):\s*(.*)$/.test(c)) {
296
+ const km = /^\s{8}(\S[^:]*):\s*(.*)$/.exec(c);
297
+ cfg[km[1]] = km[2].trim().replace(/^'|'$/g, '').replace(/''/g, "'");
298
+ }
299
+ if (isTopLine(lines[j])) break;
300
+ j += 1;
301
+ }
302
+ bots.push({ id, start: insStart, end: j, ins: true, cfg, disabled });
303
+ i = j;
304
+ } else {
305
+ i += 1;
306
+ }
307
+ } else {
308
+ i += 1;
309
+ }
310
+ }
311
+ return { raw, hasFile, bots };
312
+ }
313
+
314
+ /** 渲染一个实例块。主 im-qqbot(bundle 已 insert)→ 顶层覆盖; 新实例 → `- insert:` 包装(4/6/8 缩进) */
315
+ function renderBotBlock(inst) {
316
+ const id = String(inst.id || 'im-qqbot');
317
+ const isMain = id === 'im-qqbot';
318
+ const L0 = isMain ? 0 : 4; // 条目行缩进(主=0, insert 内=4)
319
+ const pad0 = ' '.repeat(L0);
320
+ const pad2 = ' '.repeat(L0 + 2);
321
+ const pad4 = ' '.repeat(L0 + 4);
322
+ const out = [];
323
+ if (!isMain) out.push('- insert:');
324
+ out.push(`${pad0}- id: ${id}`);
325
+ if (!isMain) out.push(`${pad2}name: '@zaofan/dsh-qqbot'`);
326
+ if (inst.disabled) out.push(`${pad2}disabled: true`);
327
+ out.push(`${pad2}config:`);
328
+ if (inst.appId) out.push(`${pad4}appId: ${yq(inst.appId)}`);
329
+ if (inst.appSecret) out.push(`${pad4}appSecret: ${ysec(inst.appSecret)}`);
330
+ if (!isMain) out.push(`${pad4}settingsNs: ${yq(id)}`); // 多账号实例身份: settings 命名空间按实例唯一
331
+ if (inst.preset) out.push(`${pad4}preset: ${yq(inst.preset)}`);
332
+ out.push(`${pad4}requireMention: false`);
333
+ out.push(`${pad4}historyLimit: 20`);
334
+ if (inst.cwd) out.push(`${pad4}cwd: ${yq(inst.cwd)}`);
335
+ return out.join('\n');
336
+ }
337
+
338
+ /** 保存实例清单(全量同步; 行级重建, 其它插件行/注释原样保留)
339
+ * 借鉴 dsh-qqbot-panel(2026-09-06): appSecret 空值/掩码(********) = 保留原值,
340
+ * 只有提供全新非掩码值才覆盖 —— 前端只回显掩码, 不会因漏传/未改而误清 secret。 */
341
+ function saveInstances(instances) {
342
+ const { raw, hasFile, bots } = parsePatch();
343
+ if (!hasFile) return { ok: false, error: '找不到 cordis.patch.yml(仅 web profile 支持)' };
344
+ const lines = raw.split('\n');
345
+ const edits = [];
346
+ const targetIds = new Set(instances.filter((x) => !x.remove).map((x) => String(x.id)));
347
+ for (const b of bots) {
348
+ if (!targetIds.has(b.id)) edits.push({ start: b.start, end: b.end, text: null });
349
+ }
350
+ for (const inst of instances) {
351
+ if (inst.remove) continue;
352
+ const t = bots.find((b) => b.id === inst.id);
353
+ // 掩码/空 → 保留原 secret(仅当原值存在; 全新账号本就无原值则维持空)
354
+ if (!inst.appSecret || inst.appSecret === SECRET_MASK) {
355
+ const orig = bots.find((b) => b.id === inst.id);
356
+ if (orig && orig.cfg?.appSecret) inst.appSecret = orig.cfg.appSecret;
357
+ else inst.appSecret = '';
358
+ }
359
+ const blockText = renderBotBlock(inst);
360
+ if (t) edits.push({ start: t.start, end: t.end, text: blockText });
361
+ else edits.push({ append: blockText });
362
+ }
363
+ edits.filter((e) => e.start !== undefined).sort((a, b) => b.start - a.start).forEach((e) => {
364
+ if (e.text === null) lines.splice(e.start, e.end - e.start);
365
+ else lines.splice(e.start, e.end - e.start, ...e.text.split('\n'));
366
+ });
367
+ let out = lines.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd() + '\n';
368
+ const appendParts = edits.filter((e) => e.append).map((e) => e.append);
369
+ if (appendParts.length) out += (out ? '\n' : '') + appendParts.join('\n') + '\n';
370
+ writeFileSync(`${PATCH_FILE}.bak`, raw, 'utf8');
371
+ writeFileSync(PATCH_FILE, out, 'utf8');
372
+ return { ok: true, file: PATCH_FILE, needRestart: true };
373
+ }
374
+
375
+ // 账号列表(多账号: 每条带 settings ns 与数据目录, 供二级 UI 按账号读写)
376
+ // 借鉴 zhengjy01/dsh-qqbot-panel(2026-09-06): appSecret 只回显掩码(不泄露明文),
377
+ // 并带 hasSecret 供前端区分"已保存"与"真空"; 保存时空值/掩码 = 保留原 secret。
378
+ const SECRET_MASK = '********';
379
+ route(ctx, 'GET', '/api/qqbot-settings/accounts', async (_req, res) => {
380
+ try {
381
+ const { bots, hasFile } = parsePatch();
382
+ writeJson(res, 200, {
383
+ hasFile,
384
+ instances: bots.map((b) => {
385
+ const cwd = b.cfg?.cwd || '';
386
+ return {
387
+ id: b.id,
388
+ ns: b.id, // settings 命名空间 = 实例 id(主 im-qqbot; 非主实例 render 已写 settingsNs=id)
389
+ appId: b.cfg?.appId || '',
390
+ appSecret: b.cfg?.appSecret ? SECRET_MASK : '', // 掩码回显(借鉴 panel: masked)
391
+ hasSecret: !!b.cfg?.appSecret,
392
+ preset: b.cfg?.preset || '',
393
+ cwd,
394
+ disabled: !!b.disabled,
395
+ // 账号数据目录(各号各库各定时): 图库={cwd}/表情包, 定时={cwd}/.qqbot
396
+ dataDir: cwd ? join(cwd, '表情包') : '',
397
+ schedDir: cwd ? join(cwd, '.qqbot') : '',
398
+ };
399
+ }),
400
+ });
401
+ } catch (e) { writeJson(res, 500, { error: String(e?.message ?? e) }); }
402
+ });
403
+ // 保存账号清单: body {instances:[{id,appId,appSecret,preset,cwd,disabled,remove}]}
404
+ route(ctx, 'POST', '/api/qqbot-settings/accounts/save', async (req, res) => {
405
+ const body = await readJsonBody(req);
406
+ if (!body || !Array.isArray(body.instances)) return writeJson(res, 400, { error: 'instances 数组必填' });
407
+ try {
408
+ const r = saveInstances(body.instances);
409
+ writeJson(res, r.ok ? 200 : 400, r);
410
+ } catch (e) { writeJson(res, 500, { error: String(e?.message ?? e) }); }
411
+ });
412
+
413
+ // ── 工作区列表(借鉴 zhengjy01/dsh-qqbot-panel 的 workspace picker, 2026-09-06):
414
+ // 列出"已有 dsh 会话"的工作区目录 + 会话数, 供账号 cwd 挑选(避免指到空目录/错目录)。
415
+ // dsh 把会话按工作区路径归到 ~/.dsh/sessions/--<编码路径>--, 目录内 *.zstd = 会话数。
416
+ /** dsh session 目录名 → 工作区绝对路径(~XXXX 为 UTF-16 code unit, - 为段分隔, 首段补盘符冒号) */
417
+ function decodeSessionDirName(name) {
418
+ try {
419
+ const segs = name.split('-').filter(Boolean);
420
+ if (segs.length === 0) return '';
421
+ const decoded = segs.map((s) => s.replace(/~([0-9A-F]{4})/g, (_m, hex) => String.fromCharCode(parseInt(hex, 16))));
422
+ // 首段是盘符(D / C) → 补冒号; 其余段拼回路径分隔
423
+ const head = /^[A-Za-z]$/.test(decoded[0]) ? decoded[0] + ':' : decoded[0];
424
+ return [head].concat(decoded.slice(1)).join('\\');
425
+ } catch { return ''; }
426
+ }
427
+ route(ctx, 'GET', '/api/qqbot-settings/workspaces', async (_req, res) => {
428
+ try {
429
+ const sessionsRoot = join(homedir(), '.dsh', 'sessions');
430
+ const out = [];
431
+ if (existsSync(sessionsRoot)) {
432
+ for (const ent of readdirSync(sessionsRoot, { withFileTypes: true })) {
433
+ if (!ent.isDirectory()) continue;
434
+ const dir = join(sessionsRoot, ent.name);
435
+ let count = 0;
436
+ try {
437
+ // 会话文件(.zstd)在 workspace 目录的深层子目录里, 需递归统计
438
+ const stack = [dir];
439
+ while (stack.length) {
440
+ const cur = stack.pop();
441
+ for (const f of readdirSync(cur, { withFileTypes: true })) {
442
+ const p = join(cur, f.name);
443
+ if (f.isDirectory()) stack.push(p);
444
+ else if (f.isFile() && f.name.endsWith('.zstd')) count += 1;
445
+ }
446
+ }
447
+ } catch { /* 目录读取失败忽略 */ }
448
+ const path = decodeSessionDirName(ent.name);
449
+ out.push({ dir: ent.name, path: path || ent.name, count });
450
+ }
451
+ }
452
+ out.sort((a, b) => b.count - a.count);
453
+ writeJson(res, 200, { workspaces: out });
454
+ } catch (e) { writeJson(res, 500, { error: String(e?.message ?? e) }); }
455
+ });
456
+
457
+ // ── agent 预设: 列表/复制/打开文件夹/说明 ──
458
+ /** QQ 通道工具插件的规范行 id/name(复制预设时自动补上; 新行名跟随本安装主包) */
459
+ const CHANNEL_TOOLS_ROW_ID = 'qqbot-channel-tools';
460
+ const CHANNEL_TOOLS_ROW_NAME = '@zaofan/dsh-qqbot/channel-tools';
461
+ /** 检测 preset 的 agent.cordis.yml 是否已含 qqbot-channel-tools 插件行 */
462
+ function presetHasChannelTools(base) {
463
+ try {
464
+ const txt = readFileSync(join(base, 'agent.cordis.yml'), 'utf8');
465
+ // 匹配连续的 "- id: qqbot-channel-tools" 与 "name: .../channel-tools" 两行(m: $=每行行尾)
466
+ return /id:\s*qqbot-channel-tools\s*\n[ \t]*name:\s*['"]?[^'\n]*\/channel-tools['"]?\s*$/m.test(txt);
467
+ } catch { return false; }
468
+ }
469
+ /** 若 preset 缺 channel-tools 插件行, 在 agent.cordis.yml 末尾自动补上(幂等) */
470
+ function ensureChannelToolsRow(base) {
471
+ const p = join(base, 'agent.cordis.yml');
472
+ let txt = '';
473
+ try { txt = readFileSync(p, 'utf8'); } catch { return false; }
474
+ if (presetHasChannelTools(base)) return true;
475
+ const sep = txt.endsWith('\n') ? '' : '\n';
476
+ txt += `${sep}- id: ${CHANNEL_TOOLS_ROW_ID}\n name: '${CHANNEL_TOOLS_ROW_NAME}'\n`;
477
+ writeFileSync(p, txt, 'utf8');
478
+ return true;
479
+ }
480
+ function scanPresets() {
481
+ if (!existsSync(PRESET_ROOT)) return [];
482
+ const out = [];
483
+ for (const dir of readdirSync(PRESET_ROOT, { withFileTypes: true })) {
484
+ if (!dir.isDirectory()) continue;
485
+ const id = dir.name;
486
+ const base = join(PRESET_ROOT, id);
487
+ if (!existsSync(join(base, 'agent.cordis.yml'))) continue;
488
+ let name = '';
489
+ try {
490
+ const pm = join(base, 'preset.yml');
491
+ if (existsSync(pm)) {
492
+ const m = /^name:\s*(.*)$/m.exec(readFileSync(pm, 'utf8'));
493
+ if (m) name = m[1].trim().replace(/^["']|["']$/g, '');
494
+ }
495
+ } catch { /* ignore */ }
496
+ let mtime = 0;
497
+ try { mtime = statSync(join(base, 'agent.cordis.yml')).mtimeMs; } catch { /* ignore */ }
498
+ out.push({ id, name: name || id, mtime, hasChannelTools: presetHasChannelTools(base) });
499
+ }
500
+ return out.sort((a, b) => b.mtime - a.mtime);
501
+ }
502
+ route(ctx, 'GET', '/api/qqbot-settings/presets', async (_req, res) => {
503
+ writeJson(res, 200, { root: PRESET_ROOT, presets: scanPresets() });
504
+ });
505
+ // 复制并双名: {sourceId, newId, newName}
506
+ route(ctx, 'POST', '/api/qqbot-settings/presets/copy', async (req, res) => {
507
+ const body = await readJsonBody(req);
508
+ if (!body || typeof body.sourceId !== 'string' || typeof body.newId !== 'string') {
509
+ return writeJson(res, 400, { error: 'sourceId/newId 必填' });
510
+ }
511
+ const newId = body.newId.trim();
512
+ if (!ID_RE.test(newId)) return writeJson(res, 400, { error: '预设 id 只能字母/数字开头,含 - _(也是文件夹名)' });
513
+ const src = join(PRESET_ROOT, String(body.sourceId).trim());
514
+ const dst = join(PRESET_ROOT, newId);
515
+ if (!existsSync(join(src, 'agent.cordis.yml'))) return writeJson(res, 404, { error: '源预设不存在' });
516
+ if (existsSync(dst)) return writeJson(res, 409, { error: `预设 ${newId} 已存在` });
517
+ try {
518
+ cpSync(src, dst, { recursive: true, filter: (s) => !COPY_SKIP.test(s) });
519
+ // 复制出的新预设自动补上 QQ 通道工具插件行(缺则加; 幂等)——保证新人格在 QQ 里能发图/查表情包
520
+ ensureChannelToolsRow(dst);
521
+ // 人设名写入新 preset.yml(name 为 YAML 双引号标量; JSON 转义兼容)
522
+ const pm = join(dst, 'preset.yml');
523
+ if (existsSync(pm)) {
524
+ const txt = readFileSync(pm, 'utf8');
525
+ const nameYaml = JSON.stringify(String(body.newName ?? newId));
526
+ const next = txt.replace(/^name:.*$/m, `name: ${nameYaml}`);
527
+ writeFileSync(pm, next, 'utf8');
528
+ }
529
+ writeJson(res, 200, { ok: true, newId });
530
+ } catch (e) { writeJson(res, 500, { error: String(e?.message ?? e) }); }
531
+ });
532
+ // 从内置"标准模式(standard)"新建一个预设(空机器起步用): 复制宿主 standard + 自动补 QQ 通道工具行
533
+ // 复用宿主原生 agentPresets.copy(from,id,name) —— 复制出的正是宿主当前标准版, 保证能跑; 无需自己拼组合。
534
+ route(ctx, 'POST', '/api/qqbot-settings/presets/new', async (req, res) => {
535
+ const body = await readJsonBody(req);
536
+ const newId = String(body?.id ?? '').trim();
537
+ const newName = String(body?.name ?? '').trim() || undefined;
538
+ if (!ID_RE.test(newId)) return writeJson(res, 400, { error: '预设 id 只能字母/数字开头,含 - _(也是文件夹名)' });
539
+ try {
540
+ const ap = (ctx).agentPresets;
541
+ if (!ap || typeof ap.copy !== 'function') {
542
+ return writeJson(res, 500, { error: '宿主未提供 agentPresets 服务, 无法从标准模式新建' });
543
+ }
544
+ await ap.copy('standard', newId, newName);
545
+ const dstDir = join(PRESET_ROOT, newId);
546
+ ensureChannelToolsRow(dstDir);
547
+ writeJson(res, 200, { ok: true, newId });
548
+ } catch (e) {
549
+ const msg = String(e?.message ?? e);
550
+ // 宿主 RemoteError 里的用户可读文案: 取含中文(预设/已存在)的后段
551
+ const m = /(预设|preset|id|已存在|exist)[^\n]*$/im.exec(msg);
552
+ writeJson(res, 500, { error: m ? m[0] : msg });
553
+ }
554
+ });
555
+ // 打开预设文件夹(本机弹资源管理器; 仅限 presetRoot 内白名单路径)
556
+ route(ctx, 'POST', '/api/qqbot-settings/presets/open', async (req, res) => {
557
+ const body = await readJsonBody(req);
558
+ const id = String(body?.id ?? '').trim();
559
+ if (!ID_RE.test(id)) return writeJson(res, 400, { error: '非法的预设 id' });
560
+ const dir = join(PRESET_ROOT, id);
561
+ if (!dir.startsWith(PRESET_ROOT) || !existsSync(join(dir, 'agent.cordis.yml'))) {
562
+ return writeJson(res, 404, { error: '预设不存在' });
563
+ }
564
+ try {
565
+ const child = spawn('explorer', [dir], { detached: true, stdio: 'ignore' });
566
+ child.unref();
567
+ writeJson(res, 200, { ok: true });
568
+ } catch (e) { writeJson(res, 500, { error: String(e?.message ?? e) }); }
569
+ });
570
+
571
+ // ── 扫码绑定注册流程(每个账号走 QQ 官方绑定, 拿平台下发的 appId/appSecret) ──
572
+ // 前端拿 qrUrl 打开授权页(手机 QQ 扫码) → host 轮询 → 完成回填。displayQrCodeToConsole=false。
573
+ const bindSessions = new Map(); // id -> {url, creds, error, stop}
574
+ let bindSeq = 0;
575
+ route(ctx, 'POST', '/api/qqbot-settings/bind/start', async (_req, res) => {
576
+ const id = 'bind-' + (++bindSeq) + '-' + Date.now().toString(36);
577
+ try {
578
+ const { startQrConnect } = await import('@tencent-connect/qqbot-connector');
579
+ const sess = { url: '', creds: null, error: null };
580
+ bindSessions.set(id, sess);
581
+ const stop = startQrConnect({
582
+ onSuccess: (creds) => { sess.creds = Array.isArray(creds) ? creds[0] : creds; },
583
+ onFailure: (err) => { sess.error = String(err?.message ?? err); },
584
+ onQrDisplayed: (url) => { sess.url = url; },
585
+ onQrExpired: () => { /* 前端轮询会拿到新 url */ },
586
+ }, { displayQrCodeToConsole: false, source: 'dsh-qqbot' });
587
+ sess.stop = stop;
588
+ // 等第一个二维码 URL(最多 12s)
589
+ const deadline = Date.now() + 12000;
590
+ while (!sess.url && !sess.error && Date.now() < deadline) {
591
+ await new Promise((r) => setTimeout(r, 300));
592
+ }
593
+ if (!sess.url) {
594
+ stop();
595
+ bindSessions.delete(id);
596
+ return writeJson(res, 500, { error: sess.error || '获取绑定二维码超时' });
597
+ }
598
+ writeJson(res, 200, { id, url: sess.url });
599
+ } catch (e) {
600
+ writeJson(res, 500, { error: String(e?.message ?? e) });
601
+ }
602
+ });
603
+ route(ctx, 'GET', '/api/qqbot-settings/bind/poll', async (req, res) => {
604
+ const u = new URL(req.url ?? '/', 'http://x');
605
+ const id = u.searchParams.get('id') || '';
606
+ const sess = bindSessions.get(id);
607
+ if (!sess) return writeJson(res, 404, { error: '绑定会话不存在或已结束' });
608
+ if (sess.creds) { bindSessions.delete(id); return writeJson(res, 200, { status: 'done', appId: sess.creds.appId, appSecret: sess.creds.appSecret }); }
609
+ if (sess.error) { bindSessions.delete(id); return writeJson(res, 200, { status: 'error', error: sess.error }); }
610
+ writeJson(res, 200, { status: 'wait', url: sess.url });
611
+ });
612
+ route(ctx, 'POST', '/api/qqbot-settings/bind/cancel', async (req, res) => {
613
+ const body = await readJsonBody(req);
614
+ const sess = bindSessions.get(String(body?.id ?? ''));
615
+ if (sess) { try { sess.stop?.(); } catch { /* ignore */ } bindSessions.delete(String(body?.id ?? '')); }
616
+ writeJson(res, 200, { ok: true });
617
+ });
618
+
619
+ // ── 图库管理 ──
620
+ // 列表: /api/qqbot-settings/stickers?layer=&q=&untagged=1&includeTrash=1
621
+ route(ctx, 'GET', '/api/qqbot-settings/stickers', async (req, res) => {
622
+ const u = new URL(req.url ?? '/', 'http://x');
623
+ const layer = u.searchParams.get('layer') || undefined;
624
+ const q = u.searchParams.get('q') || undefined;
625
+ const untagged = u.searchParams.get('untagged') === '1';
626
+ const includeTrash = u.searchParams.get('includeTrash') === '1';
627
+ const store = dirOf(u, 'dataDir') ? getStickerStore(dirOf(u, 'dataDir')) : getStickerStore();
628
+ const items = store.queryAll({ layer: layer === 'all' ? undefined : layer, q: q || undefined, untagged, includeTrash });
629
+ writeJson(res, 200, { items, total: items.length });
630
+ });
631
+
632
+ // 缩略图: /api/qqbot-settings/sticker-img?id=xxx (id 由 store 内部解析路径, 不外接路径)
633
+ route(ctx, 'GET', '/api/qqbot-settings/sticker-img', async (req, res) => {
634
+ const u = new URL(req.url ?? '/', 'http://x');
635
+ const id = u.searchParams.get('id') || '';
636
+ const store = dirOf(u, 'dataDir') ? getStickerStore(dirOf(u, 'dataDir')) : getStickerStore();
637
+ const meta = store.get(id);
638
+ if (!meta) return writeJson(res, 404, { error: 'not found' });
639
+ const p = store.pathOf(id);
640
+ const buf = readFileSync(p);
641
+ const ext = extname(p).replace('.', '').toLowerCase();
642
+ res.writeHead(200, { 'content-type': MIME[ext] ?? 'application/octet-stream', 'cache-control': 'private, max-age=3600' });
643
+ res.end(buf);
644
+ });
645
+
646
+ // 批量: {op:'promote'|'trash'|'restore'|'tag', ids:[], tags?:string[], desc?:string}
647
+ route(ctx, 'POST', '/api/qqbot-settings/stickers/batch', async (req, res) => {
648
+ const body = await readJsonBody(req);
649
+ if (!body || typeof body !== 'object') return writeJson(res, 400, { error: 'bad body' });
650
+ const store = typeof body.dataDir === 'string' && body.dataDir ? getStickerStore(body.dataDir) : getStickerStore();
651
+ const op = body.op;
652
+ const ids = Array.isArray(body.ids) ? body.ids.map(String) : [];
653
+ let okN = 0;
654
+ for (const id of ids) {
655
+ if (op === 'promote') { if (store.promote(id)) okN += 1; }
656
+ else if (op === 'trash') { if (store.markTrash(id)) okN += 1; }
657
+ else if (op === 'restore') { if (store.restore(id)) okN += 1; }
658
+ else if (op === 'tag') { if (store.setDescTags(id, Array.isArray(body.tags) ? body.tags : undefined, typeof body.desc === 'string' ? body.desc : undefined)) okN += 1; }
659
+ else return writeJson(res, 400, { error: `unknown op ${op}` });
660
+ }
661
+ try { store.flush(); } catch { /* ignore */ } // 批量改完立即落盘, 防杀进程丢最后一笔
662
+ writeJson(res, 200, { ok: okN, total: ids.length });
663
+ });
664
+
665
+ // 导入: {paths?: string[], urls?: string[], files?: [{name,dataBase64}]} → 逐项导入
666
+ route(ctx, 'POST', '/api/qqbot-settings/stickers/import', async (req, res) => {
667
+ const body = await readJsonBody(req);
668
+ if (!body || typeof body !== 'object') return writeJson(res, 400, { error: 'bad body' });
669
+ const store = typeof body.dataDir === 'string' && body.dataDir ? getStickerStore(body.dataDir) : getStickerStore();
670
+ const results = [];
671
+ const paths = Array.isArray(body.paths) ? body.paths : [];
672
+ const urls = Array.isArray(body.urls) ? body.urls : [];
673
+ const files = Array.isArray(body.files) ? body.files : [];
674
+ for (const p of paths) {
675
+ try {
676
+ const r = store.importLocalFile(String(p));
677
+ results.push({ ref: String(p), status: r.status, id: r.id, error: r.error });
678
+ } catch (e) { results.push({ ref: String(p), status: 'error', error: String(e?.message ?? e) }); }
679
+ }
680
+ for (const url of urls) {
681
+ try {
682
+ const r = await store.capture(String(url), { sourceUrl: String(url) }, undefined);
683
+ results.push({ ref: String(url), status: r.status, id: r.id, error: r.error });
684
+ } catch (e) { results.push({ ref: String(url), status: 'error', error: String(e?.message ?? e) }); }
685
+ }
686
+ for (const f of files) {
687
+ const name = String(f?.name ?? 'upload');
688
+ const b64 = typeof f?.dataBase64 === 'string' ? f.dataBase64 : '';
689
+ try {
690
+ if (!b64) throw new Error('no data');
691
+ const buf = Buffer.from(b64, 'base64');
692
+ const tmpDir = join(store.dataDir, '.import');
693
+ mkdirSync(tmpDir, { recursive: true });
694
+ const tmp = join(tmpDir, `${Date.now()}-${name.replace(/[^a-zA-Z0-9._-]/g, '_')}`);
695
+ writeFileSync(tmp, buf);
696
+ const r = store.importLocalFile(tmp);
697
+ try { rmSync(tmp, { force: true }); } catch { /* ignore */ }
698
+ results.push({ ref: name, status: r.status, id: r.id, error: r.error });
699
+ } catch (e) { results.push({ ref: name, status: 'error', error: String(e?.message ?? e) }); }
700
+ }
701
+ try { store.flush(); } catch { /* ignore */ } // 导入完立即落盘
702
+ writeJson(res, 200, { results });
703
+ });
704
+
705
+ // ── ⑥QQ 群管理面板(P3, 2026-09-05): 多群管理 host 路由(读/写走 GroupAdminClient; 面板=主人直发免 QQ 审批) ──
706
+ // 数据: {cwd}/.qqbot/{groups.json 群注册表, join-pending.json 待审, group-audit.jsonl 审计日志}
707
+ // 鉴权: 与全路由一致走 route() 同源 loopback fence; 面板主人级鉴权(P4 加强)。
708
+ function nsBot(ns) {
709
+ const { bots } = parsePatch();
710
+ const id = ns && ns !== 'im-qqbot' ? String(ns) : 'im-qqbot';
711
+ const b = bots.find((x) => x.id === id);
712
+ if (!b) return null;
713
+ const appId = b.cfg?.appId || '';
714
+ const appSecret = b.cfg?.appSecret || '';
715
+ const cwd = b.cfg?.cwd || '';
716
+ if (!appId || !appSecret) return null;
717
+ return { id, appId, appSecret, cwd, ns: id };
718
+ }
719
+ async function groupClientOf(ns) {
720
+ const bot = nsBot(ns);
721
+ if (!bot) return null;
722
+ const mod = await import('./dist/api/group-admin.js');
723
+ return { bot, client: mod.createGroupAdmin({ appId: bot.appId, appSecret: bot.appSecret }) };
724
+ }
725
+ function audit(cwd, entry) {
726
+ try {
727
+ mkdirSync(join(cwd, '.qqbot'), { recursive: true });
728
+ const af = join(cwd, '.qqbot', 'group-audit.jsonl');
729
+ writeFileSync(af, JSON.stringify({ ts: new Date().toISOString(), ...entry }) + '\n', { flag: 'a' });
730
+ } catch { /* ignore */ }
731
+ }
732
+ function readGroupsJson(cwd) {
733
+ try { return JSON.parse(readFileSync(join(cwd, '.qqbot', 'groups.json'), 'utf8') || '{}'); } catch { return {}; }
734
+ }
735
+ function writeGroupsJson(cwd, g) {
736
+ try { mkdirSync(join(cwd, '.qqbot'), { recursive: true }); writeFileSync(join(cwd, '.qqbot', 'groups.json'), JSON.stringify(g, null, 1), 'utf8'); } catch { /* ignore */ }
737
+ }
738
+ function readPendingJson(cwd) {
739
+ try { return JSON.parse(readFileSync(join(cwd, '.qqbot', 'join-pending.json'), 'utf8') || '{}'); } catch { return {}; }
740
+ }
741
+ const NSQ = (u) => (u.searchParams.get('ns') || '').trim() || undefined;
742
+ const GQ = (u) => (u.searchParams.get('gid') || '').trim();
743
+
744
+ // 群列表(注册表 + pending 出现过的群 + 台账群; 逐个调官方 info 校验有效性+补群名, 11255=群已注销/不存在则标记失效并过滤)
745
+ route(ctx, 'GET', '/api/qqbot-settings/group/accounts', async (req, res) => {
746
+ try {
747
+ const u = new URL(req.url ?? '/', 'http://x');
748
+ const bot = nsBot(NSQ(u));
749
+ if (!bot) return writeJson(res, 400, { error: '找不到该账号实例(请先在账号页配置 appId/appSecret)' });
750
+ const reg = readGroupsJson(bot.cwd);
751
+ const pend = readPendingJson(bot.cwd);
752
+ const map = new Map();
753
+ for (const [gid, meta] of Object.entries(reg)) {
754
+ if (!gid) continue;
755
+ map.set(gid, { gid, name: (meta && meta.name) || '', from: 'registry', lastAt: (meta && meta.lastAt) || 0 });
756
+ }
757
+ for (const gid of Object.keys(pend)) {
758
+ if (!gid) continue;
759
+ if (map.has(gid)) { const m = map.get(gid); m.from = 'registry+pending'; }
760
+ else map.set(gid, { gid, name: '', from: 'pending', lastAt: 0 });
761
+ }
762
+ try {
763
+ const store = getStickerStore(bot.cwd ? join(bot.cwd, '表情包') : undefined);
764
+ const raw = readFileSync(join(store.dataDir, 'known-chats.jsonl'), 'utf8');
765
+ for (const l of raw.split('\n')) {
766
+ const t = l.trim(); if (!t) continue;
767
+ try {
768
+ const o = JSON.parse(t);
769
+ if (o && o.scope === 'group' && typeof o.id === 'string' && o.id) {
770
+ if (map.has(o.id)) { if (!map.get(o.id).name && o.name) map.get(o.id).name = o.name; }
771
+ else map.set(o.id, { gid: o.id, name: o.name || '', from: 'ledger', lastAt: typeof o.ts === 'number' ? o.ts : 0 });
772
+ }
773
+ } catch { /* 坏行跳过 */ }
774
+ }
775
+ } catch { /* 无台账 */ }
776
+ // 逐个校验有效性 + 用官方群名补全显示名(注册表里的群也顺手回写官方名)
777
+ const gc = await groupClientOf(NSQ(u));
778
+ const out = [];
779
+ const aliveReg = {};
780
+ if (gc) {
781
+ for (const g of [...map.values()].sort((a, b) => (b.lastAt || 0) - (a.lastAt || 0))) {
782
+ try {
783
+ const info = await gc.client.getGroupInfo(g.gid);
784
+ if (info.ok && info.data) {
785
+ const official = info.data.group_name || '';
786
+ if (official) g.name = official;
787
+ out.push(g);
788
+ aliveReg[g.gid] = { name: official || (reg[g.gid] && reg[g.gid].name) || '', lastAt: (reg[g.gid] && reg[g.gid].lastAt) || Date.now() };
789
+ } else {
790
+ // 11255 等 = 群已注销/不存在 → 不返回给 UI(避免选中后调用报错), 仅保留审计
791
+ audit(bot.cwd, { ev: 'group.dead', ns: bot.id, gid: g.gid, code: info.err && info.err.code, human: info.err && info.err.human });
792
+ }
793
+ } catch (e2) {
794
+ audit(bot.cwd, { ev: 'group.check-error', ns: bot.id, gid: g.gid, error: String((e2 && e2.message) || e2) });
795
+ out.push(g); // 网络类错误不判死, 保守保留
796
+ }
797
+ }
798
+ // 回写有效群注册表(自动剔除已注销群)
799
+ if (Object.keys(aliveReg).length >= 0) writeGroupsJson(bot.cwd, aliveReg);
800
+ } else {
801
+ out.push(...map.values());
802
+ }
803
+ writeJson(res, 200, { groups: out });
804
+ } catch (e) { writeJson(res, 500, { error: String((e && e.message) || e) }); }
805
+ });
806
+
807
+ // 待审入群申请(官方列表 + 本地 pending 已处理标记)
808
+ route(ctx, 'GET', '/api/qqbot-settings/group/join_requests', async (req, res) => {
809
+ try {
810
+ const u = new URL(req.url ?? '/', 'http://x');
811
+ const gid = GQ(u);
812
+ if (!gid) return writeJson(res, 400, { error: 'gid 必填' });
813
+ const gc = await groupClientOf(NSQ(u));
814
+ if (!gc) return writeJson(res, 400, { error: '找不到该账号实例(请先在账号页配置 appId/appSecret)' });
815
+ const r = await gc.client.listJoinRequests(gid);
816
+ if (!r.ok) return writeJson(res, 200, { ok: false, err: r.err });
817
+ const pend = readPendingJson(gc.bot.cwd);
818
+ const local = (pend[gid] || []).map((x) => ({ member_openid: x.member_openid, join_request_id: x.join_request_id, notified: !!x.notified, seen_at: x.seen_at }));
819
+ writeJson(res, 200, { ok: true, list: r.data.list, local });
820
+ } catch (e) { writeJson(res, 500, { error: String((e && e.message) || e) }); }
821
+ });
822
+
823
+ // 本地成员清单(官方成员列表未开放 → 用机器人见过的发言者兜底; 供禁言"选人"与成员 Tab 展示)
824
+ route(ctx, 'GET', '/api/qqbot-settings/group/members_local', async (req, res) => {
825
+ try {
826
+ const u = new URL(req.url ?? '/', 'http://x');
827
+ const gid = GQ(u);
828
+ const bot = nsBot(NSQ(u));
829
+ if (!bot) return writeJson(res, 400, { error: '找不到该账号实例(请先在账号页配置 appId/appSecret)' });
830
+ const store = getStickerStore(bot.cwd ? join(bot.cwd, '表情包') : undefined);
831
+ // 与台账同 dataDir 的 group-members.jsonl
832
+ const mod = await import('./dist/features/chat-ledger.js');
833
+ const members = mod.readGroupMembers(store.dataDir, gid || undefined);
834
+ writeJson(res, 200, { ok: true, members });
835
+ } catch (e) { writeJson(res, 500, { error: String((e && e.message) || e) }); }
836
+ });
837
+
838
+ // 手动审批入群 {ns?, gid, member_openid, op:approve|decline, reason?}
839
+ route(ctx, 'POST', '/api/qqbot-settings/group/approve', async (req, res) => {
840
+ const body = await readJsonBody(req);
841
+ if (!body || typeof body !== 'object') return writeJson(res, 400, { error: 'bad body' });
842
+ const gid = String(body.gid || '');
843
+ const mid = String(body.member_openid || '');
844
+ const op = String(body.op || '');
845
+ if (!gid || !mid || (op !== 'approve' && op !== 'decline')) return writeJson(res, 400, { error: 'gid/member_openid/op(approve|decline) 必填' });
846
+ try {
847
+ const gc = await groupClientOf(String(body.ns || ''));
848
+ if (!gc) return writeJson(res, 400, { error: '找不到该账号实例(请先在账号页配置 appId/appSecret)' });
849
+ const listR = await gc.client.listJoinRequests(gid);
850
+ const found = listR.ok ? (listR.data.list || []).find((x) => x.member_openid === mid) : undefined;
851
+ const r = await gc.client.approveJoinRequest(gid, mid, op, {
852
+ ...(found ? { join_request_id: found.join_request_id } : {}),
853
+ ...(op === 'decline' && String(body.reason || '') ? { reject_reason: String(body.reason) } : {}),
854
+ });
855
+ audit(gc.bot.cwd, { ev: 'group.approve', ns: gc.bot.id, gid, member_openid: mid, op, reason: String(body.reason || ''), ok: r.ok, code: r.ok ? undefined : (r.err && r.err.code) });
856
+ writeJson(res, r.ok ? 200 : 200, r.ok ? { ok: true, msg: op === 'approve' ? '已放行' : '已拒绝' } : { ok: false, err: r.err });
857
+ } catch (e) { writeJson(res, 500, { error: String((e && e.message) || e) }); }
858
+ });
859
+
860
+ // 禁言状态 {ns?, gid}
861
+ route(ctx, 'GET', '/api/qqbot-settings/group/mute_state', async (req, res) => {
862
+ try {
863
+ const u = new URL(req.url ?? '/', 'http://x');
864
+ const gid = GQ(u);
865
+ if (!gid) return writeJson(res, 400, { error: 'gid 必填' });
866
+ const gc = await groupClientOf(NSQ(u));
867
+ if (!gc) return writeJson(res, 400, { error: '找不到该账号实例(请先在账号页配置 appId/appSecret)' });
868
+ const r = await gc.client.getMuteState(gid);
869
+ writeJson(res, 200, r.ok ? { ok: true, data: r.data } : { ok: false, err: r.err });
870
+ } catch (e) { writeJson(res, 500, { error: String((e && e.message) || e) }); }
871
+ });
872
+
873
+ // 手动禁言/解禁 {ns?, gid, member_openid, action:mute|unmute, seconds?}
874
+ route(ctx, 'POST', '/api/qqbot-settings/group/mute', async (req, res) => {
875
+ const body = await readJsonBody(req);
876
+ if (!body || typeof body !== 'object') return writeJson(res, 400, { error: 'bad body' });
877
+ const gid = String(body.gid || '');
878
+ const mid = String(body.member_openid || '');
879
+ const action = String(body.action || '');
880
+ if (!gid || !mid || (action !== 'mute' && action !== 'unmute')) return writeJson(res, 400, { error: 'gid/member_openid/action(mute|unmute) 必填' });
881
+ try {
882
+ const gc = await groupClientOf(String(body.ns || ''));
883
+ if (!gc) return writeJson(res, 400, { error: '找不到该账号实例(请先在账号页配置 appId/appSecret)' });
884
+ const secs = action === 'mute' ? Math.max(1, Math.min(30 * 86400, Math.round(Number(body.seconds || 600)))) : 0;
885
+ const p2 = (n) => String(n).padStart(2, '0');
886
+ const expire = action === 'mute' ? new Date(Date.now() + secs * 1000) : null;
887
+ const rfc = expire ? expire.getFullYear() + '-' + p2(expire.getMonth() + 1) + '-' + p2(expire.getDate()) + 'T' + p2(expire.getHours()) + ':' + p2(expire.getMinutes()) + ':' + p2(expire.getSeconds()) + '+08:00' : null;
888
+ const r = await gc.client.setMemberMute(gid, mid, rfc);
889
+ audit(gc.bot.cwd, { ev: 'group.mute', ns: gc.bot.id, gid, member_openid: mid, action, seconds: secs, ok: r.ok, code: r.ok ? undefined : (r.err && r.err.code) });
890
+ // ⚠️ 官方对"禁言已退群成员"静默返回成功但不生效(实测 HTTP 200 空体, 状态列表无此人)
891
+ // → 禁言后回读 restrict_chat_setting 验证: 目标不在成员列表 = 已不在群/无法禁言 → 人话提示 + 自动剔除本地名单
892
+ if (r.ok && action === 'mute') {
893
+ try {
894
+ const st = await gc.client.getMuteState(gid);
895
+ const stMembers = (st.ok && st.data && Array.isArray(st.data.members) ? st.data.members : []) || [];
896
+ const present = stMembers.some((m) => m.member_openid === mid);
897
+ if (!present) {
898
+ const store = getStickerStore(gc.bot.cwd ? join(gc.bot.cwd, '表情包') : undefined);
899
+ const ledgerMod = await import('./dist/features/chat-ledger.js');
900
+ ledgerMod.removeGroupMember(store.dataDir, gid, mid);
901
+ audit(gc.bot.cwd, { ev: 'group.member-auto-remove', ns: gc.bot.id, gid, member_openid: mid, reason: '禁言无效(成员已不在群)' });
902
+ return writeJson(res, 200, { ok: false, err: { code: 'MEMBER_NOT_IN_GROUP', human: '该成员已不在群(可能已退群)——已自动从本地成员清单剔除' } });
903
+ }
904
+ } catch (e2) { /* 回读失败不阻断, 按成功返回 */ }
905
+ }
906
+ writeJson(res, r.ok ? 200 : 200, r.ok ? { ok: true, msg: action === 'mute' ? '已禁言 ' + Math.round(secs / 60) + ' 分钟' : '已解除禁言' } : { ok: false, err: r.err });
907
+ } catch (e) { writeJson(res, 500, { error: String((e && e.message) || e) }); }
908
+ });
909
+
910
+ // 绑定/登记群 {ns?, gid, name?}
911
+ route(ctx, 'POST', '/api/qqbot-settings/group/bind', async (req, res) => {
912
+ const body = await readJsonBody(req);
913
+ if (!body || typeof body !== 'object') return writeJson(res, 400, { error: 'bad body' });
914
+ const gid = String(body.gid || '').trim();
915
+ if (!gid) return writeJson(res, 400, { error: 'gid 必填' });
916
+ try {
917
+ const bot = nsBot(String(body.ns || ''));
918
+ if (!bot) return writeJson(res, 400, { error: '找不到该账号实例(请先在账号页配置 appId/appSecret)' });
919
+ const reg = readGroupsJson(bot.cwd);
920
+ reg[gid] = { name: String(body.name || '').trim(), lastAt: Date.now() };
921
+ writeGroupsJson(bot.cwd, reg);
922
+ audit(bot.cwd, { ev: 'group.bind', ns: bot.id, gid });
923
+ writeJson(res, 200, { ok: true });
924
+ } catch (e) { writeJson(res, 500, { error: String((e && e.message) || e) }); }
925
+ });
926
+
927
+ }