码桶
发现社区成员的开源项目
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;
}
},
};