码桶

发现社区成员的开源项目

奶狗 /

ng-webot

公开
main
index.js33 KB
/**
 * 插件:Webhook 推送(消息推送接口)
 * --------------------------------------------------
 * 提供类似「Server 酱 / Bark」的外部消息推送能力:
 *   1) Webhook 接收:外部系统 POST 到专属链接,即可把消息推送到绑定的微信会话。
 *   2) 智能助手:可对推送内容做 AI 润色 / 总结 / 翻译,或由 AI 直接生成内容。
 *   3) 定时推送:按每天 / 每周 / 相对时间 / 绝对时间,自动把内容(或 AI 生成内容)推送给会话。
 *
 * 使用方式(在微信里给机器人发消息):
 *   推送 绑定            → 生成一个专属 Webhook 链接(绑定当前会话)
 *   推送 重绑            → 重新生成链接(旧链接失效)
 *   推送 链接            → 查看当前会话的 Webhook 链接
 *   推送 解绑            → 解除当前会话的推送通道
 *   推送 定时 每天9点 早安,该喝水啦   → 添加定时推送
 *   推送 定时 30分钟后 该起来活动一下了
 *   推送 定时列表        → 查看定时推送
 *   推送 取消定时 3      → 取消编号 3 的定时推送
 *
 * 外部系统调用示例:
 *   curl -X POST https://你的域名/api/push/webhook/<token> \
 *     -H "Content-Type: application/json" \
 *     -d '{"title":"告警","content":"CPU 使用率 95%","ai":true}'
 */
const db = require('../../lib/db');
const ILink = require('../../lib/ilink');

const crypto = require('crypto');
const config = require('../../config');

// ==================== 建表 ====================
let tablesReady = false;
async function ensureTables() {
  if (tablesReady) return;
  try {
    await db.exec(`CREATE TABLE IF NOT EXISTS push_channels (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      bot_id INTEGER NOT NULL,
      peer_id VARCHAR(128) NOT NULL,
      token VARCHAR(64) NOT NULL,
      name VARCHAR(64) DEFAULT '',
      ai_enabled INTEGER NOT NULL DEFAULT 0,
      ai_prompt TEXT DEFAULT '',
      created_at INTEGER NOT NULL
    )`);
    await db.exec(`CREATE TABLE IF NOT EXISTS push_schedules (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      bot_id INTEGER NOT NULL,
      peer_id VARCHAR(128) NOT NULL,
      content TEXT NOT NULL,
      ai_enabled INTEGER NOT NULL DEFAULT 0,
      ai_prompt TEXT DEFAULT '',
      repeat_type VARCHAR(16) NOT NULL DEFAULT 'once',
      next_at INTEGER NOT NULL,
      created_at INTEGER NOT NULL,
      enabled INTEGER NOT NULL DEFAULT 1
    )`);
    await db.exec(`CREATE TABLE IF NOT EXISTS push_deferred (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      bot_id INTEGER NOT NULL,
      peer_id VARCHAR(128) NOT NULL,
      content TEXT NOT NULL,
      category VARCHAR(32) NOT NULL DEFAULT 'webhook',
      send_at INTEGER NOT NULL,
      created_at INTEGER NOT NULL
    )`);
    tablesReady = true;
  } catch (e) {
    console.error('[push] 建表失败:', e.message);
  }
}

// ==================== 工具函数 ====================
function genToken() {
  return crypto.randomBytes(18).toString('hex');
}

/** 站点地址(用于拼接绝对 webhook URL;未配置则返回相对路径) */
function siteBase() {
  const b = String(config.base_url || '').replace(/\/+$/, '');
  return b;
}

/** 构造 webhook 链接 */
function webhookUrl(token) {
  const path = '/api/push/webhook/' + token;
  const base = siteBase();
  return base ? base + path : path;
}

/** 获取某机器人最近一条入站消息的 peer(作为默认推送目标) */
async function defaultPeer(botId) {
  const row = await db.row(
    "SELECT peer_id FROM messages WHERE bot_id=? AND direction='in' AND peer_id IS NOT NULL AND peer_id != '' ORDER BY id DESC LIMIT 1",
    [botId]
  );
  return row ? row.peer_id : '';
}

