onebots 1.0.0 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/app.js CHANGED
@@ -1,4 +1,5 @@
1
- import { BaseApp, yaml, ProtocolRegistry, configure, readLine } from "@onebots/core";
1
+ import { BaseApp, yaml, ProtocolRegistry, configure, readLine, createManagedTokenValidator, initTokenManager, } from "@onebots/core";
2
+ import { getAppConfigSchema } from "./config-schema.js";
2
3
  import * as path from "path";
3
4
  import * as fs from "fs";
4
5
  import { createRequire } from "module";
@@ -6,15 +7,78 @@ import { pathToFileURL } from "url";
6
7
  import koaStatic from "koa-static";
7
8
  import { copyFileSync, existsSync, writeFileSync, mkdirSync, readFileSync } from "fs";
8
9
  import * as pty from "@karinjs/node-pty";
10
+ import { randomBytes } from "node:crypto";
11
+ import { execFileSync } from "node:child_process";
9
12
  const require = createRequire(pathToFileURL(path.join(process.cwd(), 'node_modules')));
10
- const client = path.resolve(path.join(process.cwd(), 'node_modules/@onebots/web/dist'));
13
+ /** 站点静态根目录下的文件名:禁止路径分隔与控制字符,仅使用 basename */
14
+ function sanitizePublicStaticBasename(original) {
15
+ if (original == null || String(original).trim() === '')
16
+ return null;
17
+ let raw = String(original).trim();
18
+ try {
19
+ raw = decodeURIComponent(raw);
20
+ }
21
+ catch {
22
+ return null;
23
+ }
24
+ if (/[\\/]/.test(raw) || raw.includes('..'))
25
+ return null;
26
+ if (/[\x00-\x1f]/.test(raw))
27
+ return null;
28
+ const base = path.basename(raw);
29
+ if (!base || base !== raw || base === '.' || base === '..')
30
+ return null;
31
+ if (base.length > 255)
32
+ return null;
33
+ return base;
34
+ }
35
+ function pickPublicStaticUpload(files) {
36
+ if (!files || typeof files !== 'object')
37
+ return null;
38
+ const raw = files.file ?? files.upload;
39
+ if (!raw)
40
+ return null;
41
+ const file = Array.isArray(raw) ? raw[0] : raw;
42
+ if (!file || typeof file !== 'object')
43
+ return null;
44
+ return file;
45
+ }
46
+ // 多目录依次查找 @onebots/web/dist,找到即用(Docker/HF 从 development 启动时 cwd 下无 @onebots/web)
47
+ const client = (() => {
48
+ const rel = ['..', 'node_modules', '@onebots', 'web', 'dist'];
49
+ const candidates = [
50
+ path.join(import.meta.dirname, ...rel),
51
+ path.join(process.cwd(), 'node_modules', '@onebots', 'web', 'dist'),
52
+ path.join(import.meta.dirname, '..', '..', '..', 'packages', 'web', 'dist'),
53
+ ].map(p => path.resolve(p));
54
+ for (const dir of candidates) {
55
+ console.log('[onebots] 查找 Web 前端目录:', dir);
56
+ if (existsSync(dir)) {
57
+ console.log('[onebots] 使用 Web 前端目录:', dir);
58
+ return dir;
59
+ }
60
+ }
61
+ console.log('[onebots] 未找到 @onebots/web/dist,管理端页面将不可用');
62
+ return '';
63
+ })();
64
+ /** 当前实例是否使用了自动生成的管理端账号(用于登录成功后提示用户修改密码) */
65
+ let credentialsWereAutoGenerated = false;
11
66
  export class App extends BaseApp {
12
67
  ws;
13
68
  logCacheFile;
14
69
  logWriteStream;
15
70
  logClients = new Set();
71
+ verificationClients = new Set();
72
+ /** 待处理验证请求(Web 离线时也可稍后拉取完成),key: platform:account_id */
73
+ pendingVerifications = new Map();
74
+ static VERIFICATION_TTL_MS = 30 * 60 * 1000; // 30 分钟过期
75
+ static MAX_PENDING_VERIFICATIONS = 20; // 最多保留条数,避免堆积过多
16
76
  ptyTerminal = null;
17
77
  terminalClients = new Set();
78
+ tokenManager = initTokenManager({
79
+ defaultExpiration: 12 * 60 * 60 * 1000,
80
+ refreshExpiration: 7 * 24 * 60 * 60 * 1000,
81
+ });
18
82
  constructor(config) {
19
83
  super(config);
20
84
  // 1. 初始化日志缓存文件
@@ -50,19 +114,31 @@ export class App extends BaseApp {
50
114
  const originalStderrWrite = process.stderr.write.bind(process.stderr);
51
115
  process.stdout.write = ((chunk, encoding, callback) => {
52
116
  const message = chunk.toString();
53
- // 缓存到文件
54
- this.cacheLog(message);
55
- // 广播到所有 SSE 客户端
56
- this.broadcastLog(message);
117
+ try {
118
+ // 缓存到文件
119
+ this.cacheLog(message);
120
+ // 广播到所有 SSE 客户端
121
+ this.broadcastLog(message);
122
+ }
123
+ catch (e) {
124
+ // Use the original write to avoid re-entering the interceptor
125
+ originalStderrWrite(`[onebots] Log interceptor error: ${e}\n`);
126
+ }
57
127
  // 继续正常输出
58
128
  return originalStdoutWrite(chunk, encoding, callback);
59
129
  });
60
130
  process.stderr.write = ((chunk, encoding, callback) => {
61
131
  const message = chunk.toString();
62
- // 缓存到文件
63
- this.cacheLog(message);
64
- // 广播到所有 SSE 客户端
65
- this.broadcastLog(message);
132
+ try {
133
+ // 缓存到文件
134
+ this.cacheLog(message);
135
+ // 广播到所有 SSE 客户端
136
+ this.broadcastLog(message);
137
+ }
138
+ catch (e) {
139
+ // Use the original write to avoid re-entering the interceptor
140
+ originalStderrWrite(`[onebots] Log interceptor error: ${e}\n`);
141
+ }
66
142
  // 继续正常输出
67
143
  return originalStderrWrite(chunk, encoding, callback);
68
144
  });
@@ -82,6 +158,62 @@ export class App extends BaseApp {
82
158
  });
83
159
  }
84
160
  }
