码桶
发现社区成员的开源项目
index.js23.4 KB
/**
* 插件:定时提醒
* --------------------------------------------------
* 发送「提醒 明天下午3点 开会」即可设定定时提醒,到时自动推送消息。
* 支持:绝对时间、相对时间、每天/每周重复、列出/删除提醒。
*
* 时间格式示例:
* 明天 下午3点 → 明天 15:00
* 明天9:00 → 明天 09:00
* 下午3点 → 今天 15:00
* 5分钟后 → now + 5min
* 30分钟后 → now + 30min
* 1小时后 → now + 1h
* 每天 9点 → 每天 09:00 重复
* 每周一 8点 → 每周一 08:00 重复
* 2026-07-20 14:30 → 绝对时间
*
* 管理指令:
* 提醒列表 → 查看所有提醒
* 删除提醒 3 → 删除 id=3 的提醒
*/
const db = require('../../lib/db');
const ILink = require('../../lib/ilink');
const cron = require('../../lib/cron');
// ==================== 建表 ====================
let tableReady = false;
async function ensureTable() {
if (tableReady) return;
try {
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
)`);
// 兼容旧表:补齐 action / email_to 列
try { await db.exec("ALTER TABLE reminders ADD COLUMN action VARCHAR(16) NOT NULL DEFAULT 'message'"); } catch (_) {}
try { await db.exec("ALTER TABLE reminders ADD COLUMN email_to VARCHAR(256) NOT NULL DEFAULT ''"); } catch (_) {}
tableReady = true;
} catch (e) {
console.error('[reminder] 建表失败:', e.message);
}
}
// ==================== 时间解析器 ====================
const WEEKDAY_MAP = { '一': 1, '二': 2, '三': 3, '四': 4, '五': 5, '六': 6, '日': 7, '天': 7 };
/** 中文/阿拉伯数字 → 整数(支持 0~99:八 / 十二 / 二十 / 二十三 / 两 / 半) */
function cnToNum(s) {
if (s == null) return NaN;
s = String(s).trim();
if (s === '半') return 30;
if (/^\d+$/.test(s)) return parseInt(s, 10);
const MAP = { '零': 0, '一': 1, '二': 2, '两': 2, '三': 3, '四': 4, '五': 5, '六': 6, '七': 7, '八': 8, '九': 9 };
if (s === '十') return 10;
if (s.includes('十')) {
const [a, b] = s.split('十');
const tens = a ? (MAP[a] || 0) : 1;
const ones = b ? (MAP[b] || 0) : 0;
return tens * 10 + ones;
}
if (Object.prototype.hasOwnProperty.call(MAP, s)) return MAP[s];
return NaN;
}
/** 解析“钟表时间”:处理 早上/上午/中午/下午/晚上/傍晚/凌晨 + 数字或中文 点/分/半
* 例:早上八点 / 下午3点 / 15:30 / 三点半 / 8点30分 → {hour,min}(24h) */
function parseClock(input) {
let s = String(input || '').trim();
let period = '';
const pm = s.match(/^(凌晨|早上|上午|中午|下午|晚上|傍晚|白天)/);
if (pm) { period = pm[1]; s = s.slice(pm[1].length).trim(); }
let m = s.match(/^(\d{1,2})[::.](\d{1,2})$/);
if (m) return { hour: adjustHour(+m[1], period), min: +m[2] };
m = s.match(/^([零一二两三四五六七八九十\d]+)\s*点\s*(半|([零一二两三四五六七八九十\d]+)\s*分?)?$/);
if (m) {
let hour = cnToNum(m[1]);
let min = 0;
if (m[2] === '半') min = 30;
else if (m[3]) min = cnToNum(m[3]);
if (isNaN(hour) || isNaN(min)) return null;
return { hour: adjustHour(hour, period), min };
}
return null;
}
function parseTime(str) {
str = (str || '').trim();
const now = new Date();
let target = new Date(now);
let repeatType = 'once';
let repeatRule = '';
// ── 绝对日期时间: 2026-07-20 14:30 / 2026年7月20日 15:30 / 2026/7/20 9:00 ──
let m = str.match(/^(\d{4})[-/年.](\d{1,2})[-/月.](\d{1,2})(?:日)?\s+(\d{1,2})[::.](\d{1,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 };
}
// ── 相对时间: 30分钟后 / 两小时以后 / 半小时后 / 3天后 / 30分钟 ──
m = str.match(/^([零一二两三四五六七八九十半\d]+)\s*(分钟|分|小时|时|天|日)\s*(后|以后|之后)?$/);
if (m) {
let n = cnToNum(m[1]);
let unit = m[2];
if (m[1] === '半') { n = 30; unit = '分钟'; } // 半小时 / 半小时后 = 30 分钟
if (isNaN(n)) return null;
if (unit === '分钟' || unit === '分') target.setMinutes(target.getMinutes() + n);
else if (unit === '小时' || unit === '时') target.setHours(target.getHours() + n);
else if (unit === '天' || unit === '日') target.setDate(target.getDate() + n);
return { target: Math.floor(target.getTime() / 1000), repeatType, repeatRule };
}
// ── 日期前缀: 每天 / 每周X / 明天 / 明早 / 后天 / 大后天 / 今天 ──
let dayOffset = 0;
let rest = str;
let repeatWeekly = false;
if ((m = str.match(/^每天\s*/))) { repeatType = 'daily'; rest = str.slice(m[0].length); }
else if ((m = str.match(/^每周([一二三四五六日天])\s*/))) { repeatType = 'weekly'; repeatRule = String(WEEKDAY_MAP[m[1]]); repeatWeekly = true; rest = str.slice(m[0].length); }
else if ((m = str.match(/^每月\s*(\d{1,2})\s*(号|日)?\s*/))) {
repeatType = 'monthly';
let day = parseInt(m[1], 10);
if (day < 1) day = 1;
if (day > 28) day = 28; // 安全上限:避免小月/2月无该日
repeatRule = String(day);
rest = str.slice(m[0].length);
}
else if ((m = str.match(/^明早\s*/))) { dayOffset = 1; rest = str.slice(m[0].length); }
else if ((m = str.match(/^明天\s*/))) { dayOffset = 1; rest = str.slice(m[0].length); }
else if ((m = str.match(/^大后天\s*/))) { dayOffset = 3; rest = str.slice(m[0].length); }
else if ((m = str.match(/^后天\s*/))) { dayOffset = 2; rest = str.slice(m[0].length); }
else if ((m = str.match(/^今天\s*/))) { dayOffset = 0; rest = str.slice(m[0].length); }
if (repeatType === 'daily' || repeatType === 'weekly') {
const clk = parseClock(rest) || { hour: 9, min: 0 };
target.setHours(clk.hour, clk.min, 0, 0);
if (repeatWeekly) {
const wday = +repeatRule;
let diff = wday - target.getDay();
if (diff < 0) diff += 7;
if (diff > 0) target.setDate(target.getDate() + diff);
} else if (target <= now) {
target.setDate(target.getDate() + 1);
}
return { target: Math.floor(target.getTime() / 1000), repeatType, repeatRule };
}
if (repeatType === 'monthly') {
const day = parseInt(repeatRule, 10) || 1;
const clk = parseClock(rest) || { hour: 9, min: 0 };
target.setDate(day);
target.setHours(clk.hour, clk.min, 0, 0);
if (target <= now) {
target.setMonth(target.getMonth() + 1);
const dim = new Date(target.getFullYear(), target.getMonth() + 1, 0).getDate();
if (day > dim) target.setDate(dim);
}
return { target: Math.floor(target.getTime() / 1000), repeatType, repeatRule };
}
if (dayOffset !== 0) {
target.setDate(target.getDate() + dayOffset);
const clk = parseClock(rest);
if (clk) target.setHours(clk.hour, clk.min, 0, 0);
else target.setHours(9, 0, 0, 0);
return { target: Math.floor(target.getTime() / 1000), repeatType, repeatRule };
}
// ── 裸钟表时间(今天,过期则顺延明天): 下午3点 / 三点半 / 15:30 / 八点 ──
const clk = parseClock(str);
if (clk) {
target.setHours(clk.hour, clk.min, 0, 0);
if (target <= now) target.setDate(target.getDate() + 1);
return { target: Math.floor(target.getTime() / 1000), repeatType, repeatRule };
}
return null;
}
function adjustHour(hour, period) {
if (period) {
if (period.includes('早') || period.includes('上') || 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;
}
/** 解析提醒内容里的“动作”:普通消息 / 发邮件
* 命中条件:内容中同时出现邮箱地址与“发邮件/邮箱”等关键词。
* 返回 { type:'message' } 或 { type:'email', to, body } */
function parseAction(content) {
const emailRe = /([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/;
const em = content.match(emailRe);
const hasKeyword = /(发\s*个?\s*邮件|给\s*邮箱|邮件提醒|邮箱|email|mail)/i.test(content);
if (!em || !hasKeyword) return { type: 'message' };
const to = em[1];
let body = content.replace(emailRe, '');
body = body.replace(/(发\s*个?\s*邮件\s*给?|给\s*邮箱|邮件提醒|邮箱|email|mail)/gi, '');
body = body.replace(/\s+/g, ' ').trim();
return { type: 'email', to, body: body || content };
}
/** 触发邮件型提醒:到点时调用邮箱助手插件发送邮件 */
async function fireEmail(r) {
try {
const mailMod = require('../../plugins/mail');
const to = (r.email_to || '').trim();
if (!to) throw new Error('缺少收件邮箱');
const subject = '⏰ 定时提醒';
const text = (r.content && r.content.trim()) ? r.content : '(无内容)';
const res = await mailMod.sendMail(r.bot_id, { to, subject, text });
console.log('[reminder] 邮件提醒已发送:', to, JSON.stringify(res));
return true;
} catch (e) {
console.error('[reminder] 邮件提醒发送失败 id=' + r.id + ':', e.message);
return false;
}
}
// ==================== 后台调度器 ====================
// 与 smart 插件共用 reminders 表:用 global 单例标志保证整个进程只启动一个
// 30s 定时器(谁先被调用谁启动),避免 smart / reminder 双调度器导致重复提醒。
function startScheduler() {
if (global.__reminderSchedulerStarted) return;
global.__reminderSchedulerStarted = true;
// 每 30 秒检查一次到期的提醒
setInterval(async () => {
try {
const now = Math.floor(Date.now() / 1000);
const due = await db.rows(
"SELECT r.*, b.bot_token, b.base_url FROM reminders r JOIN bots b ON r.bot_id = b.id WHERE r.remind_at <= ? AND r.fired = 0 AND COALESCE(r.enabled,1) = 1",
[now]
);
if (due.length > 0) {
console.log('[reminder] 调度检查: 发现', due.length, '条到期提醒,now=', now);
}
for (const r of due) {
try {
// ── 勿扰 / 推送设置:仅拦截微信消息型提醒(邮件不拦)──
if (r.action !== 'email') {
const dnd = require('../../lib/dnd');
const dndPeer = r.peer_id || await resolveTargetPeer(r.bot_id);
if (dndPeer && await dnd.shouldBlock(r.bot_id, dndPeer, 'reminder')) {
const cfg = await dnd.getDnd(r.bot_id, dndPeer);
const endTs = Math.floor(dnd.dndEndDate(cfg).getTime() / 1000);
// 延后到勿扰结束再发(循环类下次仍可能被拦,符合预期)
await db.exec('UPDATE reminders SET remind_at = ?, fired = 0 WHERE id = ?', [endTs, r.id]);
continue;
}
}
let sent = false;
// ── 邮件型提醒:到点发邮件(不发微信) ──
if (r.action === 'email') {
sent = await fireEmail(r);
} else {
// 不信任存储的过期 token,始终实时解析最新有效的 context_token
const ctxToken = await resolveCtx(r.bot_id, r.peer_id);
if (!ctxToken) {
console.error('[reminder] 无法获取 context_token,bot_id=', r.bot_id, 'peer_id=', r.peer_id);
continue;
}
// 未指定目标会话时,发往机器人最近一次收到消息的会话(即「最近会话」)
const sendPeer = r.peer_id || await resolveTargetPeer(r.bot_id);
if (!sendPeer) {
console.error('[reminder] 无法确定发送目标会话,bot_id=', r.bot_id, 'peer_id=', r.peer_id);
continue;
}
// 联动其他插件:把提醒内容当作指令分发给 rss/每日简报 等插件,
// 未命中任何插件时再退回「⏰ 提醒:内容」纯文本兜底。
const pluginsLib = require('../../lib/plugins');
const bot = await db.row('SELECT * FROM bots WHERE id=?', [r.bot_id]);
let handled = false;
if (bot) {
handled = await pluginsLib.dispatchReminder(bot, r.content, sendPeer, ctxToken);
}
if (!handled) {
const il = new ILink({ bot_token: r.bot_token, base_url: r.base_url });
const resp = await il.sendMessage(sendPeer, ctxToken, '⏰ 提醒:' + r.content);
console.log('[reminder] sendMessage 响应:', JSON.stringify(resp));
if ((resp.ret ?? -1) !== 0) {
console.error('[reminder] 发送失败, ret=', resp.ret, 'msg=', resp.msg || resp.message, 'full=', JSON.stringify(resp));
continue;
}
// 记录发送的消息
const reminderText = '⏰ 提醒:' + r.content;
await db.exec(
'INSERT INTO messages (bot_id, direction, peer_id, content, msg_type, context_token, created_at) VALUES (?,?,?,?,?,?,?)',
[r.bot_id, 'out', sendPeer, reminderText, 'text', ctxToken, now]
);
}
}
// 微信消息型提醒到达此处即视为已成功处理(发送或插件分发成功),
// 进入「处理重复 / 标记完成」流程(修复此前 sent 恒为 false 导致循环重发的问题)
if (r.action !== 'email') sent = true;
if (!sent) continue;
// 处理重复
if (r.repeat_type === 'daily') {
const next = r.remind_at + 86400;
await db.exec('UPDATE reminders SET remind_at = ?, fired = 0 WHERE id = ?', [next, r.id]);
} else if (r.repeat_type === 'weekly') {
const next = r.remind_at + 604800;
await db.exec('UPDATE reminders SET remind_at = ?, fired = 0 WHERE id = ?', [next, r.id]);
} else if (r.repeat_type === 'monthly') {
const day = parseInt(r.repeat_rule, 10) || 1;
const base = new Date(r.remind_at * 1000);
let next = new Date(base);
next.setMonth(next.getMonth() + 1, day);
const dim = new Date(next.getFullYear(), next.getMonth() + 1, 0).getDate();
if (day > dim) next.setDate(dim);
next.setHours(0, 0, 0, 0);
await db.exec('UPDATE reminders SET remind_at = ?, fired = 0 WHERE id = ?', [Math.floor(next.getTime() / 1000), r.id]);
} else if (r.repeat_type === 'interval') {
// 间隔模式: rule 形如 "min:5" / "hour:2"
const m = (r.repeat_rule || '').match(/^(min|hour):(\d+)$/);
const step = m ? (m[1] === 'min' ? parseInt(m[2], 10) * 60 : parseInt(m[2], 10) * 3600) : 60;
const next = now + step;
await db.exec('UPDATE reminders SET remind_at = ?, fired = 0 WHERE id = ?', [next, r.id]);
} else if (r.repeat_type === 'cron') {
// 定时模式: rule 为 5 字段 cron 表达式
try {
const nxt = cron.nextCronTime(r.repeat_rule, now);
if (nxt) {
await db.exec('UPDATE reminders SET remind_at = ?, fired = 0 WHERE id = ?', [nxt, r.id]);
} else {
await db.exec('UPDATE reminders SET fired = 1 WHERE id = ?', [r.id]);
}
} catch (e) {
console.error('[reminder] cron 计算失败, 停止任务 id=' + r.id + ':', e.message);
await db.exec('UPDATE reminders SET fired = 1 WHERE id = ?', [r.id]);
}
} else {
// 一次性任务触发后直接删除,不再保留「已触发」记录
await db.exec('DELETE FROM reminders WHERE id = ?', [r.id]);
}
} catch (err) {
console.error('[reminder] 处理提醒 id=' + r.id + ' 失败:', err.message);
}
}
} catch (err) {
console.error('[reminder] 调度器错误:', err.message);
}
}, 30000);
}
/** 获取最新的有效 context_token */
async function resolveCtx(botId, peerId) {
// 策略1: 从该 peer 最近一条入站消息获取 token(最准确)
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;
}
// 策略2: 从该 bot 最近一条入站消息获取 token(不限 peer)
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;
// 策略3: 从 bots 表全局 token(worker.js 每次入站消息都会更新)
const bot = await db.row('SELECT context_token FROM bots WHERE id = ?', [botId]);
if (bot && bot.context_token) return bot.context_token;
// 策略4: 从该 bot 最近一条出站消息获取 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;
}
/** 解析发送目标会话:未指定 peer 时,发往机器人最近一次收到消息的会话 */
async function resolveTargetPeer(botId) {
const latestIn = 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]
);
if (latestIn && latestIn.peer_id) return latestIn.peer_id;
const latestOut = await db.row(
"SELECT peer_id FROM messages WHERE bot_id=? AND direction='out' AND peer_id IS NOT NULL AND peer_id != '' ORDER BY id DESC LIMIT 1",
[botId]
);
return latestOut ? latestOut.peer_id : null;
}
// ==================== 插件入口 ====================
module.exports = {
meta: {
id: 'reminder',
name: '定时提醒',
version: '1.0.0',
author: '奶狗',
category: '工具',
description: '设置定时提醒,到时间自动推送消息或发送邮件。支持相对/绝对时间(含中文数字、明天早上八点等)、每天/每周重复。',
entry: 'reminder/index.js',
},
async onMessage(msg, ctx) {
await ensureTable();
startScheduler();
const text = (msg.content || '').trim();
if (!text) return false;
const botId = ctx.bot.id;
const peerId = msg.peer_id;
const ctxToken = msg.context_token;
// ── 提醒列表 ──
if (text === '提醒列表') {
const items = await db.rows(
'SELECT id, content, remind_at, repeat_type, action, email_to FROM reminders WHERE bot_id=? AND peer_id=? AND fired=0 ORDER BY remind_at ASC',
[botId, peerId]
);
if (!items.length) {
await ctx.sendText('[提醒] 当前没有待执行的提醒。\n用法:提醒 明天下午3点 开会');
return true;
}
let reply = '[提醒] 待执行列表:\n';
items.forEach((r, i) => {
const dt = new Date(r.remind_at * 1000);
const ds = dt.toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' });
const tag = r.action === 'email' ? '[邮件]' : r.repeat_type === 'daily' ? '[每天]' : r.repeat_type === 'weekly' ? '[每周]' : '';
const dest = r.action === 'email' && r.email_to ? ` → ${r.email_to}` : '';
reply += `${r.id}. ${tag}${ds}${dest} — ${r.content}\n`;
});
reply += '\n发送「删除提醒 编号」可取消。';
await ctx.sendText(reply);
return true;
}
// ── 删除提醒 ──
let m = text.match(/^删除提醒\s+(\d+)\s*$/);
if (m) {
const rid = parseInt(m[1]);
const item = await db.row('SELECT id FROM reminders WHERE id=? AND bot_id=? AND peer_id=?', [rid, botId, peerId]);
if (!item) {
await ctx.sendText('[提醒] 未找到编号 ' + rid + ' 的提醒。');
} else {
await db.exec('DELETE FROM reminders WHERE id = ?', [rid]);
await ctx.sendText('[提醒] 已删除提醒 #' + rid + '。');
}
return true;
}
// ── 设置提醒: 提醒 <时间> <内容> ──
m = text.match(/^提醒\s+(.+?)\s+(.+)$/);
if (!m) return false;
const timeStr = m[1];
const content = m[2];
const parsed = parseTime(timeStr);
if (!parsed) {
await ctx.sendText(
'[提醒] 无法识别时间格式。\n支持格式:\n 明天早上八点 / 明早8点 / 下午3点\n 30分钟后 / 两小时后 / 半小时后 / 3天后\n 每天 9点 / 每周一 8点 / 每月1号 9点\n 2026-07-20 14:30\n\n发邮件:提醒 明早8点 给邮箱 [email protected] 发邮件 周报内容'
);
return true;
}
if (parsed.target <= Math.floor(Date.now() / 1000)) {
await ctx.sendText('[提醒] 时间已过,请设置未来的时间。');
return true;
}
// 解析动作(普通消息 / 发邮件)
const act = parseAction(content);
let action = 'message';
let emailTo = '';
let storeContent = content;
if (act.type === 'email') {
if (!act.to) {
await ctx.sendText('[提醒] 请在指令里附上收件邮箱,例如:\n提醒 明早8点 给邮箱 [email protected] 发邮件 周报内容');
return true;
}
action = 'email';
emailTo = act.to;
storeContent = act.body || content;
}
await db.exec(
'INSERT INTO reminders (bot_id, peer_id, context_token, content, remind_at, repeat_type, repeat_rule, action, email_to, created_at, fired) VALUES (?,?,?,?,?,?,?,?,?,?,0)',
[botId, peerId, ctxToken || null, storeContent, parsed.target, parsed.repeatType, parsed.repeatRule || '', action, emailTo, Math.floor(Date.now() / 1000)]
);
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' ? ',每周重复' : '';
const extra = action === 'email' ? `\n📧 将以邮件发送到:${emailTo}` : '';
await ctx.sendText(`[提醒] 已设定:${ds}${tag}${extra}\n内容:${storeContent}`);
return true;
},
};
// 导出解析函数,便于测试 / 其他模块复用
module.exports.parseTime = parseTime;
module.exports.parseAction = parseAction;
module.exports.cnToNum = cnToNum;
module.exports.parseClock = parseClock;