/** 动态解析最新有效的 context_token(优先该 peer,其次该 bot 最近消息,最后全局 token) */
async function resolveCtx(botId, peerId) {
  if (peerId) {
    const lastIn = await db.row(
      "SELECT context_token FROM messages WHERE bot_id=? AND peer_id=? AND direction='in' AND context_token IS NOT NULL AND context_token != '' ORDER BY id DESC LIMIT 1",
      [botId, peerId]
    );
    if (lastIn && lastIn.context_token) return lastIn.context_token;
  }
  const latestIn = await db.row(
    "SELECT context_token FROM messages WHERE bot_id=? AND direction='in' AND context_token IS NOT NULL AND context_token != '' ORDER BY id DESC LIMIT 1",
    [botId]
  );
  if (latestIn && latestIn.context_token) return latestIn.context_token;
  const bot = await db.row('SELECT context_token FROM bots WHERE id=?', [botId]);
  if (bot && bot.context_token) return bot.context_token;
  const lastOut = await db.row(
    "SELECT context_token FROM messages WHERE bot_id=? AND direction='out' AND context_token IS NOT NULL AND context_token != '' ORDER BY id DESC LIMIT 1",
    [botId]
  );
  return lastOut ? lastOut.context_token : null;
}

/** 实际发送并记录(跳过 DND 检查,供延后队列 / 直接调用) */
async function doSend(botId, peerId, text) {
  const ctxToken = await resolveCtx(botId, peerId);
  if (!ctxToken) throw new Error('无法解析 context_token,请确保该会话近期有消息往来');
  const bot = await db.row('SELECT bot_token, base_url FROM bots WHERE id=?', [botId]);
  if (!bot) throw new Error('机器人不存在');
  const il = new ILink({ bot_token: bot.bot_token, base_url: bot.base_url });
  const resp = await il.sendMessage(peerId, ctxToken, String(text));
  if ((resp.ret ?? -1) !== 0) throw new Error('发送失败: ' + JSON.stringify(resp));
  const now = Math.floor(Date.now() / 1000);
  const textStr = String(text);
  await db.exec(
    'INSERT INTO messages (bot_id, direction, peer_id, content, msg_type, context_token, created_at) VALUES (?,?,?,?,?,?,?)',
    [botId, 'out', peerId, textStr, 'text', ctxToken, now]
  );
}

/**
 * 发送主动消息。category 命中该 peer 的勿扰设置时写入延后队列(勿扰结束后自动补发)。
 * @param {string} category  webhook(外部推送)/ schedule(定时推送)/ ai(AI 主动发)
 * @returns {object}  { ok, deferred?, sendAt? }
 */
async function sendToPeer(botId, peerId, text, category = 'webhook') {
  const dnd = require('../../lib/dnd');
  if (await dnd.shouldBlock(botId, peerId, category)) {
    try {
      const cfg = await dnd.getDnd(botId, peerId);
      const end = dnd.dndEndDate(cfg);
      const sendAt = Math.floor(end.getTime() / 1000);
      await db.exec(
        'INSERT INTO push_deferred (bot_id, peer_id, content, category, send_at, created_at) VALUES (?,?,?,?,?,?)',
        [botId, peerId, String(text), category, sendAt, Math.floor(Date.now() / 1000)]
      );
      console.log(`[push] peer ${peerId} 处于勿扰时段,消息已延后至 ${end.toLocaleString()}`);
      return { ok: false, deferred: true, sendAt };
    } catch (e) {
      console.warn('[push] 延后入队失败:', e.message);
    }
  }
  await doSend(botId, peerId, text);
  return { ok: true };
}

// ==================== 时间解析 ====================
const WEEKDAY_MAP = { '一': 1, '二': 2, '三': 3, '四': 4, '五': 5, '六': 6, '日': 7, '天': 7 };

function adjustHour(hour, period) {
  if (period) {
    if (period.includes('早') || period.includes('上')) return hour;
    if (period.includes('中')) return hour === 12 ? 12 : hour + 12;
    if (period.includes('下') || period.includes('晚') || period.includes('傍')) {
      if (hour === 12) return 12;
      return hour + 12;
    }
  }
  return hour;
}

