UNPKG

onebots

Version:

OneBots 整合适配器和协议,提供HTTP/WebSocket服务

1,164 lines 49.3 kB
import { BaseApp, yaml, ProtocolRegistry, configure, readLine, createManagedTokenValidator, initTokenManager, } from "@onebots/core"; import { getAppConfigSchema } from "./config-schema.js"; import * as path from "path"; import * as fs from "fs"; import { createRequire } from "module"; import { pathToFileURL } from "url"; import koaStatic from "koa-static"; import { copyFileSync, existsSync, writeFileSync, mkdirSync, readFileSync } from "fs"; import * as pty from "@karinjs/node-pty"; import { randomBytes } from "node:crypto"; import { execFileSync } from "node:child_process"; const require = createRequire(pathToFileURL(path.join(process.cwd(), 'node_modules'))); /** 站点静态根目录下的文件名:禁止路径分隔与控制字符,仅使用 basename */ function sanitizePublicStaticBasename(original) { if (original == null || String(original).trim() === '') return null; let raw = String(original).trim(); try { raw = decodeURIComponent(raw); } catch { return null; } if (/[\\/]/.test(raw) || raw.includes('..')) return null; if (/[\x00-\x1f]/.test(raw)) return null; const base = path.basename(raw); if (!base || base !== raw || base === '.' || base === '..') return null; if (base.length > 255) return null; return base; } function pickPublicStaticUpload(files) { if (!files || typeof files !== 'object') return null; const raw = files.file ?? files.upload; if (!raw) return null; const file = Array.isArray(raw) ? raw[0] : raw; if (!file || typeof file !== 'object') return null; return file; } // 多目录依次查找 @onebots/web/dist,找到即用(Docker/HF 从 development 启动时 cwd 下无 @onebots/web) const client = (() => { const rel = ['..', 'node_modules', '@onebots', 'web', 'dist']; const candidates = [ path.join(import.meta.dirname, ...rel), path.join(process.cwd(), 'node_modules', '@onebots', 'web', 'dist'), path.join(import.meta.dirname, '..', '..', '..', 'packages', 'web', 'dist'), ].map(p => path.resolve(p)); for (const dir of candidates) { console.log('[onebots] 查找 Web 前端目录:', dir); if (existsSync(dir)) { console.log('[onebots] 使用 Web 前端目录:', dir); return dir; } } console.log('[onebots] 未找到 @onebots/web/dist,管理端页面将不可用'); return ''; })(); /** 当前实例是否使用了自动生成的管理端账号(用于登录成功后提示用户修改密码) */ let credentialsWereAutoGenerated = false; export class App extends BaseApp { ws; logCacheFile; logWriteStream; logClients = new Set(); verificationClients = new Set(); /** 待处理验证请求(Web 离线时也可稍后拉取完成),key: platform:account_id */ pendingVerifications = new Map(); static VERIFICATION_TTL_MS = 30 * 60 * 1000; // 30 分钟过期 static MAX_PENDING_VERIFICATIONS = 20; // 最多保留条数,避免堆积过多 ptyTerminal = null; terminalClients = new Set(); tokenManager = initTokenManager({ defaultExpiration: 12 * 60 * 60 * 1000, refreshExpiration: 7 * 24 * 60 * 60 * 1000, }); constructor(config) { super(config); // 1. 初始化日志缓存文件 this.logCacheFile = path.join(process.cwd(), 'data', 'terminal-logs.txt'); this.initLogCache(); // 2. 初始化 WebSocket this.ws = this.router.ws("/"); // 3. 监听进程退出,清空缓存 process.on('exit', () => { this.cleanupLogCache(); }); process.on('SIGINT', () => { this.cleanupLogCache(); process.exit(); }); process.on('SIGTERM', () => { this.cleanupLogCache(); process.exit(); }); } initLogCache() { // 确保 data 目录存在 const dataDir = path.dirname(this.logCacheFile); if (!existsSync(dataDir)) { mkdirSync(dataDir, { recursive: true }); } // 清空旧的日志缓存文件(重启时清空) writeFileSync(this.logCacheFile, '', 'utf-8'); // 创建写入流 this.logWriteStream = fs.createWriteStream(this.logCacheFile, { flags: 'a', encoding: 'utf-8' }); // 拦截 stdout 和 stderr 的 write 方法 const originalStdoutWrite = process.stdout.write.bind(process.stdout); const originalStderrWrite = process.stderr.write.bind(process.stderr); process.stdout.write = ((chunk, encoding, callback) => { const message = chunk.toString(); try { // 缓存到文件 this.cacheLog(message); // 广播到所有 SSE 客户端 this.broadcastLog(message); } catch (e) { // Use the original write to avoid re-entering the interceptor originalStderrWrite(`[onebots] Log interceptor error: ${e}\n`); } // 继续正常输出 return originalStdoutWrite(chunk, encoding, callback); }); process.stderr.write = ((chunk, encoding, callback) => { const message = chunk.toString(); try { // 缓存到文件 this.cacheLog(message); // 广播到所有 SSE 客户端 this.broadcastLog(message); } catch (e) { // Use the original write to avoid re-entering the interceptor originalStderrWrite(`[onebots] Log interceptor error: ${e}\n`); } // 继续正常输出 return originalStderrWrite(chunk, encoding, callback); }); } broadcastLog(message) { if (this.logClients.size > 0 && message) { // 将 \n 替换为 \r\n 以适配 xterm.js const terminalMessage = message.replace(/\n/g, '\r\n'); const data = `data: ${JSON.stringify({ message: terminalMessage })}\n\n`; this.logClients.forEach(client => { try { client.write(data); } catch (e) { this.logClients.delete(client); } }); } } /** 将验证请求推送给所有已连接的 verification SSE 客户端 */ broadcastVerification(payload) { const data = `data: ${JSON.stringify(payload)}\n\n`; this.verificationClients.forEach(client => { try { client.write(data); } catch (e) { this.verificationClients.delete(client); } }); } /** 存储待处理验证并广播(Web 离线时也会持久化,用户稍后打开页面可拉取完成);超出上限时剔除最旧的;key 含 type 以便同一账号同时存在 device 与 sms */ storeAndBroadcastVerification(payload) { const platform = String(payload.platform ?? ''); const account_id = String(payload.account_id ?? ''); const type = String(payload.type ?? ''); if (!platform || !account_id) return; const key = `${platform}:${account_id}:${type}`; const createdAt = Date.now(); if (this.pendingVerifications.size >= App.MAX_PENDING_VERIFICATIONS && !this.pendingVerifications.has(key)) { let oldestKey = null; let oldestTime = Infinity; for (const [k, { createdAt: t }] of this.pendingVerifications) { if (t < oldestTime) { oldestTime = t; oldestKey = k; } } if (oldestKey != null) this.pendingVerifications.delete(oldestKey); } this.pendingVerifications.set(key, { payload, createdAt }); this.broadcastVerification(payload); } /** 返回未过期的待处理验证列表(用于 GET /api/verification/pending) */ getPendingVerificationList() { const now = Date.now(); const list = []; for (const [key, { payload, createdAt }] of this.pendingVerifications) { if (now - createdAt <= App.VERIFICATION_TTL_MS) { list.push(payload); } else { this.pendingVerifications.delete(key); } } return list; } /** 订阅适配器的 verification:request,用于推送到 Web 并持久化待处理列表 */ onAdapterCreated(adapter) { adapter.on('verification:request', (payload) => { this.storeAndBroadcastVerification(payload); }); } cleanupLogCache() { // 关闭写入流 if (this.logWriteStream) { this.logWriteStream.end(); } // 清空缓存文件 try { if (existsSync(this.logCacheFile)) { writeFileSync(this.logCacheFile, '', 'utf-8'); } } catch (e) { console.error('清空日志缓存失败:', e); } } cacheLog(message) { if (this.logWriteStream && message) { this.logWriteStream.write(message); } } /** 将当前配置与整个 data 目录备份到 HF Space 仓库(需 HF_TOKEN、HF_REPO_ID) */ async backupDataToHf(configContent) { const hfToken = process.env.HF_TOKEN; const hfRepoId = process.env.HF_REPO_ID; if (!hfToken || !hfRepoId || !/^[a-zA-Z0-9_-]+\/[a-zA-Z0-9_.-]+$/.test(hfRepoId)) { return { success: false, message: "未配置 HF_TOKEN 或 HF_REPO_ID" }; } const [namespace, repo] = hfRepoId.split("/"); const files = [ { path: "config_backup.yaml", content: configContent }, ]; try { const dataDir = BaseApp.configDir; if (existsSync(dataDir)) { const tarBuf = execFileSync("tar", ["-czf", "-", "-C", dataDir, "."], { encoding: "buffer", maxBuffer: 15 * 1024 * 1024, }); if (tarBuf.length > 0) { files.push({ path: "data_backup.tar.gz", content: tarBuf.toString("base64"), encoding: "base64" }); } } const res = await fetch(`https://huggingface.co/api/spaces/${namespace}/${repo}/commit/main`, { method: "POST", headers: { "Content-Type": "application/json", "Authorization": `Bearer ${hfToken}`, }, body: JSON.stringify({ summary: "onebots data backup", files, }), }); if (!res.ok) { const text = await res.text(); this.logger?.warn?.("备份到 HF 仓库失败:", res.status, text); return { success: false, message: `备份失败: ${res.status} ${text}` }; } return { success: true }; } catch (e) { this.logger?.warn?.("备份到 HF 仓库异常:", e); return { success: false, message: e.message }; } } /** * 站点静态文件变更后:若配置了 HF_TOKEN + HF_REPO_ID(如 Hugging Face Space),则再次打包整个配置目录并提交到仓库,持久化 static 等文件 */ async backupDataDirToHfAfterStaticChange() { const hfToken = process.env.HF_TOKEN; const hfRepoId = process.env.HF_REPO_ID; if (!hfToken || !hfRepoId || !/^[a-zA-Z0-9_-]+\/[a-zA-Z0-9_.-]+$/.test(hfRepoId)) { return { attempted: false }; } try { const configContent = readFileSync(BaseApp.configPath, 'utf8'); const r = await this.backupDataToHf(configContent); if (!r.success && r.message) { this.logger?.warn?.(`Hugging Face 备份(站点静态变更后): ${r.message}`); } return { attempted: true, success: r.success, message: r.message }; } catch (e) { const msg = e.message; this.logger?.warn?.('Hugging Face 备份(站点静态变更后)异常:', e); return { attempted: true, success: false, message: msg }; } } async start() { const authValidator = createManagedTokenValidator(this.tokenManager, { tokenName: 'access_token', errorMessage: 'Unauthorized', }); const expectedUsername = this.config.username ?? BaseApp.defaultConfig.username; const expectedPassword = this.config.password ?? BaseApp.defaultConfig.password; const expectedAccessToken = this.config.access_token?.trim() || process.env.ONEBOTS_ACCESS_TOKEN?.trim() || undefined; const getTokenFromRequest = (request) => { const authHeader = request.headers.authorization; if (authHeader && typeof authHeader === 'string') { const match = authHeader.match(/^Bearer\s+(.+)$/i); return match ? match[1] : authHeader; } try { const url = new URL(request.url || '/', 'http://localhost'); return url.searchParams.get('access_token') || undefined; } catch { return undefined; } }; const getTokenFromKoa = (ctx) => { const authHeader = ctx.request.headers.authorization; if (authHeader && typeof authHeader === 'string') { const match = authHeader.match(/^Bearer\s+(.+)$/i); return match ? match[1] : authHeader; } return ctx.request.query?.access_token || undefined; }; this.router.post("/api/auth/login", (ctx) => { const body = ctx.request.body; // 鉴权码登录:Bearer 鉴权码,与 config 中 access_token 或环境变量 ONEBOTS_ACCESS_TOKEN 一致即可 if (body.access_token != null && body.access_token !== '') { if (expectedAccessToken && body.access_token === expectedAccessToken) { ctx.body = { success: true, token: body.access_token, expiresAt: null, refreshToken: null, isDefaultCredentials: false, }; return; } ctx.status = 401; ctx.body = { success: false, message: "鉴权码错误" }; return; } if (!body.username || !body.password || body.username !== expectedUsername || body.password !== expectedPassword) { ctx.status = 401; ctx.body = { success: false, message: "用户名或密码错误" }; return; } const tokenInfo = this.tokenManager.generateToken({ username: body.username }); ctx.body = { success: true, token: tokenInfo.token, expiresAt: tokenInfo.expiresAt, refreshToken: tokenInfo.refreshToken, isDefaultCredentials: credentialsWereAutoGenerated, }; }); this.router.post("/api/auth/refresh", (ctx) => { const { refreshToken } = ctx.request.body; if (!refreshToken) { ctx.status = 400; ctx.body = { success: false, message: "缺少 refreshToken" }; return; } const tokenInfo = this.tokenManager.refreshToken(refreshToken); if (!tokenInfo) { ctx.status = 401; ctx.body = { success: false, message: "refreshToken 无效或已过期" }; return; } ctx.body = { success: true, token: tokenInfo.token, expiresAt: tokenInfo.expiresAt, refreshToken: tokenInfo.refreshToken, }; }); // 仅对 Web 管理端 /api 鉴权(Bearer / access_token / 登录 token);各平台对外 API(OneBot、KOOK 等)由各自协议/适配器鉴权,不经过此处 this.router.use('/api', async (ctx, next) => { if (ctx.path === '/api/auth/login' || ctx.path === '/api/auth/refresh') return next(); const token = getTokenFromKoa(ctx); if (expectedAccessToken && token === expectedAccessToken) { ctx.state.token = token; ctx.state.tokenInfo = { metadata: { username: 'token' }, expiresAt: null }; return next(); } return authValidator(ctx, next); }); this.router.post("/api/auth/logout", (ctx) => { const token = ctx.state.token; if (token && token !== expectedAccessToken) this.tokenManager.revokeToken(token); ctx.body = { success: true }; }); this.router.get("/api/auth/me", (ctx) => { const tokenInfo = ctx.state.tokenInfo; ctx.body = { success: true, data: { username: tokenInfo?.metadata?.username ?? expectedUsername, expiresAt: tokenInfo?.expiresAt ?? null, isDefaultCredentials: credentialsWereAutoGenerated, }, }; }); // WebSocket 日志监听(确保日志文件存在再 watch,避免 ENOENT) if (!existsSync(BaseApp.logFile)) { const dir = path.dirname(BaseApp.logFile); if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); writeFileSync(BaseApp.logFile, "", "utf8"); } const fileListener = (eventType) => { if (eventType === "change") this.ws.clients.forEach(async (client) => { client.send(JSON.stringify({ event: "system.log", data: await readLine(1, BaseApp.logFile), })); }); }; const logWatcher = fs.watch(BaseApp.logFile, fileListener); this.once("close", () => { logWatcher.close(); }); process.once("disconnect", () => { logWatcher.close(); }); // WebSocket 连接处理 this.ws.on("connection", async (client) => { client.send(JSON.stringify({ event: "system.sync", data: { config: fs.readFileSync(BaseApp.configPath, "utf8"), adapters: [...this.adapters.values()].map(adapter => { return adapter.info; }), protocol: ProtocolRegistry.getAllMetadata(), app: this.info, schema: getAppConfigSchema(), logs: fs.existsSync(BaseApp.logFile) ? await readLine(100, BaseApp.logFile) : "", }, })); client.on("message", async (raw) => { let payload = {}; try { payload = JSON.parse(raw.toString()); } catch { return; } switch (payload.action) { case "system.input": // 将流的模式切换到"流动模式" process.stdin.resume(); // 使用以下函数来模拟输入数据 function simulateInput(data) { process.nextTick(() => { process.stdin.emit("data", data); }); } simulateInput(Buffer.from(payload.data + "\n", "utf8")); // 模拟结束 process.nextTick(() => { process.stdin.emit("end"); }); return true; case "system.saveConfig": fs.writeFileSync(BaseApp.configPath, payload.data, "utf8"); credentialsWereAutoGenerated = false; return; case "system.reload": const config = yaml.load(fs.readFileSync(BaseApp.configPath, "utf8")); return this.reload(config); case "bot.start": { const { platform, uin } = JSON.parse(payload.data); await this.adapters.get(platform)?.setOnline(uin); return client.send(JSON.stringify({ event: "bot.change", data: this.adapters.get(platform).getAccount(uin).info, })); } case "bot.stop": { const { platform, uin } = JSON.parse(payload.data); await this.adapters.get(platform)?.setOffline(uin); return client.send(JSON.stringify({ event: "bot.change", data: this.adapters.get(platform).getAccount(uin).info, })); } } }); }); // 管理端点 this.router.get("/api/adapters", (ctx) => { ctx.body = [...this.adapters.values()].map(adapter => adapter.info); }); this.router.get("/api/system", ctx => { ctx.body = { ...this.info, isDefaultCredentials: credentialsWereAutoGenerated, configDir: BaseApp.configDir, configPath: BaseApp.configPath, dataDir: BaseApp.dataDir, }; }); /** 手动将 data 与配置备份到 HF 仓库(与保存配置时的备份逻辑一致) */ this.router.post("/api/system/backup-to-hf", async (ctx) => { try { const configContent = readFileSync(BaseApp.configPath, "utf8"); const result = await this.backupDataToHf(configContent); if (result.success) { ctx.body = { success: true, message: "已备份到仓库" }; } else { ctx.status = 400; ctx.body = { success: false, message: result.message ?? "备份失败" }; } } catch (e) { ctx.status = 500; ctx.body = { success: false, message: e.message }; } }); /** 重启服务:进程退出后由 Docker 的 restart 策略自动拉起容器;非 Docker 需手动重新启动 */ this.router.post("/api/system/restart", (ctx) => { ctx.body = { success: true, message: "服务即将重启" }; setImmediate(() => { setTimeout(() => { process.exit(0); }, 1500); }); }); // CLI send:通过已运行网关发信 this.router.post("/api/send", async (ctx) => { try { const body = ctx.request.body || {}; const channel = String(body.channel ?? ""); const target_id = String(body.target_id ?? ""); const target_type = String(body.target_type ?? "private"); const message = String(body.message ?? ""); if (!channel || !target_id) { ctx.status = 400; ctx.body = { success: false, message: "缺少 channel 或 target_id" }; return; } const parts = channel.split("."); const platform = parts[0]; const account_id = parts.slice(1).join(".") || parts[1]; if (!platform || !account_id) { ctx.status = 400; ctx.body = { success: false, message: "channel 格式应为 platform.account_id" }; return; } const adapter = this.adapters.get(platform); if (!adapter) { ctx.status = 404; ctx.body = { success: false, message: `适配器 ${platform} 不存在` }; return; } const account = adapter.getAccount(account_id); if (!account) { ctx.status = 404; ctx.body = { success: false, message: `账号 ${channel} 不存在` }; return; } const segments = [{ type: "text", data: { text: message } }]; const scene_id = adapter.createId(target_id); const result = await adapter.sendMessage(account_id, { scene_type: target_type, scene_id, message: segments, }); ctx.body = { success: true, message_id: result?.message_id ?? null }; } catch (e) { const err = e; ctx.status = 500; ctx.body = { success: false, message: err?.message ?? "发送失败" }; } }); // PTY 终端 WebSocket 端点 const terminalWs = this.router.ws("/api/terminal"); terminalWs.on("connection", (client, request) => { const token = getTokenFromRequest(request); const valid = !!token && (expectedAccessToken ? token === expectedAccessToken : this.tokenManager.validateToken(token).valid); if (!valid) { client.close(1008, "Unauthorized"); return; } // 创建 PTY 终端实例(如果不存在) if (!this.ptyTerminal) { const shell = process.platform === 'win32' ? 'powershell.exe' : 'bash'; this.ptyTerminal = pty.spawn(shell, [], { name: "xterm-color", cols: 80, rows: 30, cwd: process.env.HOME, env: process.env, }); // 监听 PTY 输出 this.ptyTerminal.onData((data) => { // 广播到所有连接的客户端 this.terminalClients.forEach(c => { try { c.send(JSON.stringify({ type: 'output', data })); } catch (e) { this.terminalClients.delete(c); } }); }); // 监听 PTY 退出 this.ptyTerminal.onExit(() => { this.ptyTerminal = null; this.terminalClients.forEach(c => { try { c.send(JSON.stringify({ type: 'exit' })); } catch (e) { } }); this.terminalClients.clear(); }); } // 添加到客户端列表 this.terminalClients.add(client); // 监听客户端消息(用户输入) client.on("message", (msg) => { try { const payload = JSON.parse(msg.toString()); if (payload.type === 'input' && this.ptyTerminal) { this.ptyTerminal.write(payload.data); } else if (payload.type === 'resize' && this.ptyTerminal) { this.ptyTerminal.resize(payload.cols, payload.rows); } else if (payload.type === 'restart') { // 通知所有客户端 this.terminalClients.forEach(c => { try { c.send(JSON.stringify({ type: 'output', data: '\r\n\x1b[33m[服务即将重启]\x1b[0m' })); } catch (e) { } }); setTimeout(() => process.exit(100), 500); } } catch (e) { console.error('终端消息处理失败:', e); } }); // 监听客户端断开 client.on("close", () => { this.terminalClients.delete(client); // 如果没有客户端了,关闭 PTY if (this.terminalClients.size === 0 && this.ptyTerminal) { this.ptyTerminal.kill(); this.ptyTerminal = null; } }); }); // 日志流 SSE 端点 this.router.get("/api/logs", ctx => { ctx.request.socket.setTimeout(0); ctx.req.socket.setNoDelay(true); ctx.req.socket.setKeepAlive(true); ctx.set({ 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'Content-Type' }); ctx.status = 200; // 阻止 Koa 自动结束响应 ctx.respond = false; // 添加到客户端列表 this.logClients.add(ctx.res); // 发送缓存日志到客户端 try { if (existsSync(this.logCacheFile)) { const cachedLogs = readFileSync(this.logCacheFile, 'utf-8'); if (cachedLogs) { // 将历史日志的 \n 也替换为 \r\n const terminalLogs = cachedLogs.replace(/\n/g, '\r\n'); ctx.res.write(`data: ${JSON.stringify({ message: terminalLogs })}\n\n`); } } } catch (error) { console.error('读取日志缓存失败:', error); } // 定时发送心跳 const heartbeat = setInterval(() => { try { ctx.res.write(': heartbeat\n\n'); } catch (error) { clearInterval(heartbeat); this.logClients.delete(ctx.res); } }, 30000); // 监听连接关闭 ctx.req.on('close', () => { clearInterval(heartbeat); this.logClients.delete(ctx.res); }); }); // 验证流 SSE 端点(登录验证事件推送到 Web) this.router.get("/api/verification/stream", ctx => { ctx.request.socket.setTimeout(0); ctx.req.socket.setNoDelay(true); ctx.req.socket.setKeepAlive(true); ctx.set({ 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'Content-Type' }); ctx.status = 200; ctx.respond = false; this.verificationClients.add(ctx.res); const heartbeatVerification = setInterval(() => { try { ctx.res.write(': heartbeat\n\n'); } catch (error) { clearInterval(heartbeatVerification); this.verificationClients.delete(ctx.res); } }, 30000); ctx.req.on('close', () => { clearInterval(heartbeatVerification); this.verificationClients.delete(ctx.res); }); }); // 订阅已有适配器的验证事件(init 时创建的适配器已通过 onAdapterCreated 订阅,此处为兜底) for (const [, adapter] of this.adapters) { if (!adapter.listenerCount('verification:request')) { adapter.on('verification:request', (payload) => { this.storeAndBroadcastVerification(payload); }); } } // 待处理验证列表(Web 打开页面时拉取,避免离线期间错过验证) this.router.get("/api/verification/pending", (ctx) => { ctx.body = this.getPendingVerificationList(); }); // 请求发送短信验证码(设备锁带手机号时,用户选短信验证前调用) this.router.post("/api/verification/request-sms", async (ctx) => { try { const body = ctx.request.body || {}; const platform = String(body.platform ?? ''); const account_id = String(body.account_id ?? ''); if (!platform || !account_id) { ctx.status = 400; ctx.body = { success: false, message: '缺少 platform 或 account_id' }; return; } const adapter = this.adapters.get(platform); if (!adapter) { ctx.status = 404; ctx.body = { success: false, message: `适配器 ${platform} 不存在` }; return; } const requestSms = adapter.requestSmsCode; if (typeof requestSms !== 'function') { ctx.status = 501; ctx.body = { success: false, message: `适配器 ${platform} 不支持请求短信验证码` }; return; } await Promise.resolve(requestSms.call(adapter, account_id)); ctx.body = { success: true }; } catch (e) { const err = e; ctx.status = 500; ctx.body = { success: false, message: err?.message ?? '请求失败' }; } }); // 验证提交接口(Web 完成滑块/短信等后提交) this.router.post("/api/verification/submit", async (ctx) => { try { const body = ctx.request.body || {}; const platform = String(body.platform ?? ''); const account_id = String(body.account_id ?? ''); const type = String(body.type ?? ''); const data = body.data && typeof body.data === 'object' ? body.data : {}; if (!platform || !account_id || !type) { ctx.status = 400; ctx.body = { success: false, message: '缺少 platform、account_id 或 type' }; return; } const adapter = this.adapters.get(platform); if (!adapter) { ctx.status = 404; ctx.body = { success: false, message: `适配器 ${platform} 不存在` }; return; } const submit = adapter.submitVerification; if (typeof submit !== 'function') { ctx.status = 501; ctx.body = { success: false, message: `适配器 ${platform} 不支持 Web 验证提交` }; return; } await Promise.resolve(submit.call(adapter, account_id, type, data)); this.pendingVerifications.delete(`${platform}:${account_id}:${type}`); ctx.body = { success: true }; } catch (e) { const err = e; ctx.status = 500; ctx.body = { success: false, message: err?.message ?? '提交失败' }; } }); // 配置接口 this.router.get("/api/config", ctx => { ctx.body = fs.readFileSync(BaseApp.configPath, "utf8"); }); this.router.get("/api/config/schema", ctx => { ctx.body = getAppConfigSchema(); }); this.router.post("/api/config", async (ctx) => { try { const configContent = ctx.request.body; fs.writeFileSync(BaseApp.configPath, configContent, "utf8"); credentialsWereAutoGenerated = false; const backupResult = await this.backupDataToHf(configContent); if (!backupResult.success && backupResult.message) { this.logger?.warn?.(backupResult.message); } ctx.body = { success: true, message: "配置已保存" }; } catch (e) { ctx.status = 500; ctx.body = { success: false, message: e.message }; } }); // 站点根静态文件(public_static_dir):列表 / 上传 / 删除(需已配置并保存 public_static_dir) this.router.get("/api/public-static/files", (ctx) => { const root = this.getPublicStaticRoot(); if (!root) { ctx.status = 400; ctx.body = { success: false, message: '请先在基础配置中设置 public_static_dir 并保存配置', }; return; } try { const names = fs .readdirSync(root, { withFileTypes: true }) .filter((d) => d.isFile()) .map((d) => d.name) .sort((a, b) => a.localeCompare(b)); ctx.body = { success: true, files: names, root }; } catch (e) { ctx.status = 500; ctx.body = { success: false, message: e.message }; } }); this.router.post("/api/public-static/upload", async (ctx) => { const root = this.getPublicStaticRoot(); if (!root) { ctx.status = 400; ctx.body = { success: false, message: '请先在基础配置中设置 public_static_dir 并保存配置', }; return; } const file = pickPublicStaticUpload(ctx.request.files); if (!file?.filepath) { ctx.status = 400; ctx.body = { success: false, message: '缺少上传文件(字段名 file)' }; return; } const safeName = sanitizePublicStaticBasename(file.originalFilename ?? file.newFilename); if (!safeName) { try { fs.unlinkSync(file.filepath); } catch { /* 忽略临时文件清理失败 */ } ctx.status = 400; ctx.body = { success: false, message: '非法或无法识别的文件名' }; return; } const dest = path.join(root, safeName); const tmpPath = file.filepath; try { fs.copyFileSync(tmpPath, dest); ctx.body = { success: true, message: '上传成功', filename: safeName }; const hf = await this.backupDataDirToHfAfterStaticChange(); if (hf.attempted) { ctx.body.hf_backup = hf; } } catch (e) { ctx.status = 500; ctx.body = { success: false, message: e.message }; } finally { try { fs.unlinkSync(tmpPath); } catch { /* 忽略 */ } } }); this.router.delete("/api/public-static/:filename", async (ctx) => { const root = this.getPublicStaticRoot(); if (!root) { ctx.status = 400; ctx.body = { success: false, message: '请先在基础配置中设置 public_static_dir 并保存配置', }; return; } const safeName = sanitizePublicStaticBasename(ctx.params.filename ?? ''); if (!safeName) { ctx.status = 400; ctx.body = { success: false, message: '非法文件名' }; return; } const resolvedRoot = path.resolve(root); const target = path.join(root, safeName); const rel = path.relative(resolvedRoot, path.resolve(target)); if (rel.startsWith('..') || path.isAbsolute(rel) || rel === '') { ctx.status = 400; ctx.body = { success: false, message: '路径非法' }; return; } try { if (!fs.existsSync(target) || !fs.statSync(target).isFile()) { ctx.status = 404; ctx.body = { success: false, message: '文件不存在' }; return; } fs.unlinkSync(target); ctx.body = { success: true, message: '已删除' }; const hf = await this.backupDataDirToHfAfterStaticChange(); if (hf.attempted) { ctx.body.hf_backup = hf; } } catch (e) { ctx.status = 500; ctx.body = { success: false, message: e.message }; } }); // 账号管理端点 this.router.get("/api/list", ctx => { ctx.body = this.accounts.map(bot => bot.info); }); this.router.post("/api/add", (ctx) => { const config = ctx.request.body; try { this.addAccount(config); ctx.body = { success: true, message: '添加成功' }; } catch (e) { ctx.status = 500; ctx.body = { success: false, message: e.message }; } }); this.router.post("/api/edit", (ctx) => { const config = ctx.request.body; try { this.updateAccount(config); ctx.body = { success: true, message: '修改成功' }; } catch (e) { ctx.status = 500; ctx.body = { success: false, message: e.message }; } }); this.router.get("/api/remove", ctx => { const { uin, platform, force } = ctx.request.query; try { this.removeAccount(String(platform), String(uin), Boolean(force)); ctx.body = { success: true, message: '移除成功' }; } catch (e) { ctx.status = 500; ctx.body = { success: false, message: e.message }; } }); this.router.post("/api/bots/start", async (ctx) => { const { platform, uin } = ctx.request.body; try { const adapter = this.adapters.get(platform); await adapter?.setOnline(uin); ctx.body = { success: true, data: adapter?.getAccount(uin)?.info }; } catch (e) { ctx.status = 500; ctx.body = { success: false, message: e.message }; } }); this.router.post("/api/bots/stop", async (ctx) => { const { platform, uin } = ctx.request.body; try { const adapter = this.adapters.get(platform); await adapter?.setOffline(uin); ctx.body = { success: true, data: adapter?.getAccount(uin)?.info }; } catch (e) { ctx.status = 500; ctx.body = { success: false, message: e.message }; } }); // 静态文件服务 if (fs.existsSync(client)) { this.use(koaStatic(client)); // SPA fallback:仅对已知前端路由返回 index.html;协议与 adapter 提供的路径(如 /platform/accountId/...、.../webhook)一律 next,由 router 处理 const spaPathRegex = /^\/(login|bots|config|system|terminal|logs)(\/.*)?$/; this.use(async (ctx, next) => { if (ctx.method !== 'HEAD' && ctx.method !== 'GET') return next(); const p = ctx.path; const isSpaRoute = p === '/' || spaPathRegex.test(p); if (!isSpaRoute) return next(); ctx.type = 'html'; ctx.body = fs.readFileSync(path.join(client, 'index.html')); }); } // 调用父类的 start await super.start(); } } (function (App) { App.defaultConfig = { ...BaseApp.defaultConfig, }; function registerGeneral(key, config) { App.defaultConfig.general = { ...App.defaultConfig.general, [key]: config }; } App.registerGeneral = registerGeneral; async function safeImport(name) { try { return await import(name); } catch { } } async function loadAdapterFactory(platform, maybeNames = [ `@onebots/adapter-${platform}`, `onebots-adapter-${platform}`, platform ]) { if (!maybeNames.length) return false; const modName = maybeNames.shift(); try { require(modName); return true; } catch (e) { console.warn(`[onebots] Failed to load adapter ${modName}: ${e}`); return loadAdapterFactory(platform, maybeNames); } } App.loadAdapterFactory = loadAdapterFactory; async function loadProtocolFactory(name, maybeNames = [ `@onebots/protocol-${name}`, `onebots-protocol-${name}`, `${name}` ]) { if (!maybeNames.length) return false; const modName = maybeNames.shift(); try { require(modName); return true; } catch (e) { console.warn(`[onebots] Failed to load protocol ${modName}: ${e}`); return loadProtocolFactory(name, maybeNames); } } App.loadProtocolFactory = loadProtocolFactory; })(App || (App = {})); export function createOnebots(config = "config.yaml") { const isStartWithConfigFile = typeof config === "string"; if (isStartWithConfigFile) { config = path.resolve(process.cwd(), config); BaseApp.configDir = path.dirname(config); } if (!existsSync(BaseApp.configDir)) mkdirSync(BaseApp.configDir); if (!existsSync(BaseApp.configPath) && isStartWithConfigFile) { copyFileSync(path.resolve(import.meta.dirname, "./config.sample.yaml"), BaseApp.configPath); console.log("[onebots] 已创建默认配置文件:", BaseApp.configPath); } if (!isStartWithConfigFile) { writeFileSync(BaseApp.configPath, yaml.dump(config)); console.log(`已自动保存配置到:${BaseApp.configPath}`); } if (!existsSync(BaseApp.dataDir)) { mkdirSync(BaseApp.dataDir); console.log("已为你创建数据存储目录", BaseApp.dataDir); } config = yaml.load(readFileSync(BaseApp.configPath, "utf8")); const hasAccessToken = !!config.access_token?.trim(); if ((!config.username || !config.password) && !hasAccessToken) { const generatedUser = "onebots_" + randomBytes(4).toString("hex"); const generatedPass = randomBytes(16).toString("hex"); config.username = generatedUser; config.password = generatedPass; writeFileSync(BaseApp.configPath, yaml.dump(config)); credentialsWereAutoGenerated = true; console.log("[onebots] 已自动生成管理端账号并写入配置文件,请尽快在 Web 端修改密码:"); console.log(" 用户名:", generatedUser); console.log(" 密码:", generatedPass); console.log(" 配置文件:", BaseApp.configPath); } configure({ appenders: { out: { type: "stdout", layout: { type: "colored" }, }, files: { type: "file", maxLogSize: 1024 * 1024 * 50, filename: BaseApp.logFile, }, }, categories: { default: { appenders: ["out", "files"], level: config.log_level || "info", }, }, disableClustering: true, }); // if (cp) process.on("disconnect", () => cp.kill()); return new App(config); } export function defineConfig(config) { return config; } //# sourceMappingURL=app.js.map