码桶

发现社区成员的开源项目

奶狗 /

ng-webot

公开
main
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;