161
+ /** 将验证请求推送给所有已连接的 verification SSE 客户端 */
162
+ broadcastVerification(payload) {
163
+ const data = `data: ${JSON.stringify(payload)}\n\n`;
164
+ this.verificationClients.forEach(client => {
165
+ try {
166
+ client.write(data);
167
+ }
168
+ catch (e) {
169
+ this.verificationClients.delete(client);
170
+ }
171
+ });
172
+ }
173
+ /** 存储待处理验证并广播(Web 离线时也会持久化,用户稍后打开页面可拉取完成);超出上限时剔除最旧的;key 含 type 以便同一账号同时存在 device 与 sms */
174
+ storeAndBroadcastVerification(payload) {
175
+ const platform = String(payload.platform ?? '');
176
+ const account_id = String(payload.account_id ?? '');
177
+ const type = String(payload.type ?? '');
178
+ if (!platform || !account_id)
179
+ return;
180
+ const key = `${platform}:${account_id}:${type}`;
181
+ const createdAt = Date.now();
182
+ if (this.pendingVerifications.size >= App.MAX_PENDING_VERIFICATIONS && !this.pendingVerifications.has(key)) {
183
+ let oldestKey = null;
184
+ let oldestTime = Infinity;
185
+ for (const [k, { createdAt: t }] of this.pendingVerifications) {
186
+ if (t < oldestTime) {
187
+ oldestTime = t;
188
+ oldestKey = k;
189
+ }
190
+ }
191
+ if (oldestKey != null)
192
+ this.pendingVerifications.delete(oldestKey);
193
+ }
194
+ this.pendingVerifications.set(key, { payload, createdAt });
195
+ this.broadcastVerification(payload);
196
+ }
197
+ /** 返回未过期的待处理验证列表(用于 GET /api/verification/pending) */
198
+ getPendingVerificationList() {
199
+ const now = Date.now();
200
+ const list = [];
201
+ for (const [key, { payload, createdAt }] of this.pendingVerifications) {
202
+ if (now - createdAt <= App.VERIFICATION_TTL_MS) {
203
+ list.push(payload);
204
+ }
205
+ else {
206
+ this.pendingVerifications.delete(key);
207
+ }
208
+ }
209
+ return list;
210
+ }
211
+ /** 订阅适配器的 verification:request,用于推送到 Web 并持久化待处理列表 */
212
+ onAdapterCreated(adapter) {
213
+ adapter.on('verification:request', (payload) => {
214
+ this.storeAndBroadcastVerification(payload);
215
+ });
216
+ }
85
217
  cleanupLogCache() {
86
218
  // 关闭写入流
87
219
  if (this.logWriteStream) {
@@ -102,10 +234,196 @@ export class App extends BaseApp {
102
234
  this.logWriteStream.write(message);
103
235
  }
104
236
  }
237
+ /** 将当前配置与整个 data 目录备份到 HF Space 仓库(需 HF_TOKEN、HF_REPO_ID) */
238
+ async backupDataToHf(configContent) {
239
+ const hfToken = process.env.HF_TOKEN;
240
+ const hfRepoId = process.env.HF_REPO_ID;
241
+ if (!hfToken || !hfRepoId || !/^[a-zA-Z0-9_-]+\/[a-zA-Z0-9_.-]+$/.test(hfRepoId)) {
242
+ return { success: false, message: "未配置 HF_TOKEN 或 HF_REPO_ID" };
243
+ }
244
+ const [namespace, repo] = hfRepoId.split("/");
245
+ const files = [
246
+ { path: "config_backup.yaml", content: configContent },
247
+ ];
248
+ try {
249
+ const dataDir = BaseApp.configDir;
250
+ if (existsSync(dataDir)) {
251
+ const tarBuf = execFileSync("tar", ["-czf", "-", "-C", dataDir, "."], {
252
+ encoding: "buffer",
253
+ maxBuffer: 15 * 1024 * 1024,
254
+ });
255
+ if (tarBuf.length > 0) {
256
+ files.push({ path: "data_backup.tar.gz", content: tarBuf.toString("base64"), encoding: "base64" });
257
+ }
258
+ }
259
+ const res = await fetch(`https://huggingface.co/api/spaces/${namespace}/${repo}/commit/main`, {
260
+ method: "POST",
261
+ headers: {
262
+ "Content-Type": "application/json",
263
+ "Authorization": `Bearer ${hfToken}`,
264
+ },
265
+ body: JSON.stringify({
266
+ summary: "onebots data backup",
267
+ files,
268
+ }),
269
+ });
270
+ if (!res.ok) {
271
+ const text = await res.text();
272
+ this.logger?.warn?.("备份到 HF 仓库失败:", res.status, text);
273
+ return { success: false, message: `备份失败: ${res.status} ${text}` };
274
+ }
275
+ return { success: true };
276
+ }
277
+ catch (e) {
278
+ this.logger?.warn?.("备份到 HF 仓库异常:", e);
279
+ return { success: false, message: e.message };
280
+ }
281
+ }
282
+ /**
283
+ * 站点静态文件变更后:若配置了 HF_TOKEN + HF_REPO_ID(如 Hugging Face Space),则再次打包整个配置目录并提交到仓库,持久化 static 等文件
284
+ */
285
+ async backupDataDirToHfAfterStaticChange() {
286
+ const hfToken = process.env.HF_TOKEN;
287
+ const hfRepoId = process.env.HF_REPO_ID;
288
+ if (!hfToken || !hfRepoId || !/^[a-zA-Z0-9_-]+\/[a-zA-Z0-9_.-]+$/.test(hfRepoId)) {
289
+ return { attempted: false };
290
+ }
291
+ try {
292
+ const configContent = readFileSync(BaseApp.configPath, 'utf8');
293
+ const r = await this.backupDataToHf(configContent);
294
+ if (!r.success && r.message) {
295
+ this.logger?.warn?.(`Hugging Face 备份(站点静态变更后): ${r.message}`);
296
+ }
297
+ return { attempted: true, success: r.success, message: r.message };
298
+ }
299
+ catch (e) {
300
+ const msg = e.message;
301
+ this.logger?.warn?.('Hugging Face 备份(站点静态变更后)异常:', e);
302
+ return { attempted: true, success: false, message: msg };
303
+ }
304
+ }
105
305
  async start() {
106
- // WebSocket 日志监听
107
- const fileListener = e => {
108
- if (e === "change")
306
+ const authValidator = createManagedTokenValidator(this.tokenManager, {
307
+ tokenName: 'access_token',
308
+ errorMessage: 'Unauthorized',
309
+ });
310
+ const expectedUsername = this.config.username ?? BaseApp.defaultConfig.username;
311
+ const expectedPassword = this.config.password ?? BaseApp.defaultConfig.password;
312
+ const expectedAccessToken = this.config.access_token?.trim()
313
+ || process.env.ONEBOTS_ACCESS_TOKEN?.trim()
314
+ || undefined;
315
+ const getTokenFromRequest = (request) => {
316
+ const authHeader = request.headers.authorization;
317
+ if (authHeader && typeof authHeader === 'string') {
318
+ const match = authHeader.match(/^Bearer\s+(.+)$/i);
319
+ return match ? match[1] : authHeader;
320
+ }
321
+ try {
322
+ const url = new URL(request.url || '/', 'http://localhost');
323
+ return url.searchParams.get('access_token') || undefined;
324
+ }
325
+ catch {
326
+ return undefined;
327
+ }
328
+ };
329
+ const getTokenFromKoa = (ctx) => {
330
+ const authHeader = ctx.request.headers.authorization;
331
+ if (authHeader && typeof authHeader === 'string') {
332
+ const match = authHeader.match(/^Bearer\s+(.+)$/i);
333
+ return match ? match[1] : authHeader;
334
+ }
335
+ return ctx.request.query?.access_token || undefined;
336
+ };
337
+ this.router.post("/api/auth/login", (ctx) => {
338
+ const body = ctx.request.body;
339
+ // 鉴权码登录:Bearer 鉴权码,与 config 中 access_token 或环境变量 ONEBOTS_ACCESS_TOKEN 一致即可
340
+ if (body.access_token != null && body.access_token !== '') {
341
+ if (expectedAccessToken && body.access_token === expectedAccessToken) {
342
+ ctx.body = {
343
+ success: true,
344
+ token: body.access_token,
345
+ expiresAt: null,
346
+ refreshToken: null,
347
+ isDefaultCredentials: false,
348
+ };
349
+ return;
350
+ }
351
+ ctx.status = 401;
352
+ ctx.body = { success: false, message: "鉴权码错误" };
353
+ return;
354
+ }
355
+ if (!body.username || !body.password || body.username !== expectedUsername || body.password !== expectedPassword) {
356
+ ctx.status = 401;
357
+ ctx.body = { success: false, message: "用户名或密码错误" };
358
+ return;
359
+ }
360
+ const tokenInfo = this.tokenManager.generateToken({ username: body.username });
361
+ ctx.body = {
362
+ success: true,
363
+ token: tokenInfo.token,
364
+ expiresAt: tokenInfo.expiresAt,
365
+ refreshToken: tokenInfo.refreshToken,
366
+ isDefaultCredentials: credentialsWereAutoGenerated,
367
+ };
368
+ });
369
+ this.router.post("/api/auth/refresh", (ctx) => {
370
+ const { refreshToken } = ctx.request.body;
371
+ if (!refreshToken) {
372
+ ctx.status = 400;
373
+ ctx.body = { success: false, message: "缺少 refreshToken" };
374
+ return;
375
+ }
376
+ const tokenInfo = this.tokenManager.refreshToken(refreshToken);
377
+ if (!tokenInfo) {
378
+ ctx.status = 401;
379
+ ctx.body = { success: false, message: "refreshToken 无效或已过期" };
380
+ return;
381
+ }
382
+ ctx.body = {
383
+ success: true,
384
+ token: tokenInfo.token,
385
+ expiresAt: tokenInfo.expiresAt,
386
+ refreshToken: tokenInfo.refreshToken,
387
+ };
388
+ });
389
+ // 仅对 Web 管理端 /api 鉴权(Bearer / access_token / 登录 token);各平台对外 API(OneBot、KOOK 等)由各自协议/适配器鉴权,不经过此处
390
+ this.router.use('/api', async (ctx, next) => {
391
+ if (ctx.path === '/api/auth/login' || ctx.path === '/api/auth/refresh')
392
+ return next();
393
+ const token = getTokenFromKoa(ctx);
394
+ if (expectedAccessToken && token === expectedAccessToken) {
395
+ ctx.state.token = token;
396
+ ctx.state.tokenInfo = { metadata: { username: 'token' }, expiresAt: null };
397
+ return next();
398
+ }
399
+ return authValidator(ctx, next);
400
+ });
401
+ this.router.post("/api/auth/logout", (ctx) => {
402
+ const token = ctx.state.token;
403
+ if (token && token !== expectedAccessToken)
404
+ this.tokenManager.revokeToken(token);
405
+ ctx.body = { success: true };
406
+ });
407
+ this.router.get("/api/auth/me", (ctx) => {
408
+ const tokenInfo = ctx.state.tokenInfo;
409
+ ctx.body = {
410
+ success: true,
411
+ data: {
412
+ username: tokenInfo?.metadata?.username ?? expectedUsername,
413
+ expiresAt: tokenInfo?.expiresAt ?? null,
414
+ isDefaultCredentials: credentialsWereAutoGenerated,
415
+ },
416
+ };
417
+ });
418
+ // WebSocket 日志监听(确保日志文件存在再 watch,避免 ENOENT)
419
+ if (!existsSync(BaseApp.logFile)) {
420
+ const dir = path.dirname(BaseApp.logFile);
421
+ if (!existsSync(dir))
422
+ mkdirSync(dir, { recursive: true });
423
+ writeFileSync(BaseApp.logFile, "", "utf8");
424
+ }
425
+ const fileListener = (eventType) => {
426
+ if (eventType === "change")
109
427
  this.ws.clients.forEach(async (client) => {
110
428
  client.send(JSON.stringify({
111
429
  event: "system.log",
@@ -113,12 +431,12 @@ export class App extends BaseApp {
113
431
  }));
114
432
  });
115
433
  };
116
- fs.watch(BaseApp.logFile, fileListener);
434
+ const logWatcher = fs.watch(BaseApp.logFile, fileListener);
117
435
  this.once("close", () => {
118
- fs.unwatchFile(BaseApp.logFile, fileListener);
436
+ logWatcher.close();
119
437
  });
120
438
  process.once("disconnect", () => {
121
- fs.unwatchFile(BaseApp.logFile, fileListener);
439
+ logWatcher.close();
122
440
  });
123
441
  // WebSocket 连接处理
124
442
  this.ws.on("connection", async (client) => {
@@ -131,6 +449,7 @@ export class App extends BaseApp {
131
449
  }),
132
450
  protocol: ProtocolRegistry.getAllMetadata(),
133
451
  app: this.info,
452
+ schema: getAppConfigSchema(),
134
453
  logs: fs.existsSync(BaseApp.logFile) ? await readLine(100, BaseApp.logFile) : "",
135
454
  },
136
455
  }));
@@ -159,7 +478,9 @@ export class App extends BaseApp {
159
478
  });