function parseTime(str) {
  str = String(str || '').trim();
  if (!str) return null;
  const now = new Date();
  let target = new Date(now);
  let repeatType = 'once';
  let repeatRule = '';

  let m = str.match(/^(\d{4})-(\d{1,2})-(\d{1,2})\s+(\d{1,2}):(\d{2})$/);
  if (m) {
    target = new Date(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], 0, 0);
    return { target: Math.floor(target.getTime() / 1000), repeatType, repeatRule };
  }
  m = str.match(/^(\d+)\s*(分钟|分)\s*后$/);
  if (m) { target.setMinutes(target.getMinutes() + parseInt(m[1])); return { target: Math.floor(target.getTime() / 1000), repeatType, repeatRule }; }
  m = str.match(/^(\d+)\s*(小时|时)\s*后$/);
  if (m) { target.setHours(target.getHours() + parseInt(m[1])); return { target: Math.floor(target.getTime() / 1000), repeatType, repeatRule }; }
  m = str.match(/^每天\s*(早上|上午|中午|下午|晚上|傍晚)?\s*(\d{1,2})\s*(点|:)(\d{2})?\s*$/);
  if (m) {
    let hour = adjustHour(parseInt(m[2]), m[1] || '');
    let min = m[4] ? parseInt(m[4]) : 0;
    target.setHours(hour, min, 0, 0);
    if (target <= now) target.setDate(target.getDate() + 1);
    return { target: Math.floor(target.getTime() / 1000), repeatType: 'daily', repeatRule: '' };
  }
  m = str.match(/^每周([一二三四五六日天])\s*(早上|上午|中午|下午|晚上|傍晚)?\s*(\d{1,2})\s*(点|:)(\d{2})?\s*$/);
  if (m) {
    let wday = WEEKDAY_MAP[m[1]];
    let hour = adjustHour(parseInt(m[3]), m[2] || '');
    let min = m[5] ? parseInt(m[5]) : 0;
    target.setHours(hour, min, 0, 0);
    let diff = wday - target.getDay();
    if (diff <= 0) diff += 7;
    target.setDate(target.getDate() + diff);
    if (target <= now) target.setDate(target.getDate() + 7);
    return { target: Math.floor(target.getTime() / 1000), repeatType: 'weekly', repeatRule: String(wday) };
  }
  m = str.match(/^明天\s*(早上|上午|中午|下午|晚上|傍晚)?\s*(\d{1,2})\s*(点|:)(\d{2})?\s*$/);
  if (m) {
    let hour = adjustHour(parseInt(m[2]), m[1] || '');
    let min = m[4] ? parseInt(m[4]) : 0;
    target.setDate(target.getDate() + 1);
    target.setHours(hour, min, 0, 0);
    return { target: Math.floor(target.getTime() / 1000), repeatType, repeatRule };
  }
  m = str.match(/^(今天|早上|上午|中午|下午|晚上|傍晚)?\s*(\d{1,2})\s*(点|:)(\d{2})?\s*$/);
  if (m) {
    let hour = adjustHour(parseInt(m[2]), m[1] || '');
    let min = m[4] ? parseInt(m[4]) : 0;
    target.setHours(hour, min, 0, 0);
    if (target <= now) target.setDate(target.getDate() + 1);
    return { target: Math.floor(target.getTime() / 1000), repeatType, repeatRule };
  }
  m = str.match(/^(\d{1,2}):(\d{2})\s*$/);
  if (m) {
    target.setHours(parseInt(m[1]), parseInt(m[2]), 0, 0);
    if (target <= now) target.setDate(target.getDate() + 1);
    return { target: Math.floor(target.getTime() / 1000), repeatType, repeatRule };
  }
  return null;
}

// ==================== 通道(Channel)管理 ====================
async function bindChannel(botId, peerId, name) {
  await ensureTables();
  if (!peerId) peerId = await defaultPeer(botId);
  if (!peerId) throw new Error('未能确定推送目标会话,请先在微信中给机器人发送任意消息');
  let ch = await db.row('SELECT * FROM push_channels WHERE bot_id=? AND peer_id=?', [botId, peerId]);
  if (!ch) {
    const token = genToken();
    const now = Math.floor(Date.now() / 1000);
    await db.exec(
      'INSERT INTO push_channels (bot_id, peer_id, token, name, created_at) VALUES (?,?,?,?,?)',
      [botId, peerId, token, name || '', now]
    );
    ch = await db.row('SELECT * FROM push_channels WHERE bot_id=? AND peer_id=?', [botId, peerId]);
  }
  return ch;
}

/** 重新生成 token(旧链接失效) */
async function rebindChannel(botId, peerId) {
  await ensureTables();
  if (!peerId) peerId = await defaultPeer(botId);
  let ch = await db.row('SELECT * FROM push_channels WHERE bot_id=? AND peer_id=?', [botId, peerId]);
  if (!ch) return bindChannel(botId, peerId, '');
  const token = genToken();
  await db.exec('UPDATE push_channels SET token=? WHERE id=?', [token, ch.id]);
  return db.row('SELECT * FROM push_channels WHERE id=?', [ch.id]);
}

async function getChannelByToken(token) {
  await ensureTables();
  if (!token) return null;
  return db.row('SELECT * FROM push_channels WHERE token=?', [token]);
}

async function listChannels(botId) {
  await ensureTables();
  const rows = await db.rows(
    'SELECT id, peer_id, token, name, ai_enabled, ai_prompt, created_at FROM push_channels WHERE bot_id=? ORDER BY id DESC',
    [botId]
  );
  return rows.map(c => ({ ...c, url: webhookUrl(c.token) }));
}

