码桶
发现社区成员的开源项目
api.js50.8 KB
/**
* API 路由 — 对应 PHP api.php
* 所有 JSON 接口在此定义
*/
const express = require('express');
const multer = require('multer');
const path = require('path');
const crypto = require('crypto');
const os = require('os');
const fs = require('fs');
const Auth = require('../lib/auth');
const Bot = require('../lib/bot');
const ILink = require('../lib/ilink');
const db = require('../lib/db');
const settings = require('../lib/settings');
const cron = require('../lib/cron');
const config = require('../config');
const logger = require('../lib/logger');
const proxy = require('../lib/proxy');
const router = express.Router();
const upload = multer({
dest: os.tmpdir(),
limits: { fileSize: 50 * 1024 * 1024 }, // 50MB
});
// ========== 辅助函数 ==========
/** 获取当前用户并校验 bot 归属 */
async function ownsBot(req, res) {
const u = await Auth.currentUser(req);
if (!u) { res.status(401).json({ ok: false, msg: '未登录' }); return null; }
const botId = parseInt(req.body.bot_id || req.query.bot_id || 0, 10);
if (!botId) { res.status(400).json({ ok: false, msg: '缺少 bot_id' }); return null; }
const bot = await Bot.owned(botId, u.id);
if (!bot) { res.status(403).json({ ok: false, msg: '机器人不存在或无权访问' }); return null; }
return { user: u, bot };
}
// ========== 拆分 multipart 和 JSON 请求 ==========
function parseBody(req, res, next) {
const ct = req.get('Content-Type') || '';
if (ct.includes('multipart/form-data')) return next();
// JSON body
if (req.body && typeof req.body === 'object') return next();
next();
}
// ========== 路由 ==========
/** 登录:校验管理员账号密码,写入 session */
router.post('/login', async (req, res) => {
const { username, password } = req.body || {};
if (!username || !password) return res.status(400).json({ ok: false, msg: '请输入账号和密码' });
const r = await Auth.login(String(username), String(password));
if (!r.ok) return res.status(401).json({ ok: false, msg: r.msg });
req.session.user_id = r.user.id;
res.json({ ok: true, id: r.user.id, username: r.user.username, is_admin: r.user.is_admin });
});
/** 登出 */
router.post('/logout', async (req, res) => {
if (req.session) {
try { await new Promise((resolve) => req.session.destroy(resolve)); } catch (e) {}
}
res.json({ ok: true });
});
/** 获取当前用户信息(开源版:始终为本地管理员) */
router.post('/me', async (req, res) => {
const u = await Auth.currentUser(req);
if (!u) return res.status(401).json({ ok: false, msg: '未登录' });
const bots = await Bot.list(u.id);
res.json({
ok: true,
id: u.id,
username: u.username,
email: u.email || '',
created_at: u.created_at || null,
is_admin: 1,
user_code: u.user_code || '',
ai_persona: u.ai_persona || '',
companion_enabled: u.companion_enabled ? 1 : 0,
bots,
});
});
/** 主动陪伴:获取/设置用户是否开启(默认关闭,需用户主动开启) */
router.post('/companion', async (req, res) => {
const u = await Auth.currentUser(req);
if (!u) return res.status(401).json({ ok: false, msg: '未登录' });
if (req.body && typeof req.body.enabled !== 'undefined') {
const enabled = req.body.enabled ? 1 : 0;
await db.exec('UPDATE users SET companion_enabled = ? WHERE id = ?', [enabled, u.id]);
return res.json({ ok: true, companion_enabled: enabled });
}
const cur = await db.row('SELECT companion_enabled FROM users WHERE id = ?', [u.id]);
res.json({ ok: true, companion_enabled: cur ? (cur.companion_enabled ? 1 : 0) : 0 });
});
// ========== 自动化设置(机器人定时任务 / 提醒) ==========
let reminderReady = false;
async function ensureReminderTable() {
if (reminderReady) return;
try {
// 建表时直接包含 enabled 列,避免依赖插件懒建表导致「表不存在」而挂起
await db.exec(`CREATE TABLE IF NOT EXISTS reminders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
bot_id INTEGER NOT NULL,
peer_id VARCHAR(128) NOT NULL,
context_token VARCHAR(256),
content TEXT NOT NULL,
remind_at INTEGER NOT NULL,
repeat_type VARCHAR(16) NOT NULL DEFAULT 'once',
repeat_rule VARCHAR(64) DEFAULT '',
created_at INTEGER NOT NULL,
fired INTEGER NOT NULL DEFAULT 0,
enabled INTEGER NOT NULL DEFAULT 1
)`);
// 兼容旧库:CREATE TABLE IF NOT EXISTS 不会修改已存在表的结构,
// 逐列补齐(缺失才 ALTER),避免「no such column」类错误导致接口 500
const cols = await db.rows('PRAGMA table_info(reminders)');
const have = new Set(cols.map(c => c.name));
const expect = [
['bot_id', 'bot_id INTEGER NOT NULL'],
['peer_id', 'peer_id VARCHAR(128) NOT NULL'],
['context_token', 'context_token VARCHAR(256)'],
['content', 'content TEXT NOT NULL'],
['remind_at', 'remind_at INTEGER NOT NULL'],
['repeat_type', 'repeat_type VARCHAR(16) NOT NULL DEFAULT \'once\''],
['repeat_rule', 'repeat_rule VARCHAR(64) DEFAULT \'\''],
['created_at', 'created_at INTEGER NOT NULL'],
['fired', 'fired INTEGER NOT NULL DEFAULT 0'],
['enabled', 'enabled INTEGER NOT NULL DEFAULT 1'],
];
for (const [name, ddl] of expect) {
if (!have.has(name)) {
await db.exec(`ALTER TABLE reminders ADD COLUMN ${ddl}`);
}
}
} catch (e) {
console.error('[automation] 表修复失败:', e.message);
}
reminderReady = true;
}
/** 列表:返回某个机器人全部定时任务(含机器人对话创建的) */
router.post('/automation/list', async (req, res) => {
const ctx = await ownsBot(req, res);
if (!ctx) return;
await ensureReminderTable();
try {
const list = await db.rows(
`SELECT id, bot_id, peer_id, content, remind_at, repeat_type, repeat_rule, created_at, fired, COALESCE(enabled,1) AS enabled
FROM reminders WHERE bot_id=? ORDER BY remind_at ASC`,
[ctx.bot.id]
);
res.json({ ok: true, items: list });
} catch (e) {
res.json({ ok: false, msg: e.message });
}
});
/** 目标会话列表:取该机器人最近的入站会话,供网页端选择提醒发往哪里 */
router.post('/automation/peers', async (req, res) => {
const ctx = await ownsBot(req, res);
if (!ctx) return;
const rows = await db.rows(
"SELECT peer_id, content FROM messages WHERE bot_id=? AND direction='in' AND peer_id IS NOT NULL AND peer_id!='' ORDER BY id DESC LIMIT 500",
[ctx.bot.id]
);
const seen = new Set();
const peers = [];
for (const r of rows) {
if (seen.has(r.peer_id)) continue;
seen.add(r.peer_id);
peers.push({ peer_id: r.peer_id, last_msg: (r.content || '').toString().slice(0, 40) });
if (peers.length >= 30) break;
}
res.json({ ok: true, peers });
});
/** 新增定时任务 */
router.post('/automation/add', async (req, res) => {
const ctx = await ownsBot(req, res);
if (!ctx) return;
await ensureReminderTable();
try {
const content = (req.body.content || '').toString().trim();
if (!content) return res.status(400).json({ ok: false, msg: '内容不能为空' });
const now = Math.floor(Date.now() / 1000);
let rt = ['once', 'daily', 'weekly', 'interval', 'cron'].includes(req.body.repeat_type) ? req.body.repeat_type : 'once';
let rr = (req.body.repeat_rule || '').toString().slice(0, 64);
let ts = parseInt(req.body.remind_at, 10);
// 间隔模式: rule 形如 "min:5" / "hour:2"
if (rt === 'interval') {
const im = (rr || '').match(/^(min|hour):(\d+)$/);
if (!im) return res.status(400).json({ ok: false, msg: '间隔格式应为 min:5 或 hour:2(分钟/小时:数值)' });
const val = parseInt(im[2], 10);
if (val < 1 || val > 10080) return res.status(400).json({ ok: false, msg: '间隔需在 1~10080 之间(分钟)' });
ts = now + (im[1] === 'min' ? val * 60 : val * 3600);
rr = im[1] + ':' + val;
} else if (rt === 'cron') {
// 定时模式: rule 为 5 字段 cron
if (!rr) return res.status(400).json({ ok: false, msg: '请填写 cron 表达式,如 */5 * * * *' });
try {
const nxt = cron.nextCronTime(rr, now);
if (!nxt) return res.status(400).json({ ok: false, msg: 'cron 表达式在 4 年内无匹配时间' });
ts = nxt;
} catch (e) {
return res.status(400).json({ ok: false, msg: 'cron 表达式错误: ' + e.message });
}
}
if (!ts || ts <= 0) return res.status(400).json({ ok: false, msg: '请设置有效的时间' });
const pid = (req.body.peer_id || '').toString().slice(0, 128);
const en = req.body.enabled === 0 || req.body.enabled === '0' ? 0 : 1;
await db.exec(
`INSERT INTO reminders (bot_id, peer_id, context_token, content, remind_at, repeat_type, repeat_rule, created_at, fired, enabled)
VALUES (?,?,?,?,?,?,?,?,0,?)`,
[ctx.bot.id, pid, '', content, ts, rt, rr, now, en]
);
res.json({ ok: true });
} catch (e) {
res.json({ ok: false, msg: e.message });
}
});
/** 修改定时任务 */
router.post('/automation/update', async (req, res) => {
const ctx = await ownsBot(req, res);
if (!ctx) return;
await ensureReminderTable();
try {
const id = parseInt(req.body.id, 10);
if (!id) return res.status(400).json({ ok: false, msg: '缺少 id' });
const exist = await db.row('SELECT * FROM reminders WHERE id=? AND bot_id=?', [id, ctx.bot.id]);
if (!exist) return res.status(404).json({ ok: false, msg: '任务不存在' });
const now = Math.floor(Date.now() / 1000);
const fields = [];
const params = [];
if (req.body.content !== undefined) {
const c = (req.body.content || '').toString().trim();
if (!c) return res.status(400).json({ ok: false, msg: '内容不能为空' });
fields.push('content=?'); params.push(c);
}
if (req.body.remind_at !== undefined) {
const ts = parseInt(req.body.remind_at, 10);
if (!ts || ts <= 0) return res.status(400).json({ ok: false, msg: '请设置有效的时间' });
fields.push('remind_at=?'); params.push(ts);
fields.push('fired=0'); // 改期后重置触发状态
}
const rtChanged = req.body.repeat_type !== undefined;
const rrChanged = req.body.repeat_rule !== undefined;
let rt = exist.repeat_type;
if (rtChanged) {
rt = ['once', 'daily', 'weekly', 'interval', 'cron'].includes(req.body.repeat_type) ? req.body.repeat_type : 'once';
fields.push('repeat_type=?'); params.push(rt);
}
let rr = exist.repeat_rule || '';
if (rrChanged) {
rr = (req.body.repeat_rule || '').toString().slice(0, 64);
fields.push('repeat_rule=?'); params.push(rr);
}
// 间隔 / 定时模式:类型或规则变更时重算下一次触发时间并重置触发状态
if ((rtChanged || rrChanged) && (rt === 'interval' || rt === 'cron')) {
if (rt === 'interval') {
const im = (rr || '').match(/^(min|hour):(\d+)$/);
if (!im) return res.status(400).json({ ok: false, msg: '间隔格式应为 min:5 或 hour:2(分钟/小时:数值)' });
const val = parseInt(im[2], 10);
if (val < 1 || val > 10080) return res.status(400).json({ ok: false, msg: '间隔需在 1~10080 之间(分钟)' });
rr = im[1] + ':' + val;
// 确保 repeat_rule 字段存在并写入规范化值
const ri = fields.indexOf('repeat_rule=?');
if (ri >= 0) params[ri] = rr; else { fields.push('repeat_rule=?'); params.push(rr); }
fields.push('remind_at=?'); params.push(now + (im[1] === 'min' ? val * 60 : val * 3600));
fields.push('fired=0');
} else {
if (!rr) return res.status(400).json({ ok: false, msg: '请填写 cron 表达式,如 */5 * * * *' });
let nxt;
try {
nxt = cron.nextCronTime(rr, now);
} catch (e) {
return res.status(400).json({ ok: false, msg: 'cron 表达式错误: ' + e.message });
}
if (!nxt) return res.status(400).json({ ok: false, msg: 'cron 表达式在 4 年内无匹配时间' });
fields.push('remind_at=?'); params.push(nxt);
fields.push('fired=0');
}
}
if (req.body.peer_id !== undefined) {
fields.push('peer_id=?'); params.push((req.body.peer_id || '').toString().slice(0, 128));
}
if (req.body.enabled !== undefined) {
fields.push('enabled=?'); params.push(req.body.enabled === 0 || req.body.enabled === '0' ? 0 : 1);
}
if (!fields.length) return res.json({ ok: true });
params.push(id);
await db.exec('UPDATE reminders SET ' + fields.join(', ') + ' WHERE id=?', params);
res.json({ ok: true });
} catch (e) {
res.json({ ok: false, msg: e.message });
}
});
/** 删除定时任务 */
router.post('/automation/delete', async (req, res) => {
const ctx = await ownsBot(req, res);
if (!ctx) return;
try {
const id = parseInt(req.body.id, 10);
if (!id) return res.status(400).json({ ok: false, msg: '缺少 id' });
await db.exec('DELETE FROM reminders WHERE id=? AND bot_id=?', [id, ctx.bot.id]);
res.json({ ok: true });
} catch (e) {
res.json({ ok: false, msg: e.message });
}
});
/** 启用 / 暂停 */
router.post('/automation/toggle', async (req, res) => {
const ctx = await ownsBot(req, res);
if (!ctx) return;
await ensureReminderTable();
try {
const id = parseInt(req.body.id, 10);
if (!id) return res.status(400).json({ ok: false, msg: '缺少 id' });
const en = req.body.enabled === 0 || req.body.enabled === '0' ? 0 : 1;
await db.exec('UPDATE reminders SET enabled=? WHERE id=? AND bot_id=?', [en, id, ctx.bot.id]);
res.json({ ok: true });
} catch (e) {
res.json({ ok: false, msg: e.message });
}
});
/** 已启用插件列表:供自动化表单「调用插件」时选择/插入指令 */
router.post('/automation/plugins', async (req, res) => {
const ctx = await ownsBot(req, res);
if (!ctx) return;
try {
const pluginsLib = require('../lib/plugins');
const mods = await pluginsLib.getEnabledModules(ctx.bot.id);
const list = mods
.map((m) => {
const id = m.meta?.id || '';
const usage = m.meta?.usage || (pluginsLib.BUILTIN_USAGE && pluginsLib.BUILTIN_USAGE[id]) || '';
// 从用法说明中提取首个「指令」示例,便于一键插入
const mq = usage.match(/「([^」]+)」/);
const command = mq ? mq[1] : '';
return { id, name: m.meta?.name || id, usage, command };
})
.filter((p) => p.id && p.id !== 'reply' && p.id !== 'reminder');
res.json({ ok: true, plugins: list });
} catch (e) {
res.json({ ok: true, plugins: [] });
}
});
/** AI 接口配置(个人版:前台「AI 配置」页读取/保存,无需后台) */
const AI_KEYS = [
'ai_api_base', 'ai_api_key', 'ai_model',
'ai_api_base_2', 'ai_api_key_2', 'ai_model_2',
'ai_api_base_3', 'ai_api_key_3', 'ai_model_3',
'ai_strategy', 'ai_endpoint', 'ai_completion_url', 'ai_reasoning_effort', 'ai_think_mode',
'ai_system_prompt', 'ai_max_tokens', 'ai_temperature',
'ai_stt_mode', 'ai_stt_model', 'ai_transcribe_model',
'ai_stt_mimo_api_key', 'ai_stt_mimo_base', 'ai_stt_mimo_model', 'ai_stt_mimo_lang',
'ai_endpoints', 'ai_endpoint_strategy',
'image_gen_api_base', 'image_gen_api_key', 'image_gen_model', 'image_gen_size', 'image_gen_enabled',
'stt_mode', 'stt_endpoint', 'stt_model', 'stt_hf_endpoint', 'stt_local_model'
];
router.post('/ai_settings', async (req, res) => {
const u = await Auth.currentUser(req);
if (!u) return res.status(401).json({ ok: false, msg: '未登录' });
try {
const out = {};
for (const k of AI_KEYS) out[k] = await settings.getSetting(k, '');
res.json({ ok: true, settings: out });
} catch (e) {
res.status(500).json({ ok: false, msg: '加载失败: ' + e.message });
}
});
router.post('/ai_settings_save', async (req, res) => {
const u = await Auth.currentUser(req);
if (!u) return res.status(401).json({ ok: false, msg: '未登录' });
try {
for (const k of AI_KEYS) {
if (req.body[k] !== undefined) await settings.setSetting(k, req.body[k] ?? '', 'admin');
}
res.json({ ok: true, msg: '已保存' });
} catch (e) {
res.status(500).json({ ok: false, msg: '保存失败: ' + e.message });
}
});
// ============ 代理服务器设置 ============
const PROXY_KEYS = ['proxy_enabled', 'proxy_ip', 'proxy_port', 'proxy_user', 'proxy_pass', 'proxy_protocol'];
router.post('/proxy_settings', async (req, res) => {
const u = await Auth.currentUser(req);
if (!u) return res.status(401).json({ ok: false, msg: '未登录' });
try {
const out = {};
for (const k of PROXY_KEYS) out[k] = await settings.getSetting(k, '');
if (!out.proxy_protocol) out.proxy_protocol = 'http';
res.json({ ok: true, settings: out });
} catch (e) {
res.status(500).json({ ok: false, msg: '加载失败: ' + e.message });
}
});
router.post('/proxy_settings_save', async (req, res) => {
const u = await Auth.currentUser(req);
if (!u) return res.status(401).json({ ok: false, msg: '未登录' });
try {
const body = req.body || {};
for (const k of PROXY_KEYS) {
if (body[k] !== undefined) await settings.setSetting(k, body[k] == null ? '' : String(body[k]));
}
const r = await proxy.applyProxy();
res.json({ ok: true, msg: '已保存并应用', proxy: r });
} catch (e) {
res.status(500).json({ ok: false, msg: '保存失败: ' + e.message });
}
});
router.post('/proxy_test', async (req, res) => {
const u = await Auth.currentUser(req);
if (!u) return res.status(401).json({ ok: false, msg: '未登录' });
try {
const r = await proxy.testProxy((req.body && req.body.test_url) || '');
res.json({ ok: r.ok, msg: r.msg, body: r.body || '', proxy: r.proxy });
} catch (e) {
res.status(500).json({ ok: false, msg: '测试失败: ' + e.message });
}
});
/** 修改管理员密码(个人版:前台个人中心调用,无需后台登录) */
router.post('/change_password', async (req, res) => {
const u = await Auth.currentUser(req);
if (!u) return res.status(401).json({ ok: false, msg: '未登录' });
const { old_password, new_password } = req.body || {};
try {
const r = await Auth.changePassword(old_password, new_password);
res.json(r);
} catch (e) {
res.status(500).json({ ok: false, msg: '修改失败: ' + e.message });
}
});
/** 创建机器人(限制:一个用户只能绑定一个机器人) */
router.post('/bot_create', async (req, res) => {
const u = await Auth.currentUser(req);
if (!u) return res.status(401).json({ ok: false, msg: '未登录' });
// 一个用户仅允许一个机器人,避免重复创建
const existing = await Bot.list(u.id);
if (existing && existing.length > 0) {
return res.status(400).json({ ok: false, msg: '每个账号仅可绑定一个机器人,请先解除已有绑定后再操作' });
}
const bot = await Bot.create(u.id);
res.json({ ok: true, id: bot.id, bot_code: bot.bot_code, name: bot.name });
});
/** 修改机器人名称(仅本人) */
router.post('/bot_update', async (req, res) => {
const u = await Auth.currentUser(req);
if (!u) return res.status(401).json({ ok: false, msg: '未登录' });
const botId = parseInt(req.body.bot_id || 0, 10);
const bot = await Bot.owned(botId, u.id);
if (!bot) return res.status(403).json({ ok: false, msg: '机器人不存在或无权访问' });
const name = String(req.body.name || '').trim();
if (!name) return res.status(400).json({ ok: false, msg: '名称不能为空' });
if (name.length > 64) return res.status(400).json({ ok: false, msg: '名称不能超过 64 个字符' });
await db.exec('UPDATE bots SET name=? WHERE id=?', [name, botId]);
res.json({ ok: true });
});
/** 获取绑定二维码 */
router.post('/bot_qrcode', async (req, res) => {
const result = await ownsBot(req, res);
if (!result) return;
const { bot } = result;
const il = new ILink(bot);
const resp = await il.getQrcode();
if ((resp.ret ?? -1) !== 0) {
return res.status(400).json({ ok: false, msg: '获取二维码失败: ' + JSON.stringify(resp) });
}
const qrcode = resp.qrcode || '';
// 真正要编码进二维码的内容是 qrcode_img_content(微信可识别的 liteapp 绑定链接),
// 而 qrcode 只是轮询状态用的 token——之前误把 token 编进二维码导致"只有一串字母"。
const qrUrl = resp.qrcode_img_content || null;
const img = resp.qrcode_img_content || null;
const base = resp.baseurl || resp.base_url || bot.base_url || null;
const now = Math.floor(Date.now() / 1000);
await db.exec(
'UPDATE bots SET qrcode=?, base_url=?, login_status=?, qr_content=?, updated_at=? WHERE id=?',
[qrcode, base, 'wait', JSON.stringify(resp), now, bot.id]
);
res.json({ ok: true, qrcode, qrcode_url: qrUrl, qrcode_img: img, raw: resp, bot_id: bot.id });
});
/** 轮询扫码状态 */
router.post('/bot_status', async (req, res) => {
const result = await ownsBot(req, res);
if (!result) return;
const { bot } = result;
// 已绑定成功的机器人直接返回 confirmed,不再轮询,避免并发/迟滞响应把状态降级回 wait
if (bot.login_status === 'confirmed') {
return res.json({ ok: true, status: 'confirmed', bot_id: bot.id });
}
const il = new ILink(bot);
const resp = await il.getQrcodeStatus(bot.qrcode);
const status = resp.status || bot.login_status;
const now = Math.floor(Date.now() / 1000);
const upd = { login_status: status, updated_at: now };
if (status === 'confirmed') {
upd.bot_token = resp.bot_token || bot.bot_token || null;
upd.base_url = resp.baseurl || resp.base_url || bot.base_url || null;
upd.wechat_uin = resp.ilink_user_id || resp.wechat_uin || bot.wechat_uin || null;
upd.bind_at = now;
}
const setClause = Object.keys(upd).map(k => `${k} = ?`).join(', ');
const values = [...Object.values(upd), bot.id];
// 已 confirmed 不允许被后续 wait/expired 响应覆盖降级(防止并发轮询乱序覆盖)
await db.exec(`UPDATE bots SET ${setClause} WHERE id = ? AND login_status != 'confirmed'`, values);
res.json({ ok: true, status, bot_id: bot.id });
});
/** 发送文本消息 */
router.post('/bot_send', async (req, res) => {
const result = await ownsBot(req, res);
if (!result) return;
const { bot } = result;
if (bot.login_status !== 'confirmed' || !bot.bot_token) {
return res.status(400).json({ ok: false, msg: '机器人未绑定或未登录' });
}
const text = (req.body.content || '').trim();
if (!text) return res.status(400).json({ ok: false, msg: '消息内容不能为空' });
let peer = (req.body.peer_id || '').trim();
let ctx;
// 解析路由令牌:优先用该会话最近一次入站消息的 token + peer,其次用 worker 维护的最新 token
async function resolveCtx() {
if (peer) {
const last = await db.row(
'SELECT context_token FROM messages WHERE bot_id = ? AND peer_id = ? AND content IS NOT NULL AND content != ? AND context_token IS NOT NULL ORDER BY id DESC LIMIT 1',
[bot.id, peer, '']
);
return last ? last.context_token : (bot.context_token || null);
}
// peer 为空:从最近一条【有真实内容】的入站消息取出 peer_id 与 token
// (to_user_id 必填,否则 iLink 返回 ret:-2;过滤空消息避免取到无效令牌)
const last = await db.row(
'SELECT context_token, peer_id FROM messages WHERE bot_id = ? AND direction = ? AND content IS NOT NULL AND content != ? AND context_token IS NOT NULL ORDER BY id DESC LIMIT 1',
[bot.id, 'in', '']
);
if (last) {
peer = last.peer_id;
return last.context_token;
}
if (bot.context_token) return bot.context_token;
return null;
}
ctx = await resolveCtx();
if (!ctx) {
return res.status(400).json({ ok: false, msg: '暂无会话可回复,请等待微信消息' });
}
const il = new ILink(bot);
let resp = await il.sendMessage(peer, ctx, text);
// 令牌可能过期:用最新入站消息的 token + peer 重试一次
if ((resp.ret ?? -1) !== 0) {
const fresh = await db.row(
'SELECT context_token, peer_id FROM messages WHERE bot_id = ? AND direction = ? AND context_token IS NOT NULL ORDER BY id DESC LIMIT 1',
[bot.id, 'in']
);
if (fresh && fresh.context_token && (fresh.context_token !== ctx || (fresh.peer_id && fresh.peer_id !== peer))) {
ctx = fresh.context_token;
if (fresh.peer_id) peer = fresh.peer_id;
resp = await il.sendMessage(peer, ctx, text);
}
}
const now = Math.floor(Date.now() / 1000);
if ((resp.ret ?? -1) !== 0) {
// 会话超时(-14):微信只允许在对方发消息后的会话窗口内回复,令牌已过期,无法主动发送
const isSessionTimeout = resp.errcode === -14 || resp.ret === -14;
const friendlyMsg = isSessionTimeout
? '会话已超时:微信仅允许在对方最近一次发消息后的一段时间内回复,请等对方再发一条消息后重试。'
: '发送失败: ' + JSON.stringify(resp);
// 发送失败,入库并标记
await db.exec(
'INSERT INTO messages (bot_id, direction, peer_id, content, msg_type, context_token, status, error_msg, created_at) VALUES (?,?,?,?,?,?,?,?,?)',
[bot.id, 'out', peer, text, 'text', ctx, 'failed', 'iLink 返回: ' + JSON.stringify(resp), now]
);
return res.status(400).json({ ok: false, msg: friendlyMsg });
}
await db.exec(
'INSERT INTO messages (bot_id, direction, peer_id, content, msg_type, context_token, status, created_at) VALUES (?,?,?,?,?,?,?,?)',
[bot.id, 'out', peer, text, 'text', ctx, 'ok', now]
);
res.json({ ok: true, sent: true });
});
/** 获取消息列表 */
router.post('/bot_messages', async (req, res) => {
const result = await ownsBot(req, res);
if (!result) return;
const { bot } = result;
const msgs = await db.rows(
'SELECT id, direction, peer_id, content, msg_type, status, error_msg, created_at FROM messages WHERE bot_id = ? ORDER BY id DESC LIMIT 50',
[bot.id]
);
const peers = await db.rows(
'SELECT peer_id, MAX(id) AS mid, COUNT(*) AS cnt FROM messages WHERE bot_id = ? AND peer_id IS NOT NULL GROUP BY peer_id',
[bot.id]
);
res.json({ ok: true, bot_id: bot.id, messages: msgs.reverse(), peers });
});
/** 获取消息处理事件日志(前端控制台实时显示处理流水线) */
router.post('/bot_events', async (req, res) => {
const result = await ownsBot(req, res);
if (!result) return;
const { bot } = result;
const sinceId = parseInt(req.body.since_id, 10) || 0;
const msgEvents = require('../lib/msg-events');
const data = msgEvents.list(bot.id, sinceId);
res.json({ ok: true, ...data });
});
/** 解除绑定 */
router.post('/bot_unbind', async (req, res) => {
const result = await ownsBot(req, res);
if (!result) return;
const { bot } = result;
await db.exec(
'UPDATE bots SET bot_token=NULL, login_status=?, qrcode=NULL, base_url=NULL, wechat_uin=NULL, context_token=NULL, upd_buf=NULL, typing_ticket=NULL, bind_at=NULL, updated_at=? WHERE id=?',
['none', Math.floor(Date.now() / 1000), bot.id]
);
res.json({ ok: true });
});
/** 上传文件并发送 */
router.post('/bot_upload', upload.single('file'), async (req, res) => {
const result = await ownsBot(req, res);
if (!result) return;
const { bot } = result;
if (bot.login_status !== 'confirmed' || !bot.bot_token) {
return res.status(400).json({ ok: false, msg: '机器人未绑定或未登录' });
}
if (!req.file) return res.status(400).json({ ok: false, msg: '请选择文件' });
const file = req.file;
const mime = file.mimetype || '';
// 复制一份到 public/uploads,供 Web 聊天面板直接展示真实图片/文件。
// 注意:微信端下发仍走 CDN 加密上传(见 uploadMediaToCdn),本地下发仅用于网页显示。
const UPLOAD_DIR = path.join(__dirname, '..', 'public', 'uploads');
let webUrl = '';
try {
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
const ext = path.extname(file.originalname || '') || '';
const storedName = Date.now() + '_' + crypto.randomBytes(6).toString('hex') + ext;
const storedPath = path.join(UPLOAD_DIR, storedName);
fs.copyFileSync(file.path, storedPath);
webUrl = '/uploads/' + storedName;
} catch (e) {
console.error('保存到 public/uploads 失败:', e.message, '| UPLOAD_DIR=', UPLOAD_DIR, '| file.path=', file.path);
webUrl = '';
}
// media_type 映射(UploadMediaType):image=1 video=2 file=3 voice=4
let mediaType = 3, kind = 'file';
if (mime.startsWith('image/')) { mediaType = 1; kind = 'image'; }
else if (mime.startsWith('video/')) { mediaType = 2; kind = 'video'; }
// 注意:微信 bot 不支持发送语音条(官方 openclaw-weixin SDK 未实现 sendVoice,
// 服务端 ret:0 但客户端不渲染)。音频统一按"文件"发送,用户点开即可播放。
// 直接以原始文件发送,无需转码
let uploadSrcPath = file.path;
// 获取 peer 和 context_token
let peer = (req.body.peer_id || '').trim();
let ctx;
if (!peer) {
const last = await db.row(
'SELECT context_token, peer_id FROM messages WHERE bot_id = ? AND direction = ? AND content IS NOT NULL AND content != ? AND context_token IS NOT NULL ORDER BY id DESC LIMIT 1',
[bot.id, 'in', '']
);
if (!last || !last.context_token || !last.peer_id) {
try { fs.unlinkSync(file.path); } catch (e) { /* ignore */ }
return res.status(400).json({ ok: false, msg: '暂无会话可回复,请等待微信消息' });
}
ctx = last.context_token;
peer = last.peer_id;
} else {
const last = await db.row(
'SELECT context_token FROM messages WHERE bot_id = ? AND peer_id = ? AND context_token IS NOT NULL ORDER BY id DESC LIMIT 1',
[bot.id, peer]
);
ctx = last ? last.context_token : null;
if (!ctx) {
try { fs.unlinkSync(file.path); } catch (e) { /* ignore */ }
return res.status(400).json({ ok: false, msg: '该会话缺少 context_token,无法路由' });
}
}
// 加密上传到 CDN(返回媒体描述,而非 url)
const il = new ILink(bot);
let mediaDesc;
try {
mediaDesc = await il.uploadMediaToCdn(uploadSrcPath, mediaType, peer);
} catch (e) {
try { fs.unlinkSync(uploadSrcPath); } catch (err) { /* ignore */ }
try { if (uploadSrcPath !== file.path) fs.unlinkSync(file.path); } catch (err) { /* ignore */ }
return res.status(400).json({ ok: false, msg: '上传到 CDN 失败: ' + e.message });
}
// 删除临时文件(原始上传文件 + 转码后的 AMR)
try { fs.unlinkSync(uploadSrcPath); } catch (e) { /* ignore */ }
try { if (uploadSrcPath !== file.path) fs.unlinkSync(file.path); } catch (e) { /* ignore */ }
// 发送对应类型消息
async function doSend() {
if (kind === 'image') return il.sendImage(peer, ctx, mediaDesc);
if (kind === 'video') return il.sendVideo(peer, ctx, mediaDesc);
return il.sendFile(peer, ctx, mediaDesc, file.originalname);
}
let sendResp = await doSend();
// 令牌可能过期:用最新入站消息的 token + peer 重试一次(媒体引用与 token 无关,可安全重试)
if ((sendResp.ret ?? -1) !== 0) {
const fresh = await db.row(
'SELECT context_token, peer_id FROM messages WHERE bot_id = ? AND direction = ? AND content IS NOT NULL AND content != ? AND context_token IS NOT NULL ORDER BY id DESC LIMIT 1',
[bot.id, 'in', '']
);
if (fresh && fresh.context_token && fresh.peer_id && (fresh.context_token !== ctx || fresh.peer_id !== peer)) {
ctx = fresh.context_token;
peer = fresh.peer_id;
sendResp = await doSend();
}
}
if ((sendResp.ret ?? -1) !== 0) {
return res.status(400).json({ ok: false, msg: '发送失败: ' + JSON.stringify(sendResp) });
}
const now = Math.floor(Date.now() / 1000);
const contentText = webUrl || file.originalname || '文件';
await db.exec(
'INSERT INTO messages (bot_id, direction, peer_id, content, msg_type, context_token, created_at) VALUES (?,?,?,?,?,?,?)',
[bot.id, 'out', peer, contentText, kind, ctx, now]
);
res.json({ ok: true, sent: true, type: kind });
});
/** 发送输入状态(正在输入…) */
router.post('/bot_typing', async (req, res) => {
const result = await ownsBot(req, res);
if (!result) return;
const { bot } = result;
if (bot.login_status !== 'confirmed' || !bot.bot_token) {
return res.status(400).json({ ok: false, msg: '机器人未绑定或未登录' });
}
const status = parseInt(req.body.status, 10);
if (status !== 1 && status !== 2) {
return res.status(400).json({ ok: false, msg: 'status 必须为 1(输入中) 或 2(取消)' });
}
let peer = (req.body.peer_id || '').trim();
let ctx = null;
if (!peer) {
// 未指定会话时,取最近一条入站消息的 peer 与 token(与 bot_send 一致)
const last = await db.row(
'SELECT context_token, peer_id FROM messages WHERE bot_id = ? AND direction = ? AND context_token IS NOT NULL ORDER BY id DESC LIMIT 1',
[bot.id, 'in']
);
if (!last || !last.peer_id) {
return res.status(400).json({ ok: false, msg: '暂无会话可发输入状态' });
}
peer = last.peer_id;
ctx = last.context_token;
} else {
const last = await db.row(
'SELECT context_token FROM messages WHERE bot_id = ? AND peer_id = ? AND context_token IS NOT NULL ORDER BY id DESC LIMIT 1',
[bot.id, peer]
);
ctx = last ? last.context_token : null;
}
const il = new ILink(bot);
const cfg = await il.getConfig(il.botUserId(), ctx || undefined);
const ticket = cfg && cfg.typing_ticket;
if (!ticket) {
return res.status(400).json({ ok: false, msg: '获取输入状态票据失败: ' + JSON.stringify(cfg) });
}
try {
await il.sendTyping(ticket, status, il.botUserId());
} catch (e) {
return res.status(400).json({ ok: false, msg: '发送输入状态失败: ' + e.message });
}
res.json({ ok: true });
});
/* ========== 账号信息 ========== */
/** 获取账号概览(开源版:仅基础信息) */
router.post('/account', async (req, res) => {
const u = await Auth.currentUser(req);
if (!u) return res.status(401).json({ ok: false, msg: '未登录' });
const user = await db.row('SELECT created_at, email, profile_url, ai_persona FROM users WHERE id=?', [u.id]);
if (!user) return res.status(404).json({ ok: false, msg: '用户不存在' });
res.json({
ok: true,
username: u.username,
email: user.email || '',
profile_url: user.profile_url || '',
ai_persona: user.ai_persona || '',
});
});
/** Token 用量热力图(按天,无记录日补 0) */
router.post('/token_heatmap', async (req, res) => {
const u = await Auth.currentUser(req);
if (!u) return res.status(401).json({ ok: false, msg: '未登录' });
const days = Math.min(366, Math.max(7, parseInt(req.body.days || '182', 10) || 182));
const rows = await db.rows('SELECT day, tokens FROM token_usage WHERE user_id = ?', [u.id]);
const map = {};
for (const r of rows) map[r.day] = (map[r.day] || 0) + (r.tokens || 0);
const today = new Date();
const list = [];
for (let i = days - 1; i >= 0; i--) {
const d = new Date(today);
d.setDate(today.getDate() - i);
const day = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
list.push({ day, tokens: map[day] || 0 });
}
res.json({ ok: true, days, data: list });
});
/** 设置 AI 人格 / 说话风格 */
router.post('/persona', async (req, res) => {
const u = await Auth.currentUser(req);
if (!u) return res.status(401).json({ ok: false, msg: '未登录' });
const persona = String(req.body.persona || '').slice(0, 64);
await db.exec('UPDATE users SET ai_persona = ? WHERE id = ?', [persona, u.id]);
res.json({ ok: true, persona });
});
/** 更新个人资料(个人网站等) */
router.post('/update_profile', async (req, res) => {
const u = await Auth.currentUser(req);
if (!u) return res.status(401).json({ ok: false, msg: '未登录' });
const { profile_url } = req.body;
const url = String(profile_url || '').trim();
if (url && !/^https?:\/\/.+/i.test(url)) {
return res.status(400).json({ ok: false, msg: '请输入有效的网址(以 http:// 或 https:// 开头)' });
}
await db.exec('UPDATE users SET profile_url=? WHERE id=?', [url, u.id]);
res.json({ ok: true, msg: '保存成功', profile_url: url });
});
/** 清空指定机器人的聊天记录 */
router.post('/bot_clear_messages', async (req, res) => {
const u = await Auth.currentUser(req);
if (!u) return res.status(401).json({ ok: false, msg: '未登录' });
const botId = parseInt(req.body.bot_id || 0, 10);
if (!botId) return res.status(400).json({ ok: false, msg: '缺少 bot_id' });
const bot = await Bot.owned(botId, u.id);
if (!bot) return res.status(403).json({ ok: false, msg: '机器人不存在或无权访问' });
await db.exec('DELETE FROM messages WHERE bot_id = ?', [botId]);
res.json({ ok: true, msg: '聊天记录已清空' });
});
/** 下载/预览微信发来的媒体(从 CDN 拉取加密文件 → AES 解密 → 返回原始媒体) */
router.get('/bot_media', async (req, res) => {
const u = await Auth.currentUser(req);
if (!u) return res.status(401).json({ ok: false, msg: '未登录' });
const botId = parseInt(req.query.bot_id || 0, 10);
const msgId = parseInt(req.query.msg_id || 0, 10);
if (!botId || !msgId) return res.status(400).json({ ok: false, msg: '缺少 bot_id/msg_id' });
const bot = await Bot.owned(botId, u.id);
if (!bot) return res.status(403).json({ ok: false, msg: '无权访问' });
try {
const msg = await db.row('SELECT content FROM messages WHERE id=? AND bot_id=?', [msgId, botId]);
if (!msg) return res.status(404).json({ ok: false, msg: '消息不存在' });
let plaintext = msg.content;
let ref;
try { const parsed = JSON.parse(plaintext); ref = parsed.r; } catch (e) { /* ignore */ }
if (!ref || !ref.encrypt_query_param) return res.status(400).json({ ok: false, msg: '该消息不含可下载的媒体' });
const cdnBase = config.ilink_cdn || 'https://novac2c.cdn.weixin.qq.com/c2c';
const cdnUrl = `${cdnBase}/download?encrypted_query_param=${encodeURIComponent(ref.encrypt_query_param)}`;
const cdnResp = await require('axios').get(cdnUrl, {
responseType: 'arraybuffer',
timeout: 30000,
});
// AES-128-ECB 解密:aes_key 是 hex 字符串直接编码为 base64 的(与发送时一致)
const aesKeyHex = Buffer.from(ref.aes_key, 'base64').toString('utf-8');
const aesKey = Buffer.from(aesKeyHex, 'hex');
const cipher = crypto.createDecipheriv('aes-128-ecb', aesKey, null);
cipher.setAutoPadding(true);
const decrypted = Buffer.concat([cipher.update(Buffer.from(cdnResp.data)), cipher.final()]);
const mt = ref._t || 'file';
const contentTypes = { image: 'image/jpeg', video: 'video/mp4', voice: 'audio/mp3', file: 'application/octet-stream' };
res.setHeader('Content-Type', contentTypes[mt] || 'application/octet-stream');
res.setHeader('Content-Length', decrypted.length);
res.setHeader('Cache-Control', 'public, max-age=3600');
res.send(decrypted);
} catch (e) {
console.error('[bot_media] 下载媒体失败:', e.message);
if (!res.headersSent) res.status(500).json({ ok: false, msg: '下载媒体失败: ' + e.message });
}
});
// 公开:站点名称与 SEO 配置(前端据此设置页面标题、meta 描述/关键词)
router.get('/site-config', async (req, res) => {
const settings = require('../lib/settings');
const cfg = {
site_name: await settings.getSetting('site_name', '奶狗WeBot'),
site_icon: await settings.getSetting('site_icon', ''),
seo_title: await settings.getSetting('seo_title', ''),
seo_description: await settings.getSetting('seo_description', ''),
seo_keywords: await settings.getSetting('seo_keywords', ''),
};
res.json({ ok: true, config: cfg });
});
// ========== 超级记忆(实验室功能)后台接口 ==========
const memoryMod = require('../plugins/memory');
// 实验室开关状态
router.post('/memory/settings', async (req, res) => {
const u = await Auth.currentUser(req);
if (!u) return res.status(401).json({ ok: false, msg: '未登录' });
try {
const botId = parseInt(req.body.bot_id || '0', 10);
const bot = await Bot.owned(botId, u.id);
if (!bot) return res.status(403).json({ ok: false, msg: '机器人不存在或无权访问' });
res.json({ ok: true, enabled: await memoryMod.isEnabled(botId) });
} catch (e) {
res.status(500).json({ ok: false, msg: '加载失败: ' + e.message });
}
});
// 开关实验室功能(默认关闭,需用户主动开启;开启即表示知悉隐私提示)
router.post('/memory/settings_save', async (req, res) => {
const u = await Auth.currentUser(req);
if (!u) return res.status(401).json({ ok: false, msg: '未登录' });
try {
const botId = parseInt(req.body.bot_id || '0', 10);
const bot = await Bot.owned(botId, u.id);
if (!bot) return res.status(403).json({ ok: false, msg: '机器人不存在或无权访问' });
await memoryMod.setEnabled(req.body.enabled === true || req.body.enabled === '1');
res.json({ ok: true });
} catch (e) {
res.status(500).json({ ok: false, msg: '保存失败: ' + e.message });
}
});
// ========== 用户画像管理(本人 / 管理员通用) ==========
// 列出某 bot 下所有已生成画像的用户
// 管理员不传 bot_id(或 bot_id=0)时,列出全部机器人的画像
router.post('/memory/list', async (req, res) => {
const u = await Auth.currentUser(req);
if (!u) return res.status(401).json({ ok: false, msg: '未登录' });
try {
const reqBot = parseInt(req.body.bot_id || '0', 10);
const adminAll = isMemoryAdmin(u) && !reqBot;
if (!adminAll && !(await Bot.owned(reqBot, u.id)))
return res.status(403).json({ ok: false, msg: '机器人不存在或无权访问' });
let list = [];
if (adminAll) {
const bots = await db.rows('SELECT id, name FROM bots');
const nameMap = {};
for (const b of bots) nameMap[String(b.id)] = b.name || ('Bot#' + b.id);
for (const b of bots) {
const sub = await memoryMod.listUsers(b.id);
for (const it of sub) list.push({ ...it, bot_name: nameMap[String(b.id)] });
}
list.sort((a, b) => (b.generated_at || b.last_chat_at || 0) - (a.generated_at || a.last_chat_at || 0));
} else {
list = await memoryMod.listUsers(reqBot);
}
res.json({ ok: true, list });
} catch (e) {
res.status(500).json({ ok: false, msg: '加载失败: ' + e.message });
}
});
// 查看某用户画像
router.post('/memory/detail', async (req, res) => {
const u = await Auth.currentUser(req);
if (!u) return res.status(401).json({ ok: false, msg: '未登录' });
try {
const botId = parseInt(req.body.bot_id || '0', 10);
const peerId = String(req.body.peer_id || '');
if (!peerId) return res.status(400).json({ ok: false, msg: '缺少 peer_id' });
if (!(await Bot.owned(botId, u.id)) && !isMemoryAdmin(u))
return res.status(403).json({ ok: false, msg: '机器人不存在或无权访问' });
const markdown = memoryMod.getProfile(botId, peerId);
res.json({ ok: true, peer_id: peerId, markdown });
} catch (e) {
res.status(500).json({ ok: false, msg: '加载失败: ' + e.message });
}
});
// 编辑并保存某用户画像
router.post('/memory/save', async (req, res) => {
const u = await Auth.currentUser(req);
if (!u) return res.status(401).json({ ok: false, msg: '未登录' });
try {
const botId = parseInt(req.body.bot_id || '0', 10);
const peerId = String(req.body.peer_id || '');
if (!peerId) return res.status(400).json({ ok: false, msg: '缺少 peer_id' });
if (!(await Bot.owned(botId, u.id)) && !isMemoryAdmin(u))
return res.status(403).json({ ok: false, msg: '机器人不存在或无权访问' });
memoryMod.saveProfile(botId, peerId, req.body.markdown || '');
res.json({ ok: true });
} catch (e) {
res.status(500).json({ ok: false, msg: '保存失败: ' + e.message });
}
});
// 删除某用户画像
router.post('/memory/delete', async (req, res) => {
const u = await Auth.currentUser(req);
if (!u) return res.status(401).json({ ok: false, msg: '未登录' });
try {
const botId = parseInt(req.body.bot_id || '0', 10);
const peerId = String(req.body.peer_id || '');
if (!peerId) return res.status(400).json({ ok: false, msg: '缺少 peer_id' });
if (!(await Bot.owned(botId, u.id)) && !isMemoryAdmin(u))
return res.status(403).json({ ok: false, msg: '机器人不存在或无权访问' });
await memoryMod.deleteProfile(botId, peerId);
res.json({ ok: true });
} catch (e) {
res.status(500).json({ ok: false, msg: '删除失败: ' + e.message });
}
});
// 手动授权并为「单个用户」生成画像(每个用户彼此独立,只处理指定 peer_id):
// 基于该用户的全部聊天记录立即生成/更新画像并同步返回结果。
router.post('/memory/generate', async (req, res) => {
const u = await Auth.currentUser(req);
if (!u) return res.status(401).json({ ok: false, msg: '未登录' });
try {
const botId = parseInt(req.body.bot_id || '0', 10);
const peerId = String(req.body.peer_id || '');
if (!botId) return res.status(400).json({ ok: false, msg: '缺少 bot_id' });
if (!peerId) return res.status(400).json({ ok: false, msg: '请指定要生成画像的用户' });
if (!(await Bot.owned(botId, u.id)) && !isMemoryAdmin(u))
return res.status(403).json({ ok: false, msg: '机器人不存在或无权访问' });
// 手动授权:确保记忆功能已开启,并对该用户授权(失败不应阻断后续生成,仅记录)
try { await memoryMod.setEnabled(true); } catch (e) { console.error('[memory] setEnabled 失败', e.message); }
try { await memoryMod.setConsent(botId, peerId, 1); } catch (e) { console.error('[memory] setConsent 失败', e.message); }
// 手动「重新生成」强制全量重算,确保把已有增量聊天纳入画像(不再因新增不足被跳过)
let done = false;
try {
done = await memoryMod.generateProfile(botId, peerId, { limit: 800, force: true });
} catch (e) {
// 兜底:任何意外异常都记录详情并降级为「未生成」,绝不向上抛出 500
const detail = (e && e.stack) || (e && e.message) || String(e);
console.error('[memory] generateProfile 异常(已降级)', botId, peerId, '\n', detail);
try {
require('fs').mkdirSync(require('path').join(__dirname, '..', 'logs'), { recursive: true });
require('fs').appendFileSync(
require('path').join(__dirname, '..', 'logs', 'memory_generate_error.log'),
`\n[${new Date().toISOString()}] bot=${botId} peer=${peerId}\n${detail}\n`
);
} catch (_) {}
done = false;
}
const msg = done === true ? '已根据新增聊天更新用户画像' :
done === 'skipped' ? '该用户暂无新增聊天,无需重新生成' :
'该用户聊天记录不足,无法生成画像';
return res.json({ ok: true, generated: done === true, skipped: done === 'skipped', msg });
} catch (e) {
const detail = (e && e.stack) || (e && e.message) || String(e);
console.error('[memory] /api/memory/generate 异常', '\n', detail);
try {
require('fs').mkdirSync(require('path').join(__dirname, '..', 'logs'), { recursive: true });
require('fs').appendFileSync(
require('path').join(__dirname, '..', 'logs', 'memory_generate_error.log'),
`\n[${new Date().toISOString()}] ROUTE\n${detail}\n`
);
} catch (_) {}
res.status(500).json({ ok: false, msg: '生成失败: ' + (e && e.message) });
}
});
// 下载某用户画像文件
router.get('/memory/download', async (req, res) => {
const u = await Auth.currentUser(req);
if (!u) return res.status(401).json({ ok: false, msg: '未登录' });
try {
const botId = parseInt(req.query.bot_id || '0', 10);
const peerId = String(req.query.peer_id || '');
if (!peerId) return res.status(400).json({ ok: false, msg: '缺少 peer_id' });
if (!(await Bot.owned(botId, u.id)) && !isMemoryAdmin(u))
return res.status(403).json({ ok: false, msg: '机器人不存在或无权访问' });
const file = memoryMod.profilePath(botId, peerId);
if (!fs.existsSync(file)) return res.status(404).json({ ok: false, msg: '暂无画像文件' });
res.setHeader('Content-Disposition', `attachment; filename="profile_${botId}_${peerId}.md"`);
res.setHeader('Content-Type', 'text/markdown; charset=utf-8');
res.send(fs.readFileSync(file));
} catch (e) {
if (!res.headersSent) res.status(500).json({ ok: false, msg: '下载失败: ' + e.message });
}
});
function isMemoryAdmin(u) { return u && u.is_admin === 1; }
// ============ 日志控制台 ============
/** 返回执行日志(按天滚动文件)。需登录。支持 date(指定日期)、tail(末尾行数)、q(关键词过滤)。 */
router.post('/logs', async (req, res) => {
const u = await Auth.currentUser(req);
if (!u) return res.status(401).json({ ok: false, msg: '未登录' });
const body = req.body || {};
const date = (body.date && /^\d{4}-\d{2}-\d{2}$/.test(body.date)) ? body.date : logger.dateStamp();
const tail = Math.min(parseInt(body.tail, 10) || 800, 2000);
const q = (body.q || '').trim().toLowerCase();
const file = logger.logFileFor(date);
if (!fs.existsSync(file)) {
return res.json({ ok: true, lines: [], total: 0, date, file: path.basename(file) });
}
try {
const content = fs.readFileSync(file, 'utf8');
let lines = content.split('\n').filter(Boolean);
if (q) lines = lines.filter((l) => l.toLowerCase().includes(q));
const total = lines.length;
lines = lines.slice(-tail);
res.json({ ok: true, lines, total, date, file: path.basename(file) });
} catch (e) {
res.status(500).json({ ok: false, msg: '读取日志失败: ' + e.message });
}
});
/** 404 未知操作 */
router.all('*', (req, res) => {
res.status(404).json({ ok: false, msg: '未知操作' });
});
module.exports = router;