160
479
  return true;
161
480
  case "system.saveConfig":
162
- return fs.writeFileSync(BaseApp.configPath, payload.data, "utf8");
481
+ fs.writeFileSync(BaseApp.configPath, payload.data, "utf8");
482
+ credentialsWereAutoGenerated = false;
483
+ return;
163
484
  case "system.reload":
164
485
  const config = yaml.load(fs.readFileSync(BaseApp.configPath, "utf8"));
165
486
  return this.reload(config);
@@ -187,11 +508,98 @@ export class App extends BaseApp {
187
508
  ctx.body = [...this.adapters.values()].map(adapter => adapter.info);
188
509
  });
189
510
  this.router.get("/api/system", ctx => {
190
- ctx.body = this.info;
511
+ ctx.body = {
512
+ ...this.info,
513
+ isDefaultCredentials: credentialsWereAutoGenerated,
514
+ configDir: BaseApp.configDir,
515
+ configPath: BaseApp.configPath,
516
+ dataDir: BaseApp.dataDir,
517
+ };
518
+ });
519
+ /** 手动将 data 与配置备份到 HF 仓库(与保存配置时的备份逻辑一致) */
520
+ this.router.post("/api/system/backup-to-hf", async (ctx) => {
521
+ try {
522
+ const configContent = readFileSync(BaseApp.configPath, "utf8");
523
+ const result = await this.backupDataToHf(configContent);
524
+ if (result.success) {
525
+ ctx.body = { success: true, message: "已备份到仓库" };
526
+ }
527
+ else {
528
+ ctx.status = 400;
529
+ ctx.body = { success: false, message: result.message ?? "备份失败" };
530
+ }
531
+ }
532
+ catch (e) {
533
+ ctx.status = 500;
534
+ ctx.body = { success: false, message: e.message };
535
+ }
536
+ });
537
+ /** 重启服务:进程退出后由 Docker 的 restart 策略自动拉起容器;非 Docker 需手动重新启动 */
538
+ this.router.post("/api/system/restart", (ctx) => {
539
+ ctx.body = { success: true, message: "服务即将重启" };
540
+ setImmediate(() => {
541
+ setTimeout(() => {
542
+ process.exit(0);
543
+ }, 1500);
544
+ });
545
+ });
546
+ // CLI send:通过已运行网关发信
547
+ this.router.post("/api/send", async (ctx) => {
548
+ try {
549
+ const body = ctx.request.body || {};
550
+ const channel = String(body.channel ?? "");
551
+ const target_id = String(body.target_id ?? "");
552
+ const target_type = String(body.target_type ?? "private");
553
+ const message = String(body.message ?? "");
554
+ if (!channel || !target_id) {
555
+ ctx.status = 400;
556
+ ctx.body = { success: false, message: "缺少 channel 或 target_id" };
557
+ return;
558
+ }
559
+ const parts = channel.split(".");
560
+ const platform = parts[0];
561
+ const account_id = parts.slice(1).join(".") || parts[1];
562
+ if (!platform || !account_id) {
563
+ ctx.status = 400;
564
+ ctx.body = { success: false, message: "channel 格式应为 platform.account_id" };
565
+ return;
566
+ }
567
+ const adapter = this.adapters.get(platform);
568
+ if (!adapter) {
569
+ ctx.status = 404;
570
+ ctx.body = { success: false, message: `适配器 ${platform} 不存在` };
571
+ return;
572
+ }
573
+ const account = adapter.getAccount(account_id);
574
+ if (!account) {
575
+ ctx.status = 404;
576
+ ctx.body = { success: false, message: `账号 ${channel} 不存在` };
577
+ return;
578
+ }
579
+ const segments = [{ type: "text", data: { text: message } }];
580
+ const scene_id = adapter.createId(target_id);
581
+ const result = await adapter.sendMessage(account_id, {
582
+ scene_type: target_type,
583
+ scene_id,
584
+ message: segments,
585
+ });
586
+ ctx.body = { success: true, message_id: result?.message_id ?? null };
587
+ }
588
+ catch (e) {
589
+ const err = e;
590
+ ctx.status = 500;
591
+ ctx.body = { success: false, message: err?.message ?? "发送失败" };
592
+ }
191
593
  });