async function deleteChannel(id, botId) {
  await db.exec('DELETE FROM push_channels WHERE id=? AND bot_id=?', [id, botId]);
}

async function updateChannel(id, botId, patch) {
  const sets = [];
  const args = [];
  if (patch.ai_enabled !== undefined) { sets.push('ai_enabled=?'); args.push(patch.ai_enabled ? 1 : 0); }
  if (patch.ai_prompt !== undefined) { sets.push('ai_prompt=?'); args.push(String(patch.ai_prompt || '')); }
  if (!sets.length) return;
  args.push(id, botId);
  await db.exec('UPDATE push_channels SET ' + sets.join(', ') + ' WHERE id=? AND bot_id=?', args);
}

// ==================== 定时推送(Schedule)管理 ====================
async function addSchedule(botId, peerId, { content, ai_enabled, ai_prompt, time_desc }) {
  await ensureTables();
  content = (content || '').toString().trim();
  if (!content) throw new Error('定时推送内容不能为空');
  if (!peerId) peerId = await defaultPeer(botId);
  if (!peerId) throw new Error('未能确定推送目标会话,请先在微信中给机器人发送任意消息');
  const parsed = parseTime(time_desc);
  if (!parsed) throw new Error('无法解析时间:「' + time_desc + '」。支持:30分钟后 / 1小时后 / 每天9点 / 每周一8点 / 明天9点 / 2026-07-20 14:30');
  const now = Math.floor(Date.now() / 1000);
  if (parsed.target <= now) throw new Error('时间已过,请设置未来时间');
  await db.exec(
    'INSERT INTO push_schedules (bot_id, peer_id, content, ai_enabled, ai_prompt, repeat_type, next_at, created_at, enabled) VALUES (?,?,?,?,?,?,?,?,1)',
    [botId, peerId, content, ai_enabled ? 1 : 0, ai_prompt || '', parsed.repeatType, parsed.target, now]
  );
}

async function listSchedules(botId, peerId) {
  await ensureTables();
  let rows = await db.rows(
    'SELECT id, peer_id, content, ai_enabled, ai_prompt, repeat_type, next_at, created_at FROM push_schedules WHERE bot_id=? AND enabled=1 ORDER BY next_at ASC',
    [botId]
  );
  if (peerId) rows = rows.filter(r => r.peer_id === peerId);
  return rows;
}

async function deleteSchedule(id, botId) {
  await db.exec('UPDATE push_schedules SET enabled=0 WHERE id=? AND bot_id=?', [id, botId]);
}

// ==================== 智能助手(AI 润色 / 生成) ====================
/**
 * 若启用 AI,则把推送内容交给智能助手处理(润色 / 总结 / 翻译 / 生成)。
 * 失败则回退到原文,保证推送不中断。
 */
async function maybeAI(botId, content, aiEnabled, aiPrompt) {
  if (!aiEnabled) return content;
  try {
    const pluginsLib = require('../../lib/plugins');
    const prompt = (aiPrompt ? aiPrompt + '\n\n' : '请把下面的内容整理成适合推送的友好文案:\n') + content;
    const bot = await db.row('SELECT user_id FROM bots WHERE id=?', [botId]);
    const text = await pluginsLib.callAssistant({ botId, userId: bot ? bot.user_id : null, prompt });
    if (text && text.trim()) return text.trim();
  } catch (e) {
    console.error('[push] AI 处理失败,回退原文:', e.message);
  }
  return content;
}

// ==================== 外部 Webhook 处理 ====================
/**
 * 处理外部系统推送来的消息。
 * @param {string} token  通道 token(path / query.token / header x-push-token)
 * @param {object} body   请求体(JSON 或 query):content/text/msg + 可选 title/ai/ai_prompt
 */
async function handleWebhook(token, body) {
  const ch = await getChannelByToken(token);
  if (!ch) {
    const e = new Error('无效的 webhook token');
    e.status = 404;
    throw e;
  }
  const content = String(body.content || body.text || body.msg || '').trim();
  if (!content) {
    const e = new Error('缺少 content / text 字段');
    e.status = 400;
    throw e;
  }
  const title = String(body.title || '').trim();

  // AI 开关:请求级优先,其次通道级
  let aiEnabled = ch.ai_enabled === 1;
  if (body.ai !== undefined) {
    aiEnabled = body.ai === true || body.ai === '1' || body.ai === 'true';
  }
  let aiPrompt = ch.ai_prompt || '';
  if (body.ai_prompt) aiPrompt = String(body.ai_prompt);

  const text = await maybeAI(ch.bot_id, content, aiEnabled, aiPrompt);
  const finalText = (!aiEnabled && title) ? title + '\n' + text : text;

  const res = await sendToPeer(ch.bot_id, ch.peer_id, finalText, 'webhook');
  if (res.deferred) {
    return { pushed: false, deferred: true, sendAt: res.sendAt, ai: aiEnabled };
  }
  return { pushed: true, ai: aiEnabled };
}

