onebots
Version:
OneBots 整合适配器和协议,提供HTTP/WebSocket服务
1,164 lines • 49.3 kB
JavaScript
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 = [
`/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 = [
`/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