192
594
  // PTY 终端 WebSocket 端点
193
595
  const terminalWs = this.router.ws("/api/terminal");
194
- terminalWs.on("connection", (client) => {
596
+ terminalWs.on("connection", (client, request) => {
597
+ const token = getTokenFromRequest(request);
598
+ const valid = !!token && (expectedAccessToken ? token === expectedAccessToken : this.tokenManager.validateToken(token).valid);
599
+ if (!valid) {
600
+ client.close(1008, "Unauthorized");
601
+ return;
602
+ }
195
603
  // 创建 PTY 终端实例(如果不存在)
196
604
  if (!this.ptyTerminal) {
197
605
  const shell = process.platform === 'win32' ? 'powershell.exe' : 'bash';
@@ -310,14 +718,130 @@ export class App extends BaseApp {
310
718
  this.logClients.delete(ctx.res);
311
719
  });
312
720
  });
721
+ // 验证流 SSE 端点(登录验证事件推送到 Web)
722
+ this.router.get("/api/verification/stream", ctx => {
723
+ ctx.request.socket.setTimeout(0);
724
+ ctx.req.socket.setNoDelay(true);
725
+ ctx.req.socket.setKeepAlive(true);
726
+ ctx.set({
727
+ 'Content-Type': 'text/event-stream',
728
+ 'Cache-Control': 'no-cache',
729
+ 'Connection': 'keep-alive',
730
+ 'Access-Control-Allow-Origin': '*',
731
+ 'Access-Control-Allow-Headers': 'Content-Type'
732
+ });
733
+ ctx.status = 200;
734
+ ctx.respond = false;
735
+ this.verificationClients.add(ctx.res);
736
+ const heartbeatVerification = setInterval(() => {
737
+ try {
738
+ ctx.res.write(': heartbeat\n\n');
739
+ }
740
+ catch (error) {
741
+ clearInterval(heartbeatVerification);
742
+ this.verificationClients.delete(ctx.res);
743
+ }
744
+ }, 30000);
745
+ ctx.req.on('close', () => {
746
+ clearInterval(heartbeatVerification);
747
+ this.verificationClients.delete(ctx.res);
748
+ });
749
+ });
750
+ // 订阅已有适配器的验证事件(init 时创建的适配器已通过 onAdapterCreated 订阅,此处为兜底)
751
+ for (const [, adapter] of this.adapters) {
752
+ if (!adapter.listenerCount('verification:request')) {
753
+ adapter.on('verification:request', (payload) => {
754
+ this.storeAndBroadcastVerification(payload);
755
+ });
756
+ }
757
+ }
758
+ // 待处理验证列表(Web 打开页面时拉取,避免离线期间错过验证)
759
+ this.router.get("/api/verification/pending", (ctx) => {
760
+ ctx.body = this.getPendingVerificationList();
761
+ });
762
+ // 请求发送短信验证码(设备锁带手机号时,用户选短信验证前调用)
763
+ this.router.post("/api/verification/request-sms", async (ctx) => {
764
+ try {
765
+ const body = ctx.request.body || {};
766
+ const platform = String(body.platform ?? '');
767
+ const account_id = String(body.account_id ?? '');
768
+ if (!platform || !account_id) {
769
+ ctx.status = 400;
770
+ ctx.body = { success: false, message: '缺少 platform 或 account_id' };
771
+ return;
772
+ }
773
+ const adapter = this.adapters.get(platform);
774
+ if (!adapter) {
775
+ ctx.status = 404;
776
+ ctx.body = { success: false, message: `适配器 ${platform} 不存在` };
777
+ return;
778
+ }
779
+ const requestSms = adapter.requestSmsCode;
780
+ if (typeof requestSms !== 'function') {
781
+ ctx.status = 501;
782
+ ctx.body = { success: false, message: `适配器 ${platform} 不支持请求短信验证码` };
783
+ return;
784
+ }
785
+ await Promise.resolve(requestSms.call(adapter, account_id));
786
+ ctx.body = { success: true };
787
+ }
788
+ catch (e) {
789
+ const err = e;
790
+ ctx.status = 500;
791
+ ctx.body = { success: false, message: err?.message ?? '请求失败' };
792
+ }
793
+ });
794
+ // 验证提交接口(Web 完成滑块/短信等后提交)
795
+ this.router.post("/api/verification/submit", async (ctx) => {
796
+ try {
797
+ const body = ctx.request.body || {};
798
+ const platform = String(body.platform ?? '');
799
+ const account_id = String(body.account_id ?? '');
800
+ const type = String(body.type ?? '');
801
+ const data = body.data && typeof body.data === 'object' ? body.data : {};
802
+ if (!platform || !account_id || !type) {
803
+ ctx.status = 400;
804
+ ctx.body = { success: false, message: '缺少 platform、account_id 或 type' };
805
+ return;
806
+ }
807
+ const adapter = this.adapters.get(platform);
808
+ if (!adapter) {
809
+ ctx.status = 404;
810
+ ctx.body = { success: false, message: `适配器 ${platform} 不存在` };
811
+ return;
812
+ }
813
+ const submit = adapter.submitVerification;
814
+ if (typeof submit !== 'function') {
815
+ ctx.status = 501;
816
+ ctx.body = { success: false, message: `适配器 ${platform} 不支持 Web 验证提交` };
817
+ return;
818
+ }
819
+ await Promise.resolve(submit.call(adapter, account_id, type, data));
820
+ this.pendingVerifications.delete(`${platform}:${account_id}:${type}`);
821
+ ctx.body = { success: true };
822
+ }
823
+ catch (e) {
824
+ const err = e;
825
+ ctx.status = 500;
826
+ ctx.body = { success: false, message: err?.message ?? '提交失败' };
827
+ }
828
+ });
313
829
  // 配置接口
314
830
  this.router.get("/api/config", ctx => {
315
831
  ctx.body = fs.readFileSync(BaseApp.configPath, "utf8");
316
832
  });
317
- this.router.post("/api/config", (ctx) => {
833
+ this.router.get("/api/config/schema", ctx => {
834
+ ctx.body = getAppConfigSchema();
835
+ });
836
+ this.router.post("/api/config", async (ctx) => {
318
837
  try {
319
838
  const configContent = ctx.request.body;
320
839
  fs.writeFileSync(BaseApp.configPath, configContent, "utf8");
840
+ credentialsWereAutoGenerated = false;
841
+ const backupResult = await this.backupDataToHf(configContent);
842
+ if (!backupResult.success && backupResult.message) {
843
+ this.logger?.warn?.(backupResult.message);
844
+ }
321
845
  ctx.body = { success: true, message: "配置已保存" };
322
846
  }
323
847
  catch (e) {
@@ -325,6 +849,123 @@ export class App extends BaseApp {
325
849
  ctx.body = { success: false, message: e.message };
326
850
  }
327
851
  });
852
+ // 站点根静态文件(public_static_dir):列表 / 上传 / 删除(需已配置并保存 public_static_dir)
853
+ this.router.get("/api/public-static/files", (ctx) => {
854
+ const root = this.getPublicStaticRoot();
855
+ if (!root) {
856
+ ctx.status = 400;
857
+ ctx.body = {
858
+ success: false,
859
+ message: '请先在基础配置中设置 public_static_dir 并保存配置',
860
+ };
861
+ return;
862
+ }
863
+ try {
864
+ const names = fs
865
+ .readdirSync(root, { withFileTypes: true })
866
+ .filter((d) => d.isFile())
867
+ .map((d) => d.name)
868
+ .sort((a, b) => a.localeCompare(b));
869
+ ctx.body = { success: true, files: names, root };
870
+ }
871
+ catch (e) {
872
+ ctx.status = 500;
873
+ ctx.body = { success: false, message: e.message };
874
+ }
875
+ });
876
+ this.router.post("/api/public-static/upload", async (ctx) => {
877
+ const root = this.getPublicStaticRoot();
878
+ if (!root) {
879
+ ctx.status = 400;
880
+ ctx.body = {
881
+ success: false,
882
+ message: '请先在基础配置中设置 public_static_dir 并保存配置',
883
+ };
884
+ return;
885
+ }
886
+ const file = pickPublicStaticUpload(ctx.request.files);
887
+ if (!file?.filepath) {
888
+ ctx.status = 400;
889
+ ctx.body = { success: false, message: '缺少上传文件(字段名 file)' };
890
+ return;
891
+ }
892
+ const safeName = sanitizePublicStaticBasename(file.originalFilename ?? file.newFilename);
893
+ if (!safeName) {
894
+ try {
895
+ fs.unlinkSync(file.filepath);
896
+ }
897
+ catch {
898
+ /* 忽略临时文件清理失败 */
899
+ }
900
+ ctx.status = 400;
901
+ ctx.body = { success: false, message: '非法或无法识别的文件名' };
902
+ return;
903
+ }
904
+ const dest = path.join(root, safeName);
905
+ const tmpPath = file.filepath;
906
+ try {
907
+ fs.copyFileSync(tmpPath, dest);
908
+ ctx.body = { success: true, message: '上传成功', filename: safeName };
909
+ const hf = await this.backupDataDirToHfAfterStaticChange();
910
+ if (hf.attempted) {
911
+ ctx.body.hf_backup = hf;
912
+ }
913
+ }
914
+ catch (e) {
915
+ ctx.status = 500;
916
+ ctx.body = { success: false, message: e.message };
917
+ }
918
+ finally {
919
+ try {
920
+ fs.unlinkSync(tmpPath);
921
+ }
922
+ catch {
923
+ /* 忽略 */
924
+ }
925
+ }
926
+ });
927
+ this.router.delete("/api/public-static/:filename", async (ctx) => {
928
+ const root = this.getPublicStaticRoot();
929
+ if (!root) {
930
+ ctx.status = 400;
931
+ ctx.body = {
932
+ success: false,
933
+ message: '请先在基础配置中设置 public_static_dir 并保存配置',
934
+ };
935
+ return;
936
+ }
937
+ const safeName = sanitizePublicStaticBasename(ctx.params.filename ?? '');
938
+ if (!safeName) {
939
+ ctx.status = 400;
940
+ ctx.body = { success: false, message: '非法文件名' };
941
+ return;
942
+ }
943
+ const resolvedRoot = path.resolve(root);
944
+ const target = path.join(root, safeName);
945
+ const rel = path.relative(resolvedRoot, path.resolve(target));
946
+ if (rel.startsWith('..') || path.isAbsolute(rel) || rel === '') {
947
+ ctx.status = 400;
948
+ ctx.body = { success: false, message: '路径非法' };
949
+ return;
950
+ }
951
+ try {
952
+ if (!fs.existsSync(target) || !fs.statSync(target).isFile()) {
953
+ ctx.status = 404;
954
+ ctx.body = { success: false, message: '文件不存在' };
955
+ return;
956
+ }
957
+ fs.unlinkSync(target);
958
+ ctx.body = { success: true, message: '已删除' };
959
+ const hf = await this.backupDataDirToHfAfterStaticChange();
960
+ if (hf.attempted) {
961
+ ctx.body.hf_backup = hf;
962
+ }
963
+ }
964
+ catch (e) {
965
+ ctx.status = 500;
966
+ ctx.body = { success: false, message: e.message };
967
+ }
968
+ });
328
969
  // 账号管理端点
329
970
  this.router.get("/api/list", ctx => {
330
971
  ctx.body = this.accounts.map(bot => bot.info);
@@ -362,15 +1003,44 @@ export class App extends BaseApp {
362
1003
  ctx.body = { success: false, message: e.message };
363
1004
  }
364
1005
  });
1006
+ this.router.post("/api/bots/start", async (ctx) => {
1007
+ const { platform, uin } = ctx.request.body;
1008
+ try {
1009
+ const adapter = this.adapters.get(platform);
1010
+ await adapter?.setOnline(uin);
1011
+ ctx.body = { success: true, data: adapter?.getAccount(uin)?.info };
1012
+ }
1013
+ catch (e) {
1014
+ ctx.status = 500;
1015
+ ctx.body = { success: false, message: e.message };
1016
+ }
1017
+ });
1018
+ this.router.post("/api/bots/stop", async (ctx) => {
1019
+ const { platform, uin } = ctx.request.body;
1020
+ try {
1021
+ const adapter = this.adapters.get(platform);
1022
+ await adapter?.setOffline(uin);
1023
+ ctx.body = { success: true, data: adapter?.getAccount(uin)?.info };
1024
+ }
1025
+ catch (e) {
1026
+ ctx.status = 500;
1027
+ ctx.body = { success: false, message: e.message };
1028
+ }
1029
+ });
365
1030
  // 静态文件服务
366
1031
  if (fs.existsSync(client)) {
367
1032
  this.use(koaStatic(client));
368
- // SPA fallback
369
- this.use(async (ctx) => {
370
- if (!ctx.path.startsWith(this.config.path || '')) {
371
- ctx.type = 'html';
372
- ctx.body = fs.readFileSync(path.join(client, 'index.html'));
373
- }
1033
+ // SPA fallback:仅对已知前端路由返回 index.html;协议与 adapter 提供的路径(如 /platform/accountId/...、.../webhook)一律 next,由 router 处理
1034
+ const spaPathRegex = /^\/(login|bots|config|system|terminal|logs)(\/.*)?$/;
1035
+ this.use(async (ctx, next) => {
1036
+ if (ctx.method !== 'HEAD' && ctx.method !== 'GET')
1037
+ return next();
1038
+ const p = ctx.path;
1039
+ const isSpaRoute = p === '/' || spaPathRegex.test(p);
1040
+ if (!isSpaRoute)
1041
+ return next();
1042
+ ctx.type = 'html';
1043
+ ctx.body = fs.readFileSync(path.join(client, 'index.html'));
374
1044
  });
375
1045
  }
376
1046
  // 调用父类的 start
@@ -441,9 +1111,7 @@ export function createOnebots(config = "config.yaml") {
441
1111
  mkdirSync(BaseApp.configDir);
442
1112
  if (!existsSync(BaseApp.configPath) && isStartWithConfigFile) {
443
1113
  copyFileSync(path.resolve(import.meta.dirname, "./config.sample.yaml"), BaseApp.configPath);
444
- console.log("未找到对应配置文件,已自动生成默认配置文件,请修改配置文件后重新启动");
445
- console.log(`配置文件在: ${BaseApp.configPath}`);
446
- process.exit();
1114
+ console.log("[onebots] 已创建默认配置文件:", BaseApp.configPath);
447
1115
  }
448
1116
  if (!isStartWithConfigFile) {
449
1117
  writeFileSync(BaseApp.configPath, yaml.dump(config));
@@ -454,6 +1122,19 @@ export function createOnebots(config = "config.yaml") {
454
1122
  console.log("已为你创建数据存储目录", BaseApp.dataDir);
455
1123
  }
456
1124
  config = yaml.load(readFileSync(BaseApp.configPath, "utf8"));
1125
+ const hasAccessToken = !!config.access_token?.trim();
1126
+ if ((!config.username || !config.password) && !hasAccessToken) {
1127
+ const generatedUser = "onebots_" + randomBytes(4).toString("hex");
1128
+ const generatedPass = randomBytes(16).toString("hex");
1129
+ config.username = generatedUser;
1130
+ config.password = generatedPass;
1131
+ writeFileSync(BaseApp.configPath, yaml.dump(config));
1132
+ credentialsWereAutoGenerated = true;
1133
+ console.log("[onebots] 已自动生成管理端账号并写入配置文件,请尽快在 Web 端修改密码:");
1134
+ console.log(" 用户名:", generatedUser);
1135
+ console.log(" 密码:", generatedPass);
1136
+ console.log(" 配置文件:", BaseApp.configPath);
1137
+ }
457
1138
  configure({
458
1139
  appenders: {
459
1140
  out: {
@@ -463,7 +1144,7 @@ export function createOnebots(config = "config.yaml") {
463
1144
  files: {
464
1145
  type: "file",
465
1146
  maxLogSize: 1024 * 1024 * 50,
466
- filename: path.join(process.cwd(), "onebots.log"),
1147
+ filename: BaseApp.logFile,
467
1148
  },
468
1149
  },
469
1150
  categories: {