// ==================== 定时调度器 ====================
let schedulerStarted = false;
function startScheduler() {
  if (schedulerStarted || global.__pushSchedulerStarted) return;
  schedulerStarted = true;
  global.__pushSchedulerStarted = true;
  setInterval(async () => {
    try {
      const now = Math.floor(Date.now() / 1000);
      const dnd = require('../../lib/dnd');

      // ── 1) 到期定时推送:先过勿扰,命中则延后 ──
      const due = await db.rows('SELECT * FROM push_schedules WHERE next_at <= ? AND enabled=1', [now]);
      for (const s of due) {
        try {
          const bot = await db.row('SELECT * FROM bots WHERE id=?', [s.bot_id]);
          if (!bot) { await db.exec('UPDATE push_schedules SET enabled=0 WHERE id=?', [s.id]); continue; }
          const cat = s.ai_enabled === 1 ? 'ai' : 'schedule';
          if (await dnd.shouldBlock(s.bot_id, s.peer_id, cat)) {
            const cfg = await dnd.getDnd(s.bot_id, s.peer_id);
            const end = dnd.dndEndDate(cfg);
            const endTs = Math.floor(end.getTime() / 1000);
            // 一次性:直接改到勿扰结束补发;循环类:跳过本次(按周期顺延)
            if (s.repeat_type === 'daily') {
              await db.exec('UPDATE push_schedules SET next_at = next_at + 86400 WHERE id=?', [s.id]);
            } else if (s.repeat_type === 'weekly') {
              await db.exec('UPDATE push_schedules SET next_at = next_at + 604800 WHERE id=?', [s.id]);
            } else {
              await db.exec('UPDATE push_schedules SET next_at = ? WHERE id=?', [endTs, s.id]);
            }
            continue;
          }
          const text = await maybeAI(s.bot_id, s.content, s.ai_enabled === 1, s.ai_prompt);
          await sendToPeer(s.bot_id, s.peer_id, text, cat);
          if (s.repeat_type === 'daily') {
            await db.exec('UPDATE push_schedules SET next_at = next_at + 86400 WHERE id=?', [s.id]);
          } else if (s.repeat_type === 'weekly') {
            await db.exec('UPDATE push_schedules SET next_at = next_at + 604800 WHERE id=?', [s.id]);
          } else {
            await db.exec('UPDATE push_schedules SET enabled=0 WHERE id=?', [s.id]);
          }
        } catch (e) {
          console.error('[push] 定时任务 id=' + s.id + ' 失败:', e.message);
        }
      }

      // ── 2) 勿扰延后队列:到点且已不在勿扰则补发 ──
      const deferred = await db.rows('SELECT * FROM push_deferred WHERE send_at <= ?', [now]);
      for (const d of deferred) {
        try {
          if (await dnd.shouldBlock(d.bot_id, d.peer_id, d.category)) {
            const cfg = await dnd.getDnd(d.bot_id, d.peer_id);
            const end = dnd.dndEndDate(cfg);
            await db.exec('UPDATE push_deferred SET send_at=? WHERE id=?', [Math.floor(end.getTime() / 1000), d.id]);
            continue;
          }
          await doSend(d.bot_id, d.peer_id, d.content);
          await db.exec('DELETE FROM push_deferred WHERE id=?', [d.id]);
        } catch (e) {
          console.error('[push] 延后补发 id=' + d.id + ' 失败:', e.message);
        }
      }
    } catch (e) {
      console.error('[push] 调度错误:', e.message);
    }
  }, 30000).unref();
}

