@zhin.js/console 1.0.50 → 1.0.52

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 (67) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +22 -0
  3. package/browser.tsconfig.json +19 -0
  4. package/client/src/components/PageHeader.tsx +26 -0
  5. package/client/src/components/ui/accordion.tsx +2 -1
  6. package/client/src/components/ui/badge.tsx +1 -3
  7. package/client/src/components/ui/scroll-area.tsx +5 -2
  8. package/client/src/components/ui/select.tsx +7 -3
  9. package/client/src/components/ui/separator.tsx +5 -2
  10. package/client/src/components/ui/tabs.tsx +4 -2
  11. package/client/src/layouts/dashboard.tsx +223 -121
  12. package/client/src/main.tsx +34 -34
  13. package/client/src/pages/bot-detail/MessageBody.tsx +110 -0
  14. package/client/src/pages/bot-detail/date-utils.ts +8 -0
  15. package/client/src/pages/bot-detail/index.tsx +798 -0
  16. package/client/src/pages/bot-detail/types.ts +92 -0
  17. package/client/src/pages/bot-detail/useBotConsole.tsx +600 -0
  18. package/client/src/pages/bots.tsx +111 -73
  19. package/client/src/pages/database/constants.ts +16 -0
  20. package/client/src/pages/database/database-page.tsx +170 -0
  21. package/client/src/pages/database/document-collection-view.tsx +155 -0
  22. package/client/src/pages/database/index.tsx +1 -0
  23. package/client/src/pages/database/json-field.tsx +11 -0
  24. package/client/src/pages/database/kv-bucket-view.tsx +169 -0
  25. package/client/src/pages/database/related-table-view.tsx +221 -0
  26. package/client/src/pages/env.tsx +38 -28
  27. package/client/src/pages/files/code-editor.tsx +85 -0
  28. package/client/src/pages/files/editor-constants.ts +9 -0
  29. package/client/src/pages/files/file-editor.tsx +133 -0
  30. package/client/src/pages/files/file-icons.tsx +25 -0
  31. package/client/src/pages/files/files-page.tsx +92 -0
  32. package/client/src/pages/files/hljs-global.d.ts +10 -0
  33. package/client/src/pages/files/index.tsx +1 -0
  34. package/client/src/pages/files/language.ts +18 -0
  35. package/client/src/pages/files/tree-node.tsx +69 -0
  36. package/client/src/pages/files/use-hljs-theme.ts +23 -0
  37. package/client/src/pages/logs.tsx +77 -22
  38. package/client/src/style.css +144 -0
  39. package/client/src/utils/parseComposerContent.ts +57 -0
  40. package/client/tailwind.config.js +1 -0
  41. package/client/tsconfig.json +3 -1
  42. package/dist/assets/index-COKXlFo2.js +124 -0
  43. package/dist/assets/style-kkLO-vsa.css +3 -0
  44. package/dist/client.js +4262 -1
  45. package/dist/index.html +2 -2
  46. package/dist/radix-ui.js +1261 -1262
  47. package/dist/react-dom-client.js +2243 -2240
  48. package/dist/react-dom.js +15 -15
  49. package/dist/style.css +1 -3
  50. package/lib/index.js +1010 -81
  51. package/lib/transform.js +16 -2
  52. package/lib/websocket.js +845 -28
  53. package/node.tsconfig.json +18 -0
  54. package/package.json +15 -16
  55. package/src/bin.ts +24 -0
  56. package/src/bot-db-models.ts +74 -0
  57. package/src/bot-hub.ts +240 -0
  58. package/src/bot-persistence.ts +270 -0
  59. package/src/build.ts +90 -0
  60. package/src/dev.ts +107 -0
  61. package/src/index.ts +337 -0
  62. package/src/transform.ts +199 -0
  63. package/src/websocket.ts +1369 -0
  64. package/client/src/pages/database.tsx +0 -708
  65. package/client/src/pages/files.tsx +0 -470
  66. package/client/src/pages/login-assist.tsx +0 -225
  67. package/dist/index.js +0 -124
