码桶

发现社区成员的开源项目

奶狗 /

ng-webot

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