// ==================== 勿扰 / 推送设置(DND)聊天命令 ====================
const CAT_LABEL = {
  webhook: '外部推送',
  schedule: '定时推送',
  reminder: '提醒',
  ai: 'AI 主动发',
};
function maskToText(mask) {
  const dnd = require('../../lib/dnd');
  return Object.keys(dnd.CAT)
    .filter(k => (mask & dnd.CAT[k]) !== 0)
    .map(k => CAT_LABEL[k] || k)
    .join('、') || '(无)';
}
function fmtMin(m) {
  const dnd = require('../../lib/dnd');
  return dnd.fmtMin(m);
}
/** 解析类别列表文字 → 位掩码;无法识别返回 null */
function parseCatMask(text) {
  const dnd = require('../../lib/dnd');
  const map = {
    '推送': 'webhook', 'webhook': 'webhook', '外部': 'webhook', '外部推送': 'webhook',
    '定时': 'schedule', 'schedule': 'schedule', '定时推送': 'schedule',
    '提醒': 'reminder', 'reminder': 'reminder',
    'ai': 'ai', 'a i': 'ai', '智能': 'ai', '机器人主动': 'ai', '主动': 'ai',
  };
  const parts = String(text).split(/[,,、\s]+/).map(s => s.trim()).filter(Boolean);
  if (!parts.length) return null;
  let mask = 0;
  for (const p of parts) {
    const key = map[p.toLowerCase()] || map[p];
    if (!key) return null;
    mask |= dnd.CAT[key];
  }
  return mask;
}
function formatDndStatus(cfg) {
  const dnd = require('../../lib/dnd');
  const now = dnd.inWindow(cfg);
  const head = cfg.enabled
    ? (now ? '🔕 当前处于勿扰时段' : '🔔 已开启勿扰(当前不在时段内)')
    : '🔔 勿扰已关闭(随时可主动推送)';
  return head + '\n' +
    '时段:' + fmtMin(cfg.start_min) + ' ~ ' + fmtMin(cfg.end_min) + '(每日循环)\n' +
    '勿扰时段内静音:' + maskToText(cfg.mask) + '\n' +
    '(AI 主动发默认不禁;可用「勿扰 类别 ...」自定义)';
}
async function handleDndCommand(arg, botId, peerId, ctx) {
  const dnd = require('../../lib/dnd');
  const cfg = await dnd.getDnd(botId, peerId);

  // 状态查询
  if (!arg || /^(状态|查询|查看|status|\?|?)$/i.test(arg)) {
    await ctx.sendText(formatDndStatus(cfg));
    return true;
  }
  if (/^(关|关闭|off|disable|取消)$/i.test(arg)) {
    await dnd.setDnd(botId, peerId, { ...cfg, enabled: false });
    await ctx.sendText('🔕 已关闭勿扰(推送设置)。机器人将随时可主动给你发消息。\n发送「勿扰 状态」可随时查看。');
    return true;
  }
  if (/^(开|开启|on|enable)$/i.test(arg)) {
    await dnd.setDnd(botId, peerId, { ...cfg, enabled: true });
    await ctx.sendText('🔔 已开启勿扰(推送设置)。' + (dnd.inWindow(cfg) ? '(当前正处于勿扰时段)' : '') + '\n发送「勿扰 状态」查看详情。');
    return true;
  }
  // 预设:全开(含 AI)/ 智能(默认,不含 AI)
  if (/^(全开|全部静音|全静音|all)$/i.test(arg)) {
    await dnd.setDnd(botId, peerId, { ...cfg, enabled: true, mask: dnd.CAT.webhook | dnd.CAT.schedule | dnd.CAT.reminder | dnd.CAT.ai });
    await ctx.sendText('🔕 已设置:勿扰时段内【全部】主动消息(含 AI 主动发)均静音。\n发送「勿扰 智能」可恢复推荐设置。');
    return true;
  }
  if (/^(智能|默认|default|标准)$/i.test(arg)) {
    await dnd.setDnd(botId, peerId, { ...cfg, enabled: true, mask: dnd.DEFAULT_MASK });
    await ctx.sendText('🔔 已设置:勿扰时段内仅静音【外部推送 / 定时推送 / 提醒】,AI 主动发的消息仍会发(推荐)。');
    return true;
  }
  // 类别设置:勿扰 类别 推送,提醒
  const cm = arg.match(/^(类别|类型|分类|静音)\s*(.+)$/i);
  if (cm) {
    const mask = parseCatMask(cm[2]);
    if (mask == null) {
      await ctx.sendText('无法识别类别。可用项:推送 / 定时 / 提醒 / ai(逗号分隔,如:勿扰 类别 推送,提醒)');
      return true;
    }
    await dnd.setDnd(botId, peerId, { ...cfg, enabled: true, mask });
    await ctx.sendText('🔔 已更新勿扰静音类别:' + maskToText(mask) + '\n(未列出的类别在勿扰时段仍会发送)');
    return true;
  }
  // 时段设置:勿扰 22:00-08:00(支持 : 、 - ~ 至 到)
  const wm = arg.match(/^(\d{1,2})[::]?(\d{2})?\s*[-~至到]\s*(\d{1,2})[::]?(\d{2})?$/);
  if (wm) {
    const sh = parseInt(wm[1], 10), sm = wm[2] ? parseInt(wm[2], 10) : 0;
    const eh = parseInt(wm[3], 10), em = wm[4] ? parseInt(wm[4], 10) : 0;
    if (sh > 23 || eh > 23 || sm > 59 || em > 59) {
      await ctx.sendText('时间格式不正确,示例:勿扰 22:00-08:00');
      return true;
    }
    const start_min = sh * 60 + sm, end_min = eh * 60 + em;
    await dnd.setDnd(botId, peerId, { ...cfg, enabled: true, start_min, end_min });
    await ctx.sendText(
      '🔔 已设置勿扰时段:' + fmtMin(start_min) + ' ~ ' + fmtMin(end_min) + '(每日循环,跨午夜自动处理)。\n' +
      '当前静音类别:' + maskToText(cfg.mask) + '\n' +
      '发送「勿扰 状态」查看,「勿扰 类别 推送,提醒」自定义,「勿扰 关」关闭。'
    );
    return true;
  }
  // 帮助
  await ctx.sendText(
    '🔔 推送设置(勿扰模式)用法:\n' +
    '· 勿扰 22:00-08:00   设置勿扰时段(默认静音:外部推送/定时推送/提醒,AI 主动发仍发)\n' +
    '· 勿扰 类别 推送,提醒  自定义静音类别(可用:推送 / 定时 / 提醒 / ai)\n' +
    '· 勿扰 智能   恢复推荐设置(AI 主动发不禁)\n' +
    '· 勿扰 全开   勿扰时段内全部静音(含 AI)\n' +
    '· 勿扰 状态   查看当前设置\n' +
    '· 勿扰 关     关闭勿扰'
  );
  return true;
}