@@ -0,0 +1,1369 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import WebSocket from "ws";
4
+ import { Plugin, usePlugin, Adapter } from "@zhin.js/core";
5
+ import type { SchemaFeature, ConfigFeature, DatabaseFeature } from "@zhin.js/core";
6
+ import type { WebServer } from "./index.js";
7
+ import {
8
+ initBotHub,
9
+ setBotHubWss,
10
+ sendCatchUpToClient,
11
+ getPendingRequest,
12
+ markRequestConsumedByPlatformId,
13
+ } from "./bot-hub.js";
14
+ import {
15
+ markRequestsConsumed,
16
+ markNoticesConsumed,
17
+ listRequestsForBot,
18
+ listUnconsumedRequests,
19
+ listUnconsumedNotices,
20
+ getRequestRowById,
21
+ } from "./bot-persistence.js";
22
+ import { removePendingRequest } from "./bot-hub.js";
23
+
24
+ const { root, logger } = usePlugin();
25
+
26
+ function collectBotsList(): Array<{
27
+ name: string;
28
+ adapter: string;
29
+ connected: boolean;
30
+ status: "online" | "offline";
31
+ pendingRequestCount?: number;
32
+ pendingNoticeCount?: number;
33
+ }> {
34
+ const bots: Array<{
35
+ name: string;
36
+ adapter: string;
37
+ connected: boolean;
38
+ status: "online" | "offline";
39
+ }> = [];
40
+ for (const name of root.adapters) {
41
+ const adapter = root.inject(name as keyof Plugin.Contexts);
42
+ if (adapter instanceof Adapter) {
43
+ for (const [botName, bot] of adapter.bots.entries()) {
44
+ bots.push({
45
+ name: botName,
46
+ adapter: String(name),
47
+ connected: !!(bot as { $connected?: boolean }).$connected,
48
+ status: (bot as { $connected?: boolean }).$connected ? "online" : "offline",
49
+ });
50
+ }
51
+ }
52
+ }
53
+ return bots;
54
+ }
55
+
56
+ async function collectBotsListWithPending(): Promise<
57
+ Array<{
58
+ name: string;
59
+ adapter: string;
60
+ connected: boolean;
61
+ status: "online" | "offline";
62
+ pendingRequestCount: number;
63
+ pendingNoticeCount: number;
64
+ }>
65
+ > {
66
+ const bots = collectBotsList();
67
+ let reqs: Awaited<ReturnType<typeof listUnconsumedRequests>> = [];
68
+ let notices: Awaited<ReturnType<typeof listUnconsumedNotices>> = [];
69
+ try {
70
+ [reqs, notices] = await Promise.all([listUnconsumedRequests(), listUnconsumedNotices()]);
71
+ } catch {
72
+ // ignore
73
+ }
74
+ return bots.map((bot) => {
75
+ const pendingRequestCount = reqs.filter(
76
+ (r) => r.adapter === bot.adapter && r.bot_id === bot.name
77
+ ).length;
78
+ const pendingNoticeCount = notices.filter(
79
+ (n) => n.adapter === bot.adapter && n.bot_id === bot.name
80
+ ).length;
81
+ return {
82
+ ...bot,
83
+ pendingRequestCount,
84
+ pendingNoticeCount,
85
+ };
86
+ });
87
+ }
88
+
89
+ const ENV_WHITELIST = [".env", ".env.development", ".env.production"];
90
+
91
+ // 允许在文件管理器中访问的路径模式(相对于 cwd)
92
+ const FILE_MANAGER_ALLOWED = [
93
+ "src",
94
+ "plugins",
95
+ "client",
96
+ "package.json",
97
+ "tsconfig.json",
98
+ "zhin.config.yml",
99
+ ".env",
100
+ ".env.development",
101
+ ".env.production",
102
+ "README.md",
103
+ ];
104
+
105
+ // 禁止访问的目录和文件
106
+ const FILE_MANAGER_BLOCKED = new Set([
107
+ "node_modules",
108
+ ".git",
109
+ ".env.local",
110
+ "data",
111
+ "lib",
112
+ "dist",
113
+ "coverage",
114
+ ]);
115
+
116
+ function isPathAllowed(relativePath: string): boolean {
117
+ // 阻止路径遍历
118
+ if (relativePath.includes("..") || path.isAbsolute(relativePath)) return false;
119
+ const normalized = relativePath.replace(/\\/g, "/").replace(/^\.\//, "");
120
+ const firstSegment = normalized.split("/")[0];
121
+ if (FILE_MANAGER_BLOCKED.has(firstSegment)) return false;
122
+ return FILE_MANAGER_ALLOWED.some((p) => normalized === p || normalized.startsWith(p + "/"));
123
+ }
124
+
125
+ function resolveConfigKey(pluginName: string): string {
126
+ const schemaService = root.inject('schema' as any) as SchemaFeature | null;
127
+ return schemaService?.resolveConfigKey(pluginName) ?? pluginName;
128
+ }
129
+
130
+ function getPluginKeys(): string[] {
131
+ const schemaService = root.inject('schema' as any) as SchemaFeature | null;
132
+ if (!schemaService) return [];
133
+ const keys = new Set<string>();
134
+ for (const [, configKey] of schemaService.getPluginKeyMap()) {
135
+ keys.add(configKey);
136
+ }
137
+ return Array.from(keys);
138
+ }
139
+
140
+ function getConfigFilePath(): string {
141
+ return path.resolve(process.cwd(), 'zhin.config.yml');
142
+ }
143
+
144
+ export function setupWebSocket(webServer: WebServer) {
145
+ setBotHubWss(webServer.ws);
146
+ initBotHub(root as { on: (ev: string, fn: (...a: unknown[]) => void) => void });
147
+
148
+ webServer.ws.on("connection", (ws: WebSocket) => {
149
+ ws.send(JSON.stringify({
150
+ type: "sync",
151
+ data: { key: "entries", value: Object.values(webServer.entries) },
152
+ }));
153
+ ws.send(JSON.stringify({ type: "init-data", timestamp: Date.now() }));
154
+ void sendCatchUpToClient(ws).catch((e) =>
155
+ logger.warn("[console] bot catch-up failed", (e as Error).message)
156
+ );
157
+
158
+ ws.on("message", async (data) => {
159
+ try {
160
+ const message = JSON.parse(data.toString());
161
+ await handleWebSocketMessage(ws, message, webServer);
162
+ } catch (error) {
163
+ console.error("WebSocket 消息处理错误:", error);
164
+ ws.send(JSON.stringify({ error: "Invalid message format" }));
165
+ }
166
+ });
167
+ ws.on("close", () => {});
168
+ ws.on("error", (error) => { console.error("WebSocket 错误:", error); });
169
+ });
170
+ }
171
+
172
+ async function handleWebSocketMessage(
173
+ ws: WebSocket,
174
+ message: any,
175
+ webServer: WebServer
176
+ ) {
177
+ const { type, requestId, pluginName } = message;
178
+
179
+ switch (type) {
180
+ case "ping":
181
+ ws.send(JSON.stringify({ type: "pong", requestId }));
182
+ break;
183
+
184
+ case "entries:get":
185
+ ws.send(JSON.stringify({ requestId, data: Object.values(webServer.entries) }));
186
+ break;
187
+
188
+ // ================================================================
189
+ // 配置文件原始 YAML 读写(用于配置管理页面)
190
+ // ================================================================
191
+
192
+ case "config:get-yaml":
193
+ try {
194
+ const filePath = getConfigFilePath();
195
+ const yaml = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf-8') : '';
196
+ ws.send(JSON.stringify({ requestId, data: { yaml, pluginKeys: getPluginKeys() } }));
197
+ } catch (error) {
198
+ ws.send(JSON.stringify({ requestId, error: `Failed to read config: ${(error as Error).message}` }));
199
+ }
200
+ break;
201
+
202
+ case "config:save-yaml":
203
+ try {
204
+ const { yaml } = message;
205
+ if (typeof yaml !== 'string') {
206
+ ws.send(JSON.stringify({ requestId, error: 'yaml field is required' }));
207
+ break;
208
+ }
209
+ const filePath = getConfigFilePath();
210
+ fs.writeFileSync(filePath, yaml, 'utf-8');
211
+ const configService = root.inject('config') as ConfigFeature;
212
+ const loader = configService.configs.get('zhin.config.yml');
213
+ if (loader) loader.load();
214
+ ws.send(JSON.stringify({ requestId, data: { success: true, message: '配置已保存,需重启生效' } }));
215
+ } catch (error) {
216
+ ws.send(JSON.stringify({ requestId, error: `Failed to save config: ${(error as Error).message}` }));
217
+ }
218
+ break;
219
+
220
+ // ================================================================
221
+ // 插件配置(供 PluginConfigForm 使用)
222
+ // ================================================================
223
+
224
+ case "config:get":
225
+ try {
226
+ const configService = root.inject('config') as ConfigFeature;
227
+ const rawConfig = configService.getRaw<Record<string, any>>('zhin.config.yml');
228
+ if (!pluginName) {
229
+ ws.send(JSON.stringify({ requestId, data: rawConfig }));
230
+ } else {
231
+ const configKey = resolveConfigKey(pluginName);
232
+ ws.send(JSON.stringify({ requestId, data: rawConfig[configKey] || {} }));
233
+ }
234
+ } catch (error) {
235
+ ws.send(JSON.stringify({ requestId, error: `Failed to get config: ${(error as Error).message}` }));
236
+ }
237
+ break;
238
+
239
+ case "config:get-all":
240
+ try {
241
+ const configService = root.inject('config') as ConfigFeature;
242
+ const rawConfig = configService.getRaw<Record<string, any>>('zhin.config.yml');
243
+ const allConfigs: Record<string, any> = { ...rawConfig };
244
+ const schemaService = root.inject('schema' as any) as SchemaFeature | null;
245
+ if (schemaService) {
246
+ for (const [pName, configKey] of schemaService.getPluginKeyMap()) {
247
+ if (pName !== configKey) {
248
+ allConfigs[pName] = rawConfig[configKey] || {};
249
+ }
250
+ }
251
+ }
252
+ ws.send(JSON.stringify({ requestId, data: allConfigs }));
253
+ } catch (error) {
254
+ ws.send(JSON.stringify({ requestId, error: `Failed to get all configs: ${(error as Error).message}` }));
255
+ }
256
+ break;
257
+
258
+ case "config:set":
259
+ try {
260
+ const { data } = message;
261
+ if (!pluginName) {
262
+ ws.send(JSON.stringify({ requestId, error: 'Plugin name is required' }));
263
+ break;
264
+ }
265
+ const configKey = resolveConfigKey(pluginName);
266
+ const configService = root.inject('config') as ConfigFeature;
267
+ const loader = configService.configs.get('zhin.config.yml');
268
+ if (!loader) {
269
+ ws.send(JSON.stringify({ requestId, error: 'Config file not loaded' }));
270
+ break;
271
+ }
272
+ loader.patchKey(configKey, data);
273
+
274
+ broadcastToAll(webServer, { type: 'config:updated', data: { pluginName, config: data } });
275
+
276
+ const schemaService = root.inject('schema' as any) as SchemaFeature | null;
277
+ const reloadable = schemaService?.isReloadable?.(pluginName) ?? false;
278
+
279
+ if (reloadable) {
280
+ const target = findPluginByConfigKey(root, pluginName);
281
+ if (target) {
282
+ try {
283
+ await target.reload();
284
+ ws.send(JSON.stringify({ requestId, data: { success: true, reloaded: true } }));
285
+ } catch (reloadErr) {
286
+ logger.warn(`重载插件 ${pluginName} 失败: ${(reloadErr as Error).message}`);
287
+ ws.send(JSON.stringify({ requestId, data: { success: true, reloaded: false, message: '配置已保存,但重载失败' } }));
288
+ }
289
+ } else {
290
+ ws.send(JSON.stringify({ requestId, data: { success: true, reloaded: false, message: '配置已保存' } }));
291
+ }
292
+ } else {
293
+ ws.send(JSON.stringify({ requestId, data: { success: true, reloaded: false, message: '配置已保存,需重启进程才能生效' } }));
294
+ }
295
+ } catch (error) {
296
+ ws.send(JSON.stringify({ requestId, error: `Failed to set config: ${(error as Error).message}` }));
297
+ }
298
+ break;
299
+
300
+ // ================================================================
301
+ // Schema(供 PluginConfigForm 使用)
302
+ // ================================================================
303
+
304
+ case "schema:get":
305
+ try {
306
+ const schemaService = root.inject('schema' as any) as SchemaFeature | null;
307
+ const schema = pluginName && schemaService ? schemaService.get(pluginName) : null;
308
+ ws.send(JSON.stringify({ requestId, data: schema ? schema.toJSON() : null }));
309
+ } catch (error) {
310
+ ws.send(JSON.stringify({ requestId, error: `Failed to get schema: ${(error as Error).message}` }));
311
+ }
312
+ break;
313
+
314
+ case "schema:get-all":
315
+ try {
316
+ const schemaService = root.inject('schema' as any) as SchemaFeature | null;
317
+ const schemas: Record<string, any> = {};
318
+ if (schemaService) {
319
+ for (const record of schemaService.items) {
320
+ if (record.key && record.schema) {
321
+ schemas[record.key] = record.schema.toJSON();
322
+ }
323
+ }
324
+ for (const [pName, configKey] of schemaService.getPluginKeyMap()) {
325
+ if (pName !== configKey && schemas[configKey]) {
326
+ schemas[pName] = schemas[configKey];
327
+ }
328
+ }
329
+ }
330
+ ws.send(JSON.stringify({ requestId, data: schemas }));
331
+ } catch (error) {
332
+ ws.send(JSON.stringify({ requestId, error: `Failed to get all schemas: ${(error as Error).message}` }));
333
+ }
334
+ break;
335
+
336
+ // ================================================================
337
+ // 环境变量文件管理
338
+ // ================================================================
339
+
340
+ case "env:list":
341
+ try {
342
+ const cwd = process.cwd();
343
+ const files = ENV_WHITELIST.map(name => ({
344
+ name,
345
+ exists: fs.existsSync(path.resolve(cwd, name)),
346
+ }));
347
+ ws.send(JSON.stringify({ requestId, data: { files } }));
348
+ } catch (error) {
349
+ ws.send(JSON.stringify({ requestId, error: `Failed to list env files: ${(error as Error).message}` }));
350
+ }
351
+ break;
352
+
353
+ case "env:get":
354
+ try {
355
+ const { filename } = message;
356
+ if (!filename || !ENV_WHITELIST.includes(filename)) {
357
+ ws.send(JSON.stringify({ requestId, error: `Invalid env file: ${filename}` }));
358
+ break;
359
+ }
360
+ const envPath = path.resolve(process.cwd(), filename);
361
+ const content = fs.existsSync(envPath) ? fs.readFileSync(envPath, 'utf-8') : '';
362
+ ws.send(JSON.stringify({ requestId, data: { content } }));
363
+ } catch (error) {
364
+ ws.send(JSON.stringify({ requestId, error: `Failed to read env file: ${(error as Error).message}` }));
365
+ }
366
+ break;
367
+
368
+ case "env:save":
369
+ try {
370
+ const { filename, content } = message;
371
+ if (!filename || !ENV_WHITELIST.includes(filename)) {
372
+ ws.send(JSON.stringify({ requestId, error: `Invalid env file: ${filename}` }));
373
+ break;
374
+ }
375
+ if (typeof content !== 'string') {
376
+ ws.send(JSON.stringify({ requestId, error: 'content field is required' }));
377
+ break;
378
+ }
379
+ const envPath = path.resolve(process.cwd(), filename);
380
+ fs.writeFileSync(envPath, content, 'utf-8');
381
+ ws.send(JSON.stringify({ requestId, data: { success: true, message: '环境变量已保存,需重启生效' } }));
382
+ } catch (error) {
383
+ ws.send(JSON.stringify({ requestId, error: `Failed to save env file: ${(error as Error).message}` }));
384
+ }
385
+ break;
386
+
387
+ // ================================================================
388
+ // 文件管理
389
+ // ================================================================
390
+
391
+ case "files:tree":
392
+ try {
393
+ const cwd = process.cwd();
394
+ const tree = buildFileTree(cwd, "", FILE_MANAGER_ALLOWED);
395
+ ws.send(JSON.stringify({ requestId, data: { tree } }));
396
+ } catch (error) {
397
+ ws.send(JSON.stringify({ requestId, error: `Failed to build file tree: ${(error as Error).message}` }));
398
+ }
399
+ break;
400
+
401
+ case "files:read":
402
+ try {
403
+ const { filePath: fp } = message;
404
+ if (!fp || !isPathAllowed(fp)) {
405
+ ws.send(JSON.stringify({ requestId, error: `Access denied: ${fp}` }));
406
+ break;
407
+ }
408
+ const absPath = path.resolve(process.cwd(), fp);
409
+ if (!fs.existsSync(absPath)) {
410
+ ws.send(JSON.stringify({ requestId, error: `File not found: ${fp}` }));
411
+ break;
412
+ }
413
+ const stat = fs.statSync(absPath);
414
+ if (!stat.isFile()) {
415
+ ws.send(JSON.stringify({ requestId, error: `Not a file: ${fp}` }));
416
+ break;
417
+ }
418
+ // 限制文件大小(1MB)
419
+ if (stat.size > 1024 * 1024) {
420
+ ws.send(JSON.stringify({ requestId, error: `File too large: ${(stat.size / 1024).toFixed(0)}KB (max 1MB)` }));
421
+ break;
422
+ }
423
+ const fileContent = fs.readFileSync(absPath, 'utf-8');
424
+ ws.send(JSON.stringify({ requestId, data: { content: fileContent, size: stat.size } }));
425
+ } catch (error) {
426
+ ws.send(JSON.stringify({ requestId, error: `Failed to read file: ${(error as Error).message}` }));
427
+ }
428
+ break;
429
+
430
+ case "files:save":
431
+ try {
432
+ const { filePath: fp, content: fileContent } = message;
433
+ if (!fp || !isPathAllowed(fp)) {
434
+ ws.send(JSON.stringify({ requestId, error: `Access denied: ${fp}` }));
435
+ break;
436
+ }
437
+ if (typeof fileContent !== 'string') {
438
+ ws.send(JSON.stringify({ requestId, error: 'content field is required' }));
439
+ break;
440
+ }
441
+ const absPath = path.resolve(process.cwd(), fp);
442
+ const dir = path.dirname(absPath);
443
+ if (!fs.existsSync(dir)) {
444
+ fs.mkdirSync(dir, { recursive: true });
445
+ }
446
+ fs.writeFileSync(absPath, fileContent, 'utf-8');
447
+ ws.send(JSON.stringify({ requestId, data: { success: true, message: `文件已保存: ${fp}` } }));
448
+ } catch (error) {
449
+ ws.send(JSON.stringify({ requestId, error: `Failed to save file: ${(error as Error).message}` }));
450
+ }
451
+ break;
452
+
453
+ // ================================================================
454
+ // 数据库管理
455
+ // ================================================================
456
+
457
+ case "db:info":
458
+ try {
459
+ const dbInfo = getDatabaseInfo();
460
+ ws.send(JSON.stringify({ requestId, data: dbInfo }));
461
+ } catch (error) {
462
+ ws.send(JSON.stringify({ requestId, error: `Failed to get db info: ${(error as Error).message}` }));
463
+ }
464
+ break;
465
+
466
+ case "db:tables":
467
+ try {
468
+ const tables = getDatabaseTables();
469
+ ws.send(JSON.stringify({ requestId, data: { tables } }));
470
+ } catch (error) {
471
+ ws.send(JSON.stringify({ requestId, error: `Failed to list tables: ${(error as Error).message}` }));
472
+ }
473
+ break;
474
+
475
+ case "db:select":
476
+ try {
477
+ const { table, page = 1, pageSize = 50, where } = message;
478
+ if (!table) { ws.send(JSON.stringify({ requestId, error: 'table is required' })); break; }
479
+ const selectResult = await dbSelect(table, page, pageSize, where);
480
+ ws.send(JSON.stringify({ requestId, data: selectResult }));
481
+ } catch (error) {
482
+ ws.send(JSON.stringify({ requestId, error: `Failed to select: ${(error as Error).message}` }));
483
+ }
484
+ break;
485
+
486
+ case "db:insert":
487
+ try {
488
+ const { table, row } = message;
489
+ if (!table || !row) { ws.send(JSON.stringify({ requestId, error: 'table and row are required' })); break; }
490
+ await dbInsert(table, row);
491
+ ws.send(JSON.stringify({ requestId, data: { success: true } }));
492
+ } catch (error) {
493
+ ws.send(JSON.stringify({ requestId, error: `Failed to insert: ${(error as Error).message}` }));
494
+ }
495
+ break;
496
+
497
+ case "db:update":
498
+ try {
499
+ const { table, row, where: updateWhere } = message;
500
+ if (!table || !row || !updateWhere) { ws.send(JSON.stringify({ requestId, error: 'table, row, and where are required' })); break; }
501
+ const affected = await dbUpdate(table, row, updateWhere);
502
+ ws.send(JSON.stringify({ requestId, data: { success: true, affected } }));
503
+ } catch (error) {
504
+ ws.send(JSON.stringify({ requestId, error: `Failed to update: ${(error as Error).message}` }));
505
+ }
506
+ break;
507
+
508
+ case "db:delete":
509
+ try {
510
+ const { table, where: deleteWhere } = message;
511
+ if (!table || !deleteWhere) { ws.send(JSON.stringify({ requestId, error: 'table and where are required' })); break; }
512
+ const deleted = await dbDelete(table, deleteWhere);
513
+ ws.send(JSON.stringify({ requestId, data: { success: true, deleted } }));
514
+ } catch (error) {
515
+ ws.send(JSON.stringify({ requestId, error: `Failed to delete: ${(error as Error).message}` }));
516
+ }
517
+ break;
518
+
519
+ case "db:drop-table":
520
+ try {
521
+ const { table: dropTableName } = message;
522
+ if (!dropTableName) { ws.send(JSON.stringify({ requestId, error: 'table is required' })); break; }
523
+ await dbDropTable(dropTableName);
524
+ ws.send(JSON.stringify({ requestId, data: { success: true } }));
525
+ } catch (error) {
526
+ ws.send(JSON.stringify({ requestId, error: `Failed to drop table: ${(error as Error).message}` }));
527
+ }
528
+ break;
529
+
530
+ // KV 专用操作
531
+ case "db:kv:get":
532
+ try {
533
+ const { table, key } = message;
534
+ if (!table || !key) { ws.send(JSON.stringify({ requestId, error: 'table and key are required' })); break; }
535
+ const kvValue = await kvGet(table, key);
536
+ ws.send(JSON.stringify({ requestId, data: { key, value: kvValue } }));
537
+ } catch (error) {
538
+ ws.send(JSON.stringify({ requestId, error: `Failed to get kv: ${(error as Error).message}` }));
539
+ }
540
+ break;
541
+
542
+ case "db:kv:set":
543
+ try {
544
+ const { table, key, value, ttl } = message;
545
+ if (!table || !key) { ws.send(JSON.stringify({ requestId, error: 'table and key are required' })); break; }
546
+ await kvSet(table, key, value, ttl);
547
+ ws.send(JSON.stringify({ requestId, data: { success: true } }));
548
+ } catch (error) {
549
+ ws.send(JSON.stringify({ requestId, error: `Failed to set kv: ${(error as Error).message}` }));
550
+ }
551
+ break;
552
+
553
+ case "db:kv:delete":
554
+ try {
555
+ const { table, key } = message;
556
+ if (!table || !key) { ws.send(JSON.stringify({ requestId, error: 'table and key are required' })); break; }
557
+ await kvDelete(table, key);
558
+ ws.send(JSON.stringify({ requestId, data: { success: true } }));
559
+ } catch (error) {
560
+ ws.send(JSON.stringify({ requestId, error: `Failed to delete kv: ${(error as Error).message}` }));
561
+ }
562
+ break;
563
+
564
+ case "db:kv:entries":
565
+ try {
566
+ const { table } = message;
567
+ if (!table) { ws.send(JSON.stringify({ requestId, error: 'table is required' })); break; }
568
+ const kvEntries = await kvGetEntries(table);
569
+ ws.send(JSON.stringify({ requestId, data: { entries: kvEntries } }));
570
+ } catch (error) {
571
+ ws.send(JSON.stringify({ requestId, error: `Failed to get entries: ${(error as Error).message}` }));
572
+ }
573
+ break;
574
+
575
+ // ================================================================
576
+ // 机器人管理(WebSocket)
577
+ // ================================================================
578
+
579
+ case "bot:list": {
580
+ try {
581
+ const botsWithPending = await collectBotsListWithPending();
582
+ ws.send(JSON.stringify({ requestId, data: { bots: botsWithPending } }));
583
+ } catch (error) {
584
+ ws.send(JSON.stringify({ requestId, error: (error as Error).message }));
585
+ }
586
+ break;
587
+ }
588
+
589
+ case "bot:info": {
590
+ try {
591
+ const d = message.data || {};
592
+ const { adapter, botId } = d;
593
+ if (!adapter || !botId) {
594
+ ws.send(JSON.stringify({ requestId, error: "adapter and botId required" }));
595
+ break;
596
+ }
597
+ const ad = root.inject(adapter as keyof Plugin.Contexts);
598
+ if (!(ad instanceof Adapter)) {
599
+ ws.send(JSON.stringify({ requestId, error: "adapter not found" }));
600
+ break;
601
+ }
602
+ const bot = ad.bots.get(botId);
603
+ if (!bot) {
604
+ ws.send(JSON.stringify({ requestId, error: "bot not found" }));
605
+ break;
606
+ }
607
+ ws.send(
608
+ JSON.stringify({
609
+ requestId,
610
+ data: {
611
+ name: botId,
612
+ adapter: String(adapter),
613
+ connected: !!bot.$connected,
614
+ status: bot.$connected ? "online" : "offline",
615
+ },
616
+ })
617
+ );
618
+ } catch (error) {
619
+ ws.send(JSON.stringify({ requestId, error: (error as Error).message }));
620
+ }
621
+ break;
622
+ }
623
+
624
+ case "bot:sendMessage": {
625
+ try {
626
+ const d = message.data || {};
627
+ const { adapter, botId, id, type: msgType, content } = d;
628
+ if (!adapter || !botId || !id || !msgType || content === undefined) {
629
+ ws.send(
630
+ JSON.stringify({
631
+ requestId,
632
+ error: "adapter, botId, id, type, content required",
633
+ })
634
+ );
635
+ break;
636
+ }
637
+ const ad = root.inject(adapter as keyof Plugin.Contexts);
638
+ if (!(ad instanceof Adapter)) {
639
+ ws.send(JSON.stringify({ requestId, error: "adapter not found" }));
640
+ break;
641
+ }
642
+ const normalized =
643
+ typeof content === "string"
644
+ ? content
645
+ : Array.isArray(content)
646
+ ? content
647
+ : String(content);
648
+ const messageId = await ad.sendMessage({
649
+ context: adapter,
650
+ bot: botId,
651
+ id: String(id),
652
+ type: msgType as "private" | "group" | "channel",
653
+ content: normalized,
654
+ });
655
+ ws.send(JSON.stringify({ requestId, data: { messageId } }));
656
+ } catch (error) {
657
+ ws.send(JSON.stringify({ requestId, error: (error as Error).message }));
658
+ }
659
+ break;
660
+ }
661
+
662
+ case "bot:friends":
663
+ case "bot:groups": {
664
+ try {
665
+ const d = message.data || {};
666
+ const { adapter, botId } = d;
667
+ if (!adapter || !botId) {
668
+ ws.send(JSON.stringify({ requestId, error: "adapter and botId required" }));
669
+ break;
670
+ }
671
+ if (adapter !== "icqq") {
672
+ ws.send(JSON.stringify({ requestId, error: "not supported for this adapter" }));
673
+ break;
674
+ }
675
+ const ad = root.inject("icqq" as keyof Plugin.Contexts) as any;
676
+ const bot = ad?.bots?.get?.(botId);
677
+ if (!bot) {
678
+ ws.send(JSON.stringify({ requestId, error: "bot not found" }));
679
+ break;
680
+ }
681
+ if (type === "bot:friends") {
682
+ const fl = bot.fl;
683
+ const friends = Array.from((fl || new Map()).values()).map((f: any) => ({
684
+ user_id: f.user_id,
685
+ nickname: f.nickname,
686
+ remark: f.remark,
687
+ }));
688
+ ws.send(JSON.stringify({ requestId, data: { friends, count: friends.length } }));
689
+ } else {
690
+ const gl = bot.gl;
691
+ const groups = Array.from((gl || new Map()).values()).map((g: any) => ({
692
+ group_id: g.group_id,
693
+ name: g.name,
694
+ }));
695
+ ws.send(JSON.stringify({ requestId, data: { groups, count: groups.length } }));
696
+ }
697
+ } catch (error) {
698
+ ws.send(JSON.stringify({ requestId, error: (error as Error).message }));
699
+ }
700
+ break;
701
+ }
702
+
703
+ case "bot:channels": {
704
+ try {
705
+ const d = message.data || {};
706
+ const { adapter, botId } = d;
707
+ if (!adapter || !botId) {
708
+ ws.send(JSON.stringify({ requestId, error: "adapter and botId required" }));
709
+ break;
710
+ }
711
+ if (adapter === "icqq") {
712
+ ws.send(JSON.stringify({ requestId, error: "channels not supported for icqq" }));
713
+ break;
714
+ }
715
+ const ad = root.inject(adapter as keyof Plugin.Contexts) as any;
716
+ const bot = ad?.bots?.get?.(botId);
717
+ if (!bot) {
718
+ ws.send(JSON.stringify({ requestId, error: "bot not found" }));
719
+ break;
720
+ }
721
+ const channels: Array<{ id: string; name: string }> = [];
722
+ if (adapter === "qq" && typeof bot.getGuilds === "function" && typeof bot.getChannels === "function") {
723
+ const guilds = (await bot.getGuilds()) || [];
724
+ for (const g of guilds) {
725
+ const gid = g?.id ?? g?.guild_id ?? String(g);
726
+ const chs = (await bot.getChannels(gid)) || [];
727
+ for (const c of chs) {
728
+ channels.push({
729
+ id: String(c?.id ?? c?.channel_id ?? c),
730
+ name: String(c?.name ?? c?.channel_name ?? c?.id ?? ""),
731
+ });
732
+ }
733
+ }
734
+ } else if (typeof (ad as any)?.listChannels === "function") {
735
+ const result = await (ad as any).listChannels(botId);
736
+ if (Array.isArray(result)) channels.push(...result.map((c: any) => ({ id: String(c?.id ?? c), name: String(c?.name ?? c?.id ?? "") })));
737
+ else if (result?.channels) channels.push(...result.channels.map((c: any) => ({ id: String(c?.id ?? c), name: String(c?.name ?? c?.id ?? "") })));
738
+ }
739
+ ws.send(JSON.stringify({ requestId, data: { channels, count: channels.length } }));
740
+ } catch (error) {
741
+ ws.send(JSON.stringify({ requestId, error: (error as Error).message }));
742
+ }
743
+ break;
744
+ }
745
+
746
+ case "bot:deleteFriend": {
747
+ try {
748
+ const d = message.data || {};
749
+ const { adapter, botId, userId } = d;
750
+ if (!adapter || !botId || !userId) {
751
+ ws.send(JSON.stringify({ requestId, error: "adapter, botId, userId required" }));
752
+ break;
753
+ }
754
+ const ad = root.inject(adapter as keyof Plugin.Contexts) as any;
755
+ const bot = ad?.bots?.get?.(botId);
756
+ if (!bot) {
757
+ ws.send(JSON.stringify({ requestId, error: "bot not found" }));
758
+ break;
759
+ }
760
+ if (adapter === "icqq" && typeof bot.deleteFriend === "function") {
761
+ await bot.deleteFriend(Number(userId));
762
+ ws.send(JSON.stringify({ requestId, data: { success: true } }));
763
+ } else if (adapter === "icqq" && typeof bot.delete_friend === "function") {
764
+ await bot.delete_friend(Number(userId));
765
+ ws.send(JSON.stringify({ requestId, data: { success: true } }));
766
+ } else {
767
+ ws.send(JSON.stringify({ requestId, error: "当前适配器暂不支持删除好友" }));
768
+ }
769
+ } catch (error) {
770
+ ws.send(JSON.stringify({ requestId, error: (error as Error).message }));
771
+ }
772
+ break;
773
+ }
774
+
775
+ case "bot:requests": {
776
+ try {
777
+ const d = message.data || {};
778
+ const { adapter, botId } = d;
779
+ if (!adapter || !botId) {
780
+ ws.send(JSON.stringify({ requestId, error: "adapter and botId required" }));
781
+ break;
782
+ }
783
+ const rows = await listRequestsForBot(String(adapter), String(botId));
784
+ ws.send(
785
+ JSON.stringify({
786
+ requestId,
787
+ data: {
788
+ requests: rows.map((r) => ({
789
+ id: r.id,
790
+ platformRequestId: r.platform_request_id,
791
+ type: r.type,
792
+ sender: { id: r.sender_id, name: r.sender_name },
793
+ comment: r.comment,
794
+ channel: { id: r.channel_id, type: r.channel_type },
795
+ timestamp: r.created_at,
796
+ })),
797
+ },
798
+ })
799
+ );
800
+ } catch (error) {
801
+ ws.send(JSON.stringify({ requestId, error: (error as Error).message }));
802
+ }
803
+ break;
804
+ }
805
+
806
+ case "bot:requestApprove":
807
+ case "bot:requestReject": {
808
+ try {
809
+ const d = message.data || {};
810
+ const { adapter, botId, requestId: platformReqId, remark, reason } = d;
811
+ if (!adapter || !botId || !platformReqId) {
812
+ ws.send(
813
+ JSON.stringify({
814
+ requestId,
815
+ error: "adapter, botId, requestId required",
816
+ })
817
+ );
818
+ break;
819
+ }
820
+ const req = getPendingRequest(String(adapter), String(botId), String(platformReqId));
821
+ if (!req) {
822
+ ws.send(
823
+ JSON.stringify({
824
+ requestId,
825
+ error:
826
+ "request not in memory (restart?) — use bot:requestConsumed to dismiss",
827
+ })
828
+ );
829
+ break;
830
+ }
831
+ if (type === "bot:requestApprove") await req.$approve(remark);
832
+ else await req.$reject(reason);
833
+ await markRequestConsumedByPlatformId(String(adapter), String(botId), String(platformReqId));
834
+ ws.send(JSON.stringify({ requestId, data: { success: true } }));
835
+ } catch (error) {
836
+ ws.send(JSON.stringify({ requestId, error: (error as Error).message }));
837
+ }
838
+ break;
839
+ }
840
+
841
+ case "bot:requestConsumed": {
842
+ try {
843
+ const d = message.data || {};
844
+ const ids = d.ids ?? (d.id != null ? [d.id] : []);
845
+ if (!Array.isArray(ids) || !ids.length) {
846
+ ws.send(JSON.stringify({ requestId, error: "id or ids required" }));
847
+ break;
848
+ }
849
+ const numIds = ids.map(Number);
850
+ for (const id of numIds) {
851
+ const row = await getRequestRowById(id);
852
+ if (row && row.consumed === 0) {
853
+ removePendingRequest(row.adapter, row.bot_id, row.platform_request_id);
854
+ }
855
+ }
856
+ await markRequestsConsumed(numIds);
857
+ ws.send(JSON.stringify({ requestId, data: { success: true } }));
858
+ } catch (error) {
859
+ ws.send(JSON.stringify({ requestId, error: (error as Error).message }));
860
+ }
861
+ break;
862
+ }
863
+
864
+ case "bot:noticeConsumed": {
865
+ try {
866
+ const d = message.data || {};
867
+ const ids = d.ids ?? (d.id != null ? [d.id] : []);
868
+ if (!Array.isArray(ids) || !ids.length) {
869
+ ws.send(JSON.stringify({ requestId, error: "id or ids required" }));
870
+ break;
871
+ }
872
+ await markNoticesConsumed(ids.map(Number));
873
+ ws.send(JSON.stringify({ requestId, data: { success: true } }));
874
+ } catch (error) {
875
+ ws.send(JSON.stringify({ requestId, error: (error as Error).message }));
876
+ }
877
+ break;
878
+ }
879
+
880
+ case "bot:inboxMessages": {
881
+ try {
882
+ const d = message.data || {};
883
+ const { adapter, botId, channelId, channelType, limit = 50, beforeId, beforeTs } = d;
884
+ if (!adapter || !botId || !channelId || !channelType) {
885
+ ws.send(JSON.stringify({ requestId, error: "adapter, botId, channelId, channelType required" }));
886
+ break;
887
+ }
888
+ let db: DatabaseFeature;
889
+ try {
890
+ db = root.inject("database") as DatabaseFeature;
891
+ } catch {
892
+ ws.send(JSON.stringify({ requestId, data: { messages: [], inboxEnabled: false } }));
893
+ break;
894
+ }
895
+ const MessageModel = db?.models?.get("unified_inbox_message") as any;
896
+ if (!MessageModel) {
897
+ ws.send(JSON.stringify({ requestId, data: { messages: [], inboxEnabled: false } }));
898
+ break;
899
+ }
900
+ const where: Record<string, unknown> = {
901
+ adapter: String(adapter),
902
+ bot_id: String(botId),
903
+ channel_id: String(channelId),
904
+ channel_type: String(channelType),
905
+ };
906
+ if (beforeTs != null) where.created_at = { $lt: Number(beforeTs) };
907
+ if (beforeId != null) where.id = { $lt: Number(beforeId) };
908
+ let q = MessageModel.select().where(where).orderBy("created_at", "DESC").limit(Math.min(Number(limit) || 50, 100));
909
+ const rows = await (typeof q.then === "function" ? q : Promise.resolve(q));
910
+ const messages = (rows || []).map((r: any) => ({
911
+ id: r.id,
912
+ platform_message_id: r.platform_message_id,
913
+ sender_id: r.sender_id,
914
+ sender_name: r.sender_name,
915
+ content: r.content,
916
+ raw: r.raw,
917
+ created_at: r.created_at,
918
+ }));
919
+ ws.send(JSON.stringify({ requestId, data: { messages, inboxEnabled: true } }));
920
+ } catch (error) {
921
+ ws.send(JSON.stringify({ requestId, error: (error as Error).message }));
922
+ }
923
+ break;
924
+ }
925
+
926
+ case "bot:inboxRequests": {
927
+ try {
928
+ const d = message.data || {};
929
+ const { adapter, botId, limit = 30, offset = 0 } = d;
930
+ if (!adapter || !botId) {
931
+ ws.send(JSON.stringify({ requestId, error: "adapter and botId required" }));
932
+ break;
933
+ }
934
+ let db: DatabaseFeature;
935
+ try {
936
+ db = root.inject("database") as DatabaseFeature;
937
+ } catch {
938
+ ws.send(JSON.stringify({ requestId, data: { requests: [], inboxEnabled: false } }));
939
+ break;
940
+ }
941
+ const RequestModel = db?.models?.get("unified_inbox_request") as any;
942
+ if (!RequestModel) {
943
+ ws.send(JSON.stringify({ requestId, data: { requests: [], inboxEnabled: false } }));
944
+ break;
945
+ }
946
+ const where = { adapter: String(adapter), bot_id: String(botId) };
947
+ const limitNum = Math.min(Number(limit) || 30, 100);
948
+ const offsetNum = Math.max(0, Number(offset) || 0);
949
+ let q = RequestModel.select().where(where).orderBy("created_at", "DESC").limit(limitNum).offset(offsetNum);
950
+ const rows = await (typeof q.then === "function" ? q : Promise.resolve(q));
951
+ const requests = (rows || []).map((r: any) => ({
952
+ id: r.id,
953
+ platform_request_id: r.platform_request_id,
954
+ type: r.type,
955
+ sub_type: r.sub_type,
956
+ channel_id: r.channel_id,
957
+ channel_type: r.channel_type,
958
+ sender_id: r.sender_id,
959
+ sender_name: r.sender_name,
960
+ comment: r.comment,
961
+ created_at: r.created_at,
962
+ resolved: r.resolved,
963
+ resolved_at: r.resolved_at,
964
+ }));
965
+ ws.send(JSON.stringify({ requestId, data: { requests, inboxEnabled: true } }));
966
+ } catch (error) {
967
+ ws.send(JSON.stringify({ requestId, error: (error as Error).message }));
968
+ }
969
+ break;
970
+ }
971
+
972
+ case "bot:inboxNotices": {
973
+ try {
974
+ const d = message.data || {};
975
+ const { adapter, botId, limit = 30, offset = 0 } = d;
976
+ if (!adapter || !botId) {
977
+ ws.send(JSON.stringify({ requestId, error: "adapter and botId required" }));
978
+ break;
979
+ }
980
+ let db: DatabaseFeature;
981
+ try {
982
+ db = root.inject("database") as DatabaseFeature;
983
+ } catch {
984
+ ws.send(JSON.stringify({ requestId, data: { notices: [], inboxEnabled: false } }));
985
+ break;
986
+ }
987
+ const NoticeModel = db?.models?.get("unified_inbox_notice") as any;
988
+ if (!NoticeModel) {
989
+ ws.send(JSON.stringify({ requestId, data: { notices: [], inboxEnabled: false } }));
990
+ break;
991
+ }
992
+ const where = { adapter: String(adapter), bot_id: String(botId) };
993
+ const limitNum = Math.min(Number(limit) || 30, 100);
994
+ const offsetNum = Math.max(0, Number(offset) || 0);
995
+ let q = NoticeModel.select().where(where).orderBy("created_at", "DESC").limit(limitNum).offset(offsetNum);
996
+ const rows = await (typeof q.then === "function" ? q : Promise.resolve(q));
997
+ const notices = (rows || []).map((r: any) => ({
998
+ id: r.id,
999
+ platform_notice_id: r.platform_notice_id,
1000
+ type: r.type,
1001
+ sub_type: r.sub_type,
1002
+ channel_id: r.channel_id,
1003
+ channel_type: r.channel_type,
1004
+ operator_id: r.operator_id,
1005
+ operator_name: r.operator_name,
1006
+ target_id: r.target_id,
1007
+ target_name: r.target_name,
1008
+ payload: r.payload,
1009
+ created_at: r.created_at,
1010
+ }));
1011
+ ws.send(JSON.stringify({ requestId, data: { notices, inboxEnabled: true } }));
1012
+ } catch (error) {
1013
+ ws.send(JSON.stringify({ requestId, error: (error as Error).message }));
1014
+ }
1015
+ break;
1016
+ }
1017
+
1018
+ case "bot:groupMembers":
1019
+ case "bot:groupKick":
1020
+ case "bot:groupMute":
1021
+ case "bot:groupAdmin": {
1022
+ try {
1023
+ const d = message.data || {};
1024
+ const { adapter, botId, groupId, userId, duration, enable } = d;
1025
+ if (!adapter || !botId || !groupId) {
1026
+ ws.send(
1027
+ JSON.stringify({ requestId, error: "adapter, botId, groupId required" })
1028
+ );
1029
+ break;
1030
+ }
1031
+ const ad = root.inject(adapter as keyof Plugin.Contexts) as any;
1032
+ if (!ad) {
1033
+ ws.send(JSON.stringify({ requestId, error: "adapter not found" }));
1034
+ break;
1035
+ }
1036
+ const gid = String(groupId);
1037
+ if (type === "bot:groupMembers") {
1038
+ if (typeof ad.listMembers !== "function") {
1039
+ ws.send(JSON.stringify({ requestId, error: "adapter does not support listMembers" }));
1040
+ break;
1041
+ }
1042
+ const r = await ad.listMembers(botId, gid);
1043
+ ws.send(JSON.stringify({ requestId, data: r }));
1044
+ } else if (type === "bot:groupKick") {
1045
+ if (!userId) {
1046
+ ws.send(JSON.stringify({ requestId, error: "userId required" }));
1047
+ break;
1048
+ }
1049
+ if (typeof ad.kickMember !== "function") {
1050
+ ws.send(JSON.stringify({ requestId, error: "adapter does not support kickMember" }));
1051
+ break;
1052
+ }
1053
+ await ad.kickMember(botId, gid, String(userId));
1054
+ ws.send(JSON.stringify({ requestId, data: { success: true } }));
1055
+ } else if (type === "bot:groupMute") {
1056
+ if (!userId) {
1057
+ ws.send(JSON.stringify({ requestId, error: "userId required" }));
1058
+ break;
1059
+ }
1060
+ if (typeof ad.muteMember !== "function") {
1061
+ ws.send(JSON.stringify({ requestId, error: "adapter does not support muteMember" }));
1062
+ break;
1063
+ }
1064
+ await ad.muteMember(botId, gid, String(userId), duration ?? 600);
1065
+ ws.send(JSON.stringify({ requestId, data: { success: true } }));
1066
+ } else {
1067
+ if (!userId) {
1068
+ ws.send(JSON.stringify({ requestId, error: "userId required" }));
1069
+ break;
1070
+ }
1071
+ if (typeof ad.setAdmin !== "function") {
1072
+ ws.send(JSON.stringify({ requestId, error: "adapter does not support setAdmin" }));
1073
+ break;
1074
+ }
1075
+ await ad.setAdmin(botId, gid, String(userId), enable !== false);
1076
+ ws.send(JSON.stringify({ requestId, data: { success: true } }));
1077
+ }
1078
+ } catch (error) {
1079
+ ws.send(JSON.stringify({ requestId, error: (error as Error).message }));
1080
+ }
1081
+ break;
1082
+ }
1083
+
1084
+ default:
1085
+ ws.send(JSON.stringify({ requestId, error: `Unknown message type: ${type}` }));
1086
+ }
1087
+ }
1088
+
1089
+ // ================================================================
1090
+ // 数据库操作辅助函数
1091
+ // ================================================================
1092
+
1093
+ function getDb(): DatabaseFeature {
1094
+ return root.inject('database') as DatabaseFeature;
1095
+ }
1096
+
1097
+ type DbType = 'related' | 'document' | 'keyvalue';
1098
+
1099
+ function getDbType(): DbType {
1100
+ const dbFeature = getDb();
1101
+ const dialectName = dbFeature.db.dialect.name;
1102
+ if (['mongodb'].includes(dialectName)) return 'document';
1103
+ if (['redis'].includes(dialectName)) return 'keyvalue';
1104
+ return 'related';
1105
+ }
1106
+
1107
+ function getDatabaseInfo() {
1108
+ const dbFeature = getDb();
1109
+ const db = dbFeature.db;
1110
+ return {
1111
+ dialect: db.dialectName,
1112
+ type: getDbType(),
1113
+ tables: Array.from(db.models.keys()),
1114
+ };
1115
+ }
1116
+
1117
+ function getDatabaseTables() {
1118
+ const dbFeature = getDb();
1119
+ const db = dbFeature.db;
1120
+ const dbType = getDbType();
1121
+ const tables: Array<{ name: string; columns?: Record<string, any> }> = [];
1122
+ for (const [name] of db.models) {
1123
+ const def = db.definitions.get(name);
1124
+ tables.push({ name: name as string, columns: def ? Object.fromEntries(Object.entries(def).map(([col, colDef]) => [col, colDef])) : undefined });
1125
+ }
1126
+ return tables;
1127
+ }
1128
+
1129
+ async function dbSelect(table: string, page: number, pageSize: number, where?: any) {
1130
+ const dbFeature = getDb();
1131
+ const db = dbFeature.db;
1132
+ const dbType = getDbType();
1133
+ const model = db.models.get(table as any);
1134
+ if (!model) throw new Error(`Table '${table}' not found`);
1135
+
1136
+ if (dbType === 'keyvalue') {
1137
+ // KV: return entries
1138
+ const kvModel = model as any;
1139
+ const allEntries: Array<[string, any]> = await kvModel.entries();
1140
+ const total = allEntries.length;
1141
+ const start = (page - 1) * pageSize;
1142
+ const rows = allEntries.slice(start, start + pageSize).map(([k, v]: [string, any]) => ({ key: k, value: v }));
1143
+ return { rows, total, page, pageSize };
1144
+ }
1145
+
1146
+ // Related / Document: use select with pagination
1147
+ let selection = model.select();
1148
+ if (where && Object.keys(where).length > 0) {
1149
+ selection = selection.where(where);
1150
+ }
1151
+ // Get total count first
1152
+ let total: number;
1153
+ try {
1154
+ const countResult = await (db as any).aggregate(table).count('*', 'total').where(where || {});
1155
+ total = countResult?.[0]?.total ?? 0;
1156
+ } catch {
1157
+ // fallback: just fetch all and count
1158
+ const all = await model.select();
1159
+ total = (all as any[]).length;
1160
+ }
1161
+ const offset = (page - 1) * pageSize;
1162
+ let query = model.select();
1163
+ if (where && Object.keys(where).length > 0) {
1164
+ query = query.where(where);
1165
+ }
1166
+ const rows = await query.limit(pageSize).offset(offset) as any[];
1167
+ return { rows, total, page, pageSize };
1168
+ }
1169
+
1170
+ async function dbInsert(table: string, row: any) {
1171
+ const dbFeature = getDb();
1172
+ const db = dbFeature.db;
1173
+ const dbType = getDbType();
1174
+ const model = db.models.get(table as any);
1175
+ if (!model) throw new Error(`Table '${table}' not found`);
1176
+
1177
+ if (dbType === 'keyvalue') {
1178
+ const kvModel = model as any;
1179
+ if (!row.key) throw new Error('key is required for KV insert');
1180
+ await kvModel.set(row.key, row.value);
1181
+ return;
1182
+ }
1183
+
1184
+ if (dbType === 'document') {
1185
+ await (model as any).create(row);
1186
+ return;
1187
+ }
1188
+
1189
+ await model.insert(row);
1190
+ }
1191
+
1192
+ async function dbUpdate(table: string, row: any, where: any) {
1193
+ const dbFeature = getDb();
1194
+ const db = dbFeature.db;
1195
+ const dbType = getDbType();
1196
+ const model = db.models.get(table as any);
1197
+ if (!model) throw new Error(`Table '${table}' not found`);
1198
+
1199
+ if (dbType === 'keyvalue') {
1200
+ const kvModel = model as any;
1201
+ if (!where.key) throw new Error('key is required for KV update');
1202
+ await kvModel.set(where.key, row.value);
1203
+ return 1;
1204
+ }
1205
+
1206
+ if (dbType === 'document') {
1207
+ if (where._id) {
1208
+ return await (model as any).updateById(where._id, row);
1209
+ }
1210
+ }
1211
+
1212
+ return await model.update(row).where(where);
1213
+ }
1214
+
1215
+ async function dbDelete(table: string, where: any) {
1216
+ const dbFeature = getDb();
1217
+ const db = dbFeature.db;
1218
+ const dbType = getDbType();
1219
+ const model = db.models.get(table as any);
1220
+ if (!model) throw new Error(`Table '${table}' not found`);
1221
+
1222
+ if (dbType === 'keyvalue') {
1223
+ const kvModel = model as any;
1224
+ if (!where.key) throw new Error('key is required for KV delete');
1225
+ await kvModel.deleteByKey(where.key);
1226
+ return 1;
1227
+ }
1228
+
1229
+ if (dbType === 'document') {
1230
+ if (where._id) {
1231
+ return await (model as any).deleteById(where._id);
1232
+ }
1233
+ }
1234
+
1235
+ return await model.delete(where);
1236
+ }
1237
+
1238
+ async function dbDropTable(table: string) {
1239
+ const dbFeature = getDb();
1240
+ const db = dbFeature.db;
1241
+ const model = db.models.get(table as any);
1242
+ if (!model) throw new Error(`Table '${table}' not found`);
1243
+ const sql = db.dialect.formatDropTable(table, true);
1244
+ await db.query(sql);
1245
+ db.models.delete(table as any);
1246
+ db.definitions.delete(table as any);
1247
+ }
1248
+
1249
+ // KV 专用操作
1250
+ async function kvGet(table: string, key: string) {
1251
+ const dbFeature = getDb();
1252
+ const model = dbFeature.db.models.get(table as any) as any;
1253
+ if (!model) throw new Error(`Bucket '${table}' not found`);
1254
+ return await model.get(key);
1255
+ }
1256
+
1257
+ async function kvSet(table: string, key: string, value: any, ttl?: number) {
1258
+ const dbFeature = getDb();
1259
+ const model = dbFeature.db.models.get(table as any) as any;
1260
+ if (!model) throw new Error(`Bucket '${table}' not found`);
1261
+ await model.set(key, value, ttl);
1262
+ }
1263
+
1264
+ async function kvDelete(table: string, key: string) {
1265
+ const dbFeature = getDb();
1266
+ const model = dbFeature.db.models.get(table as any) as any;
1267
+ if (!model) throw new Error(`Bucket '${table}' not found`);
1268
+ await model.deleteByKey(key);
1269
+ }
1270
+
1271
+ async function kvGetEntries(table: string) {
1272
+ const dbFeature = getDb();
1273
+ const model = dbFeature.db.models.get(table as any) as any;
1274
+ if (!model) throw new Error(`Bucket '${table}' not found`);
1275
+ const entries: Array<[string, any]> = await model.entries();
1276
+ return entries.map(([k, v]) => ({ key: k, value: v }));
1277
+ }
1278
+
1279
+ interface FileTreeNode {
1280
+ name: string;
1281
+ path: string;
1282
+ type: "file" | "directory";
1283
+ children?: FileTreeNode[];
1284
+ }
1285
+
1286
+ function buildFileTree(cwd: string, relativePath: string, allowed: string[]): FileTreeNode[] {
1287
+ const tree: FileTreeNode[] = [];
1288
+ const absDir = path.resolve(cwd, relativePath);
1289
+
1290
+ for (const entry of allowed) {
1291
+ // 只处理顶层匹配项
1292
+ if (relativePath && !entry.startsWith(relativePath + "/")) continue;
1293
+ const entryRelative = relativePath ? entry.slice(relativePath.length + 1) : entry;
1294
+ if (entryRelative.includes("/")) continue; // 跳过嵌套,由递归处理
1295
+
1296
+ const absPath = path.resolve(cwd, entry);
1297
+ if (!fs.existsSync(absPath)) continue;
1298
+
1299
+ const stat = fs.statSync(absPath);
1300
+ if (stat.isDirectory()) {
1301
+ tree.push({
1302
+ name: entryRelative,
1303
+ path: entry,
1304
+ type: "directory",
1305
+ children: buildDirectoryTree(cwd, entry, 3),
1306
+ });
1307
+ } else if (stat.isFile()) {
1308
+ tree.push({ name: entryRelative, path: entry, type: "file" });
1309
+ }
1310
+ }
1311
+
1312
+ return tree.sort((a, b) => {
1313
+ if (a.type !== b.type) return a.type === "directory" ? -1 : 1;
1314
+ return a.name.localeCompare(b.name);
1315
+ });
1316
+ }
1317
+
1318
+ function buildDirectoryTree(cwd: string, relativePath: string, maxDepth: number): FileTreeNode[] {
1319
+ if (maxDepth <= 0) return [];
1320
+ const absDir = path.resolve(cwd, relativePath);
1321
+ if (!fs.existsSync(absDir) || !fs.statSync(absDir).isDirectory()) return [];
1322
+
1323
+ const entries = fs.readdirSync(absDir, { withFileTypes: true });
1324
+ const result: FileTreeNode[] = [];
1325
+
1326
+ for (const entry of entries) {
1327
+ if (FILE_MANAGER_BLOCKED.has(entry.name) || entry.name.startsWith(".")) continue;
1328
+ const childRelative = relativePath ? `${relativePath}/${entry.name}` : entry.name;
1329
+
1330
+ if (entry.isDirectory()) {
1331
+ result.push({
1332
+ name: entry.name,
1333
+ path: childRelative,
1334
+ type: "directory",
1335
+ children: buildDirectoryTree(cwd, childRelative, maxDepth - 1),
1336
+ });
1337
+ } else if (entry.isFile()) {
1338
+ result.push({ name: entry.name, path: childRelative, type: "file" });
1339
+ }
1340
+ }
1341
+
1342
+ return result.sort((a, b) => {
1343
+ if (a.type !== b.type) return a.type === "directory" ? -1 : 1;
1344
+ return a.name.localeCompare(b.name);
1345
+ });
1346
+ }
1347
+
1348
+ function findPluginByConfigKey(rootPlugin: Plugin, configKey: string): Plugin | null {
1349
+ for (const child of rootPlugin.children) {
1350
+ if (child.name === configKey || child.name.endsWith(`-${configKey}`) || child.name.includes(configKey)) {
1351
+ return child;
1352
+ }
1353
+ const found = findPluginByConfigKey(child, configKey);
1354
+ if (found) return found;
1355
+ }
1356
+ return null;
1357
+ }
1358
+
1359
+ export function broadcastToAll(webServer: WebServer, message: any) {
1360
+ for (const ws of webServer.ws.clients || []) {
1361
+ if (ws.readyState === WebSocket.OPEN) {
1362
+ ws.send(JSON.stringify(message));
1363
+ }
1364
+ }
1365
+ }
1366
+
1367
+ export function notifyDataUpdate(webServer: WebServer) {
1368
+ broadcastToAll(webServer, { type: "data-update", timestamp: Date.now() });
1369
+ }