onebots 0.5.1 → 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/README.md +6 -6
- package/lib/app.d.ts +35 -7
- package/lib/app.js +1092 -63
- package/lib/bin.js +2 -48
- package/lib/cli.d.ts +2 -0
- package/lib/cli.js +293 -0
- package/lib/config-schema.d.ts +9 -0
- package/lib/config-schema.js +78 -0
- package/lib/daemon.d.ts +30 -0
- package/lib/daemon.js +94 -0
- package/lib/index.d.ts +1 -1
- package/lib/index.js +1 -1
- package/lib/service-manager.d.ts +4 -0
- package/lib/service-manager.js +171 -0
- package/package.json +13 -5
- package/lib/utils.d.ts +0 -2
- package/lib/utils.js +0 -11
package/lib/app.js
CHANGED
|
@@ -1,18 +1,1051 @@
|
|
|
1
|
-
import { BaseApp, yaml,
|
|
2
|
-
import
|
|
1
|
+
import { BaseApp, yaml, ProtocolRegistry, configure, readLine, createManagedTokenValidator, initTokenManager, } from "@onebots/core";
|
|
2
|
+
import { getAppConfigSchema } from "./config-schema.js";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
import * as fs from "fs";
|
|
3
5
|
import { createRequire } from "module";
|
|
6
|
+
import { pathToFileURL } from "url";
|
|
4
7
|
import koaStatic from "koa-static";
|
|
5
8
|
import { copyFileSync, existsSync, writeFileSync, mkdirSync, readFileSync } from "fs";
|
|
6
|
-
import
|
|
7
|
-
|
|
9
|
+
import * as pty from "@karinjs/node-pty";
|
|
10
|
+
import { randomBytes } from "node:crypto";
|
|
11
|
+
import { execFileSync } from "node:child_process";
|
|
12
|
+
const require = createRequire(pathToFileURL(path.join(process.cwd(), 'node_modules')));
|
|
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;
|
|
8
66
|
export class App extends BaseApp {
|
|
67
|
+
ws;
|
|
68
|
+
logCacheFile;
|
|
69
|
+
logWriteStream;
|
|
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; // 最多保留条数,避免堆积过多
|
|
76
|
+
ptyTerminal = null;
|
|
77
|
+
terminalClients = new Set();
|
|
78
|
+
tokenManager = initTokenManager({
|
|
79
|
+
defaultExpiration: 12 * 60 * 60 * 1000,
|
|
80
|
+
refreshExpiration: 7 * 24 * 60 * 60 * 1000,
|
|
81
|
+
});
|
|
9
82
|
constructor(config) {
|
|
10
83
|
super(config);
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
84
|
+
// 1. 初始化日志缓存文件
|
|
85
|
+
this.logCacheFile = path.join(process.cwd(), 'data', 'terminal-logs.txt');
|
|
86
|
+
this.initLogCache();
|
|
87
|
+
// 2. 初始化 WebSocket
|
|
88
|
+
this.ws = this.router.ws("/");
|
|
89
|
+
// 3. 监听进程退出,清空缓存
|
|
90
|
+
process.on('exit', () => {
|
|
91
|
+
this.cleanupLogCache();
|
|
92
|
+
});
|
|
93
|
+
process.on('SIGINT', () => {
|
|
94
|
+
this.cleanupLogCache();
|
|
95
|
+
process.exit();
|
|
96
|
+
});
|
|
97
|
+
process.on('SIGTERM', () => {
|
|
98
|
+
this.cleanupLogCache();
|
|
99
|
+
process.exit();
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
initLogCache() {
|
|
103
|
+
// 确保 data 目录存在
|
|
104
|
+
const dataDir = path.dirname(this.logCacheFile);
|
|
105
|
+
if (!existsSync(dataDir)) {
|
|
106
|
+
mkdirSync(dataDir, { recursive: true });
|
|
107
|
+
}
|
|
108
|
+
// 清空旧的日志缓存文件(重启时清空)
|
|
109
|
+
writeFileSync(this.logCacheFile, '', 'utf-8');
|
|
110
|
+
// 创建写入流
|
|
111
|
+
this.logWriteStream = fs.createWriteStream(this.logCacheFile, { flags: 'a', encoding: 'utf-8' });
|
|
112
|
+
// 拦截 stdout 和 stderr 的 write 方法
|
|
113
|
+
const originalStdoutWrite = process.stdout.write.bind(process.stdout);
|
|
114
|
+
const originalStderrWrite = process.stderr.write.bind(process.stderr);
|
|
115
|
+
process.stdout.write = ((chunk, encoding, callback) => {
|
|
116
|
+
const message = chunk.toString();
|
|
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
|
+
}
|
|
127
|
+
// 继续正常输出
|
|
128
|
+
return originalStdoutWrite(chunk, encoding, callback);
|
|
129
|
+
});
|
|
130
|
+
process.stderr.write = ((chunk, encoding, callback) => {
|
|
131
|
+
const message = chunk.toString();
|
|
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
|
+
}
|
|
142
|
+
// 继续正常输出
|
|
143
|
+
return originalStderrWrite(chunk, encoding, callback);
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
broadcastLog(message) {
|
|
147
|
+
if (this.logClients.size > 0 && message) {
|
|
148
|
+
// 将 \n 替换为 \r\n 以适配 xterm.js
|
|
149
|
+
const terminalMessage = message.replace(/\n/g, '\r\n');
|
|
150
|
+
const data = `data: ${JSON.stringify({ message: terminalMessage })}\n\n`;
|
|
151
|
+
this.logClients.forEach(client => {
|
|
152
|
+
try {
|
|
153
|
+
client.write(data);
|
|
154
|
+
}
|
|
155
|
+
catch (e) {
|
|
156
|
+
this.logClients.delete(client);
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
}
|
|
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
|
+
}
|
|
217
|
+
cleanupLogCache() {
|
|
218
|
+
// 关闭写入流
|
|
219
|
+
if (this.logWriteStream) {
|
|
220
|
+
this.logWriteStream.end();
|
|
221
|
+
}
|
|
222
|
+
// 清空缓存文件
|
|
223
|
+
try {
|
|
224
|
+
if (existsSync(this.logCacheFile)) {
|
|
225
|
+
writeFileSync(this.logCacheFile, '', 'utf-8');
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
catch (e) {
|
|
229
|
+
console.error('清空日志缓存失败:', e);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
cacheLog(message) {
|
|
233
|
+
if (this.logWriteStream && message) {
|
|
234
|
+
this.logWriteStream.write(message);
|
|
14
235
|
}
|
|
15
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
|
+
}
|
|
305
|
+
async start() {
|
|
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")
|
|
427
|
+
this.ws.clients.forEach(async (client) => {
|
|
428
|
+
client.send(JSON.stringify({
|
|
429
|
+
event: "system.log",
|
|
430
|
+
data: await readLine(1, BaseApp.logFile),
|
|
431
|
+
}));
|
|
432
|
+
});
|
|
433
|
+
};
|
|
434
|
+
const logWatcher = fs.watch(BaseApp.logFile, fileListener);
|
|
435
|
+
this.once("close", () => {
|
|
436
|
+
logWatcher.close();
|
|
437
|
+
});
|
|
438
|
+
process.once("disconnect", () => {
|
|
439
|
+
logWatcher.close();
|
|
440
|
+
});
|
|
441
|
+
// WebSocket 连接处理
|
|
442
|
+
this.ws.on("connection", async (client) => {
|
|
443
|
+
client.send(JSON.stringify({
|
|
444
|
+
event: "system.sync",
|
|
445
|
+
data: {
|
|
446
|
+
config: fs.readFileSync(BaseApp.configPath, "utf8"),
|
|
447
|
+
adapters: [...this.adapters.values()].map(adapter => {
|
|
448
|
+
return adapter.info;
|
|
449
|
+
}),
|
|
450
|
+
protocol: ProtocolRegistry.getAllMetadata(),
|
|
451
|
+
app: this.info,
|
|
452
|
+
schema: getAppConfigSchema(),
|
|
453
|
+
logs: fs.existsSync(BaseApp.logFile) ? await readLine(100, BaseApp.logFile) : "",
|
|
454
|
+
},
|
|
455
|
+
}));
|
|
456
|
+
client.on("message", async (raw) => {
|
|
457
|
+
let payload = {};
|
|
458
|
+
try {
|
|
459
|
+
payload = JSON.parse(raw.toString());
|
|
460
|
+
}
|
|
461
|
+
catch {
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
switch (payload.action) {
|
|
465
|
+
case "system.input":
|
|
466
|
+
// 将流的模式切换到"流动模式"
|
|
467
|
+
process.stdin.resume();
|
|
468
|
+
// 使用以下函数来模拟输入数据
|
|
469
|
+
function simulateInput(data) {
|
|
470
|
+
process.nextTick(() => {
|
|
471
|
+
process.stdin.emit("data", data);
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
simulateInput(Buffer.from(payload.data + "\n", "utf8"));
|
|
475
|
+
// 模拟结束
|
|
476
|
+
process.nextTick(() => {
|
|
477
|
+
process.stdin.emit("end");
|
|
478
|
+
});
|
|
479
|
+
return true;
|
|
480
|
+
case "system.saveConfig":
|
|
481
|
+
fs.writeFileSync(BaseApp.configPath, payload.data, "utf8");
|
|
482
|
+
credentialsWereAutoGenerated = false;
|
|
483
|
+
return;
|
|
484
|
+
case "system.reload":
|
|
485
|
+
const config = yaml.load(fs.readFileSync(BaseApp.configPath, "utf8"));
|
|
486
|
+
return this.reload(config);
|
|
487
|
+
case "bot.start": {
|
|
488
|
+
const { platform, uin } = JSON.parse(payload.data);
|
|
489
|
+
await this.adapters.get(platform)?.setOnline(uin);
|
|
490
|
+
return client.send(JSON.stringify({
|
|
491
|
+
event: "bot.change",
|
|
492
|
+
data: this.adapters.get(platform).getAccount(uin).info,
|
|
493
|
+
}));
|
|
494
|
+
}
|
|
495
|
+
case "bot.stop": {
|
|
496
|
+
const { platform, uin } = JSON.parse(payload.data);
|
|
497
|
+
await this.adapters.get(platform)?.setOffline(uin);
|
|
498
|
+
return client.send(JSON.stringify({
|
|
499
|
+
event: "bot.change",
|
|
500
|
+
data: this.adapters.get(platform).getAccount(uin).info,
|
|
501
|
+
}));
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
});
|
|
505
|
+
});
|
|
506
|
+
// 管理端点
|
|
507
|
+
this.router.get("/api/adapters", (ctx) => {
|
|
508
|
+
ctx.body = [...this.adapters.values()].map(adapter => adapter.info);
|
|
509
|
+
});
|
|
510
|
+
this.router.get("/api/system", ctx => {
|
|
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
|
+
}
|
|
593
|
+
});
|
|
594
|
+
// PTY 终端 WebSocket 端点
|
|
595
|
+
const terminalWs = this.router.ws("/api/terminal");
|
|
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
|
+
}
|
|
603
|
+
// 创建 PTY 终端实例(如果不存在)
|
|
604
|
+
if (!this.ptyTerminal) {
|
|
605
|
+
const shell = process.platform === 'win32' ? 'powershell.exe' : 'bash';
|
|
606
|
+
this.ptyTerminal = pty.spawn(shell, [], {
|
|
607
|
+
name: "xterm-color",
|
|
608
|
+
cols: 80,
|
|
609
|
+
rows: 30,
|
|
610
|
+
cwd: process.env.HOME,
|
|
611
|
+
env: process.env,
|
|
612
|
+
});
|
|
613
|
+
// 监听 PTY 输出
|
|
614
|
+
this.ptyTerminal.onData((data) => {
|
|
615
|
+
// 广播到所有连接的客户端
|
|
616
|
+
this.terminalClients.forEach(c => {
|
|
617
|
+
try {
|
|
618
|
+
c.send(JSON.stringify({ type: 'output', data }));
|
|
619
|
+
}
|
|
620
|
+
catch (e) {
|
|
621
|
+
this.terminalClients.delete(c);
|
|
622
|
+
}
|
|
623
|
+
});
|
|
624
|
+
});
|
|
625
|
+
// 监听 PTY 退出
|
|
626
|
+
this.ptyTerminal.onExit(() => {
|
|
627
|
+
this.ptyTerminal = null;
|
|
628
|
+
this.terminalClients.forEach(c => {
|
|
629
|
+
try {
|
|
630
|
+
c.send(JSON.stringify({ type: 'exit' }));
|
|
631
|
+
}
|
|
632
|
+
catch (e) { }
|
|
633
|
+
});
|
|
634
|
+
this.terminalClients.clear();
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
// 添加到客户端列表
|
|
638
|
+
this.terminalClients.add(client);
|
|
639
|
+
// 监听客户端消息(用户输入)
|
|
640
|
+
client.on("message", (msg) => {
|
|
641
|
+
try {
|
|
642
|
+
const payload = JSON.parse(msg.toString());
|
|
643
|
+
if (payload.type === 'input' && this.ptyTerminal) {
|
|
644
|
+
this.ptyTerminal.write(payload.data);
|
|
645
|
+
}
|
|
646
|
+
else if (payload.type === 'resize' && this.ptyTerminal) {
|
|
647
|
+
this.ptyTerminal.resize(payload.cols, payload.rows);
|
|
648
|
+
}
|
|
649
|
+
else if (payload.type === 'restart') {
|
|
650
|
+
// 通知所有客户端
|
|
651
|
+
this.terminalClients.forEach(c => {
|
|
652
|
+
try {
|
|
653
|
+
c.send(JSON.stringify({ type: 'output', data: '\r\n\x1b[33m[服务即将重启]\x1b[0m' }));
|
|
654
|
+
}
|
|
655
|
+
catch (e) { }
|
|
656
|
+
});
|
|
657
|
+
setTimeout(() => process.exit(100), 500);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
catch (e) {
|
|
661
|
+
console.error('终端消息处理失败:', e);
|
|
662
|
+
}
|
|
663
|
+
});
|
|
664
|
+
// 监听客户端断开
|
|
665
|
+
client.on("close", () => {
|
|
666
|
+
this.terminalClients.delete(client);
|
|
667
|
+
// 如果没有客户端了,关闭 PTY
|
|
668
|
+
if (this.terminalClients.size === 0 && this.ptyTerminal) {
|
|
669
|
+
this.ptyTerminal.kill();
|
|
670
|
+
this.ptyTerminal = null;
|
|
671
|
+
}
|
|
672
|
+
});
|
|
673
|
+
});
|
|
674
|
+
// 日志流 SSE 端点
|
|
675
|
+
this.router.get("/api/logs", ctx => {
|
|
676
|
+
ctx.request.socket.setTimeout(0);
|
|
677
|
+
ctx.req.socket.setNoDelay(true);
|
|
678
|
+
ctx.req.socket.setKeepAlive(true);
|
|
679
|
+
ctx.set({
|
|
680
|
+
'Content-Type': 'text/event-stream',
|
|
681
|
+
'Cache-Control': 'no-cache',
|
|
682
|
+
'Connection': 'keep-alive',
|
|
683
|
+
'Access-Control-Allow-Origin': '*',
|
|
684
|
+
'Access-Control-Allow-Headers': 'Content-Type'
|
|
685
|
+
});
|
|
686
|
+
ctx.status = 200;
|
|
687
|
+
// 阻止 Koa 自动结束响应
|
|
688
|
+
ctx.respond = false;
|
|
689
|
+
// 添加到客户端列表
|
|
690
|
+
this.logClients.add(ctx.res);
|
|
691
|
+
// 发送缓存日志到客户端
|
|
692
|
+
try {
|
|
693
|
+
if (existsSync(this.logCacheFile)) {
|
|
694
|
+
const cachedLogs = readFileSync(this.logCacheFile, 'utf-8');
|
|
695
|
+
if (cachedLogs) {
|
|
696
|
+
// 将历史日志的 \n 也替换为 \r\n
|
|
697
|
+
const terminalLogs = cachedLogs.replace(/\n/g, '\r\n');
|
|
698
|
+
ctx.res.write(`data: ${JSON.stringify({ message: terminalLogs })}\n\n`);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
catch (error) {
|
|
703
|
+
console.error('读取日志缓存失败:', error);
|
|
704
|
+
}
|
|
705
|
+
// 定时发送心跳
|
|
706
|
+
const heartbeat = setInterval(() => {
|
|
707
|
+
try {
|
|
708
|
+
ctx.res.write(': heartbeat\n\n');
|
|
709
|
+
}
|
|
710
|
+
catch (error) {
|
|
711
|
+
clearInterval(heartbeat);
|
|
712
|
+
this.logClients.delete(ctx.res);
|
|
713
|
+
}
|
|
714
|
+
}, 30000);
|
|
715
|
+
// 监听连接关闭
|
|
716
|
+
ctx.req.on('close', () => {
|
|
717
|
+
clearInterval(heartbeat);
|
|
718
|
+
this.logClients.delete(ctx.res);
|
|
719
|
+
});
|
|
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
|
+
});
|
|
829
|
+
// 配置接口
|
|
830
|
+
this.router.get("/api/config", ctx => {
|
|
831
|
+
ctx.body = fs.readFileSync(BaseApp.configPath, "utf8");
|
|
832
|
+
});
|
|
833
|
+
this.router.get("/api/config/schema", ctx => {
|
|
834
|
+
ctx.body = getAppConfigSchema();
|
|
835
|
+
});
|
|
836
|
+
this.router.post("/api/config", async (ctx) => {
|
|
837
|
+
try {
|
|
838
|
+
const configContent = ctx.request.body;
|
|
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
|
+
}
|
|
845
|
+
ctx.body = { success: true, message: "配置已保存" };
|
|
846
|
+
}
|
|
847
|
+
catch (e) {
|
|
848
|
+
ctx.status = 500;
|
|
849
|
+
ctx.body = { success: false, message: e.message };
|
|
850
|
+
}
|
|
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
|
+
});
|
|
969
|
+
// 账号管理端点
|
|
970
|
+
this.router.get("/api/list", ctx => {
|
|
971
|
+
ctx.body = this.accounts.map(bot => bot.info);
|
|
972
|
+
});
|
|
973
|
+
this.router.post("/api/add", (ctx) => {
|
|
974
|
+
const config = ctx.request.body;
|
|
975
|
+
try {
|
|
976
|
+
this.addAccount(config);
|
|
977
|
+
ctx.body = { success: true, message: '添加成功' };
|
|
978
|
+
}
|
|
979
|
+
catch (e) {
|
|
980
|
+
ctx.status = 500;
|
|
981
|
+
ctx.body = { success: false, message: e.message };
|
|
982
|
+
}
|
|
983
|
+
});
|
|
984
|
+
this.router.post("/api/edit", (ctx) => {
|
|
985
|
+
const config = ctx.request.body;
|
|
986
|
+
try {
|
|
987
|
+
this.updateAccount(config);
|
|
988
|
+
ctx.body = { success: true, message: '修改成功' };
|
|
989
|
+
}
|
|
990
|
+
catch (e) {
|
|
991
|
+
ctx.status = 500;
|
|
992
|
+
ctx.body = { success: false, message: e.message };
|
|
993
|
+
}
|
|
994
|
+
});
|
|
995
|
+
this.router.get("/api/remove", ctx => {
|
|
996
|
+
const { uin, platform, force } = ctx.request.query;
|
|
997
|
+
try {
|
|
998
|
+
this.removeAccount(String(platform), String(uin), Boolean(force));
|
|
999
|
+
ctx.body = { success: true, message: '移除成功' };
|
|
1000
|
+
}
|
|
1001
|
+
catch (e) {
|
|
1002
|
+
ctx.status = 500;
|
|
1003
|
+
ctx.body = { success: false, message: e.message };
|
|
1004
|
+
}
|
|
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
|
+
});
|
|
1030
|
+
// 静态文件服务
|
|
1031
|
+
if (fs.existsSync(client)) {
|
|
1032
|
+
this.use(koaStatic(client));
|
|
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'));
|
|
1044
|
+
});
|
|
1045
|
+
}
|
|
1046
|
+
// 调用父类的 start
|
|
1047
|
+
await super.start();
|
|
1048
|
+
}
|
|
16
1049
|
}
|
|
17
1050
|
(function (App) {
|
|
18
1051
|
App.defaultConfig = {
|
|
@@ -25,61 +1058,46 @@ export class App extends BaseApp {
|
|
|
25
1058
|
};
|
|
26
1059
|
}
|
|
27
1060
|
App.registerGeneral = registerGeneral;
|
|
28
|
-
async function
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
async function
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
for (const name of maybeNames) {
|
|
50
|
-
try {
|
|
51
|
-
const entry = require.resolve(name);
|
|
52
|
-
const mod = await import(entry);
|
|
53
|
-
if (mod.default)
|
|
54
|
-
return mod.default;
|
|
55
|
-
}
|
|
56
|
-
catch (e) {
|
|
57
|
-
errors.push(e.toString());
|
|
58
|
-
}
|
|
1061
|
+
async function safeImport(name) {
|
|
1062
|
+
try {
|
|
1063
|
+
return await import(name);
|
|
1064
|
+
}
|
|
1065
|
+
catch { }
|
|
1066
|
+
}
|
|
1067
|
+
async function loadAdapterFactory(platform, maybeNames = [
|
|
1068
|
+
`@onebots/adapter-${platform}`,
|
|
1069
|
+
`onebots-adapter-${platform}`,
|
|
1070
|
+
platform
|
|
1071
|
+
]) {
|
|
1072
|
+
if (!maybeNames.length)
|
|
1073
|
+
return false;
|
|
1074
|
+
const modName = maybeNames.shift();
|
|
1075
|
+
try {
|
|
1076
|
+
require(modName);
|
|
1077
|
+
return true;
|
|
1078
|
+
}
|
|
1079
|
+
catch (e) {
|
|
1080
|
+
console.warn(`[onebots] Failed to load adapter ${modName}: ${e}`);
|
|
1081
|
+
return loadAdapterFactory(platform, maybeNames);
|
|
59
1082
|
}
|
|
60
|
-
throw new Error(errors.join("\n"));
|
|
61
1083
|
}
|
|
62
1084
|
App.loadAdapterFactory = loadAdapterFactory;
|
|
63
|
-
async function loadProtocolFactory(name,
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
catch (e) {
|
|
79
|
-
errors.push(e.toString());
|
|
80
|
-
}
|
|
1085
|
+
async function loadProtocolFactory(name, maybeNames = [
|
|
1086
|
+
`@onebots/protocol-${name}`,
|
|
1087
|
+
`onebots-protocol-${name}`,
|
|
1088
|
+
`${name}`
|
|
1089
|
+
]) {
|
|
1090
|
+
if (!maybeNames.length)
|
|
1091
|
+
return false;
|
|
1092
|
+
const modName = maybeNames.shift();
|
|
1093
|
+
try {
|
|
1094
|
+
require(modName);
|
|
1095
|
+
return true;
|
|
1096
|
+
}
|
|
1097
|
+
catch (e) {
|
|
1098
|
+
console.warn(`[onebots] Failed to load protocol ${modName}: ${e}`);
|
|
1099
|
+
return loadProtocolFactory(name, maybeNames);
|
|
81
1100
|
}
|
|
82
|
-
throw new Error(errors.join("\n"));
|
|
83
1101
|
}
|
|
84
1102
|
App.loadProtocolFactory = loadProtocolFactory;
|
|
85
1103
|
})(App || (App = {}));
|
|
@@ -92,10 +1110,8 @@ export function createOnebots(config = "config.yaml") {
|
|
|
92
1110
|
if (!existsSync(BaseApp.configDir))
|
|
93
1111
|
mkdirSync(BaseApp.configDir);
|
|
94
1112
|
if (!existsSync(BaseApp.configPath) && isStartWithConfigFile) {
|
|
95
|
-
copyFileSync(path.resolve(
|
|
96
|
-
console.log("
|
|
97
|
-
console.log(`配置文件在: ${BaseApp.configPath}`);
|
|
98
|
-
process.exit();
|
|
1113
|
+
copyFileSync(path.resolve(import.meta.dirname, "./config.sample.yaml"), BaseApp.configPath);
|
|
1114
|
+
console.log("[onebots] 已创建默认配置文件:", BaseApp.configPath);
|
|
99
1115
|
}
|
|
100
1116
|
if (!isStartWithConfigFile) {
|
|
101
1117
|
writeFileSync(BaseApp.configPath, yaml.dump(config));
|
|
@@ -106,6 +1122,19 @@ export function createOnebots(config = "config.yaml") {
|
|
|
106
1122
|
console.log("已为你创建数据存储目录", BaseApp.dataDir);
|
|
107
1123
|
}
|
|
108
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
|
+
}
|
|
109
1138
|
configure({
|
|
110
1139
|
appenders: {
|
|
111
1140
|
out: {
|
|
@@ -115,7 +1144,7 @@ export function createOnebots(config = "config.yaml") {
|
|
|
115
1144
|
files: {
|
|
116
1145
|
type: "file",
|
|
117
1146
|
maxLogSize: 1024 * 1024 * 50,
|
|
118
|
-
filename:
|
|
1147
|
+
filename: BaseApp.logFile,
|
|
119
1148
|
},
|
|
120
1149
|
},
|
|
121
1150
|
categories: {
|