// ==================== 前端 / 路由 聚合查询 ====================
async function listForBot(botId) {
  await ensureTables();
  const channels = await listChannels(botId);
  const schedules = await db.rows(
    'SELECT id, peer_id, content, ai_enabled, ai_prompt, repeat_type, next_at, created_at FROM push_schedules WHERE bot_id=? AND enabled=1 ORDER BY next_at ASC',
    [botId]
  );
  return { channels, schedules, defaultPeer: await defaultPeer(botId) };
}

// ==================== 插件入口 ====================
const PREFIX_RE = /^(推送|push)\s*/i;

module.exports = {
  meta: {
    id: 'push',
    name: 'Webhook 推送',
    version: '1.0.0',
    author: '奶狗',
    category: '消息处理',
    description: '提供 Webhook 消息推送接口(类似 Server 酱 / Bark):外部系统 POST 到专属链接即可把消息推送到微信。支持用「智能助手」对推送内容做 AI 润色/总结/生成,并支持「定时推送」(每天/每周/相对时间)。',
    entry: 'push/index.js',
    // 指令前缀:让智能助手在遇到这些前缀时让位给本插件处理
    commandPrefix: ['推送', 'push'],
    customConfig: 'push',
  },

  // 供 routes/push.js 调用的公共方法
  ensureTables, startScheduler, bindChannel, rebindChannel, getChannelByToken,
  listChannels, deleteChannel, updateChannel, handleWebhook,
  addSchedule, listSchedules, deleteSchedule, listForBot, defaultPeer, webhookUrl,

  async onMessage(msg, ctx) {
    await ensureTables();
    startScheduler();

    const text = (msg.content || '').trim();
    if (!text) return false;

    // ── 勿扰 / 推送设置(独立前缀,优先于「推送」指令)──
    const dndRe = /^(勿扰|免打扰|免打搅|勿扰模式|dnd)\s*/i;
    const dm = text.match(dndRe);
    if (dm) {
      return await handleDndCommand(text.slice(dm[0].length).trim(), ctx.bot.id, msg.peer_id, ctx);
    }

    const m = text.match(PREFIX_RE);
    if (!m) return false; // 不是本插件指令,放行给其他插件 / 智能助手

    const rest = text.slice(m[0].length).trim();
    const botId = ctx.bot.id;
    const peerId = msg.peer_id;

    try {
      // ── 绑定 ──
      if (/^(绑定|bind)$/i.test(rest)) {
        const ch = await bindChannel(botId, peerId, '');
        const url = webhookUrl(ch.token);
        await ctx.sendText(
          '[Link] 已生成专属推送链接(绑定本会话):\n' + url +
          '\n\n外部系统调用示例:\n' +
          'curl -X POST ' + url + ' \\\n' +
          '  -H "Content-Type: application/json" \\\n' +
          '  -d \'{"title":"标题","content":"要推送的内容","ai":true}\'\n\n' +
          '提示:ai:true 会用智能助手润色/生成内容;不传则直接推送原文。'
        );
        return true;
      }

      // ── 重新绑定(重置 token)──
      if (/^(重绑|重置|rebind|reset)$/i.test(rest)) {
        const ch = await rebindChannel(botId, peerId);
        await ctx.sendText('[Renew] 已重置推送链接(旧链接已失效):\n' + webhookUrl(ch.token));
        return true;
      }

      // ── 查看链接 ──
      if (/^(链接|我的|url|地址|link)$/i.test(rest)) {
        const ch = await db.row('SELECT * FROM push_channels WHERE bot_id=? AND peer_id=?', [botId, peerId]);
        if (!ch) {
          await ctx.sendText('尚未绑定推送链接,发送「推送 绑定」即可生成。');
        } else {
          await ctx.sendText('[Link] 你的推送链接:\n' + webhookUrl(ch.token) +
            '\n\nAI 开关:' + (ch.ai_enabled === 1 ? '已开启' : '未开启') +
            (ch.ai_prompt ? '\nAI 指令:' + ch.ai_prompt : ''));
        }
        return true;
      }

      // ── 解绑 ──
      if (/^(解绑|取消绑定|unbind)$/i.test(rest)) {
        const ch = await db.row('SELECT id FROM push_channels WHERE bot_id=? AND peer_id=?', [botId, peerId]);
        if (!ch) {
          await ctx.sendText('本会话尚未绑定推送链接。');
        } else {
          await deleteChannel(ch.id, botId);
          await ctx.sendText('[OK] 已解绑,推送链接失效。');
        }
        return true;
      }

      // ── 定时列表 ──
      if (/^(定时列表|定时推送列表|schedule\s*list|schedules|我的定时)$/i.test(rest)) {
        const items = await listSchedules(botId, peerId);
        if (!items.length) {
          await ctx.sendText('[定时推送] 当前没有待执行的定时推送。\n添加:推送 定时 <时间> <内容>\n例如:推送 定时 每天9点 早安,记得喝水');
          return true;
        }
        const lines = items.map((r, i) => {
          const dt = new Date(r.next_at * 1000);
          const ds = dt.toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' });
          const tag = r.repeat_type === 'daily' ? '[每天]' : r.repeat_type === 'weekly' ? '[每周]' : '';
          const ai = r.ai_enabled === 1 ? '' : '';
          return `${r.id}. ${tag}${ds}${ai} — ${r.content}`;
        });
        await ctx.sendText('[定时推送] 待执行列表:\n' + lines.join('\n') + '\n\n发送「推送 取消定时 编号」可取消。');
        return true;
      }

      // ── 取消定时 ──
      let cm = rest.match(/^(取消定时|取消定时推送|删除定时|删除定时推送)\s*(\d+)\s*$/i);
      if (cm) {
        const rid = parseInt(cm[2], 10);
        await deleteSchedule(rid, botId);
        await ctx.sendText('[OK] 已取消定时推送 #' + rid + '。');
        return true;
      }

      // ── 添加定时 ──
      cm = rest.match(/^(定时|schedule|定时推送)\s+(.+?)\s+(.+)$/i);
      if (cm) {
        const timeDesc = cm[2];
        const content = cm[3];
        await addSchedule(botId, peerId, { content, ai_enabled: false, ai_prompt: '', time_desc: timeDesc });
        const parsed = parseTime(timeDesc);
        const dt = new Date(parsed.target * 1000);
        const ds = dt.toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' });
        const tag = parsed.repeatType === 'daily' ? ',每天重复' : parsed.repeatType === 'weekly' ? ',每周重复' : '';
        await ctx.sendText('[OK] 已设定定时推送:' + ds + tag + '\n内容:' + content + '\n(如需 AI 润色,请在插件设置中开启或调用 webhook 时传 ai:true)');
        return true;
      }

      // 未匹配子命令 → 输出帮助
      await ctx.sendText(
        '[Push] Webhook 推送 使用说明:\n' +
        '  推送 绑定            → 生成专属推送链接(绑定本会话)\n' +
        '  推送 重绑            → 重新生成链接(旧链接失效)\n' +
        '  推送 链接            → 查看当前链接\n' +
        '  推送 解绑            → 解除推送通道\n' +
        '  推送 定时 <时间> <内容>  → 添加定时推送\n' +
        '  推送 定时列表        → 查看定时推送\n' +
        '  推送 取消定时 <编号>  → 取消定时推送\n\n' +
        '时间格式:30分钟后 / 1小时后 / 每天9点 / 每周一8点 / 明天9点 / 2026-07-20 14:30'
      );
      return true;
    } catch (e) {
      console.error('[push] onMessage 出错:', e.message);
      try { await ctx.sendText('[推送] 操作失败:' + e.message); } catch (_) {}
      return true;
    }
  },
};