码桶

发现社区成员的开源项目

奶狗 /

ng-webot

公开
main
index.js23 KB
/**
 * 插件:邮箱助手(SMTP 发件 + IMAP 收件提示 + 智能助手收发)
 * --------------------------------------------------
 * 用途:
 *   1) 配置 SMTP / IMAP 服务器后,机器人即可帮你「发邮件」「收邮件」。
 *   2) 收到新邮件时自动在微信里提示(可选 AI 摘要)。
 *   3) 接入智能助手 function calling:直接用自然语言让 AI 发邮件、看最近邮件、读某封邮件。
 *
 * 在微信里使用(指令):
 *   邮箱 / 邮件                     → 查看使用说明与配置状态
 *   邮件 发送 收件人|主题|正文       → 发送一封邮件(分隔符 | 或 |)
 *   邮件 收取 [数量]                → 拉取最近若干封邮件(默认 5)
 *   邮件 最新                       → 查看最新一封邮件
 *
 * 配置(机器人 → 插件设置 → 邮箱助手):
 *   SMTP(发件):smtp_host / smtp_port / smtp_secure / smtp_user / smtp_pass / smtp_from
 *   IMAP(收件):imap_host / imap_port / imap_user / imap_pass(账号密码留空则复用 SMTP)
 *   收到新邮件提示:notify(开关)+ ai_summary(AI 摘要开关)+ poll_interval(轮询秒数)
 *
 * 注意:多数邮箱(QQ / 163 / Gmail 等)需使用「授权码 / 应用专用密码」而非登录密码。
 */
const db = require('../../lib/db');
const ILink = require('../../lib/ilink');

const nodemailer = require('nodemailer');
const { ImapFlow } = require('imapflow');
const { simpleParser } = require('mailparser');

// ==================== 建表(记录每个机器人已读到的最大 UID,用于新邮件检测) ====================
let tablesReady = false;
async function ensureTables() {
  if (tablesReady) return;
  try {
    await db.exec(`CREATE TABLE IF NOT EXISTS mail_state (
      bot_id INTEGER PRIMARY KEY,
      last_uid INTEGER NOT NULL DEFAULT 0,
      updated_at INTEGER NOT NULL DEFAULT 0
    )`);
    tablesReady = true;
  } catch (e) {
    console.error('[mail] 建表失败:', e.message);
  }
}

// ==================== 配置读取 ====================
async function loadConfig(botId) {
  const rows = await db.rows(
    'SELECT config_key, config_value FROM plugin_settings WHERE bot_id=? AND plugin_id=?',
    [botId, 'mail']
  );
  const c = {};
  rows.forEach(r => { c[r.config_key] = r.config_value; });

  const smtpHost = (c.smtp_host || '').trim();
  const smtpUser = (c.smtp_user || '').trim();
  const smtpPass = (c.smtp_pass || '').trim();
  const smtpPort = parseInt(c.smtp_port, 10) || 465;
  const smtpSecure = c.smtp_secure !== undefined ? c.smtp_secure === '1' : (smtpPort === 465);

  const imapHost = (c.imap_host || '').trim();
  const imapUser = (c.imap_user || '').trim() || smtpUser;
  const imapPass = (c.imap_pass || '').trim() || smtpPass;
  const imapPort = parseInt(c.imap_port, 10) || 993;

  return {
    smtp: { host: smtpHost, port: smtpPort, secure: smtpSecure, user: smtpUser, pass: smtpPass, from: (c.smtp_from || '').trim() || smtpUser },
    imap: { host: imapHost, port: imapPort, secure: true, user: imapUser, pass: imapPass },
    notify: c.notify === '1',
    aiSummary: c.ai_summary === '1',
    pollInterval: Math.max(30, parseInt(c.poll_interval, 10) || 120),
    hasSmtp: !!(smtpHost && smtpUser && smtpPass),
    hasImap: !!(imapHost && imapUser && imapPass),
  };
}

// ==================== 发件(SMTP / nodemailer) ====================
async function sendMail(botId, { to, subject, text, html }) {
  const cfg = await loadConfig(botId);
  if (!cfg.hasSmtp) throw new Error('尚未配置 SMTP 发件服务器,请在插件设置中填写 smtp_host / smtp_user / smtp_pass');
  to = String(to || '').trim();
  if (!to) throw new Error('缺少收件人');
  if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(to.split(/[,;]/)[0].trim())) throw new Error('收件人邮箱格式不正确:' + to);

  // QQ/163 等邮箱 SMTP 要求发件人必须与授权账号一致 → 自动修正 from 中的邮箱部分
  let fromAddr = cfg.smtp.from || cfg.smtp.user;
  const fromEmailMatch = fromAddr.match(/<([^>@]+@[^>]+)>/) || fromAddr.match(/^([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})$/);
  if (fromEmailMatch && fromEmailMatch[1].toLowerCase() !== cfg.smtp.user.toLowerCase()) {
    const displayName = fromAddr.match(/^([^<]*)\s*</);
    fromAddr = displayName && displayName[1].trim() ? `${displayName[1].trim()} <${cfg.smtp.user}>` : cfg.smtp.user;
  }

  const transporter = nodemailer.createTransport({
    host: cfg.smtp.host,
    port: cfg.smtp.port,
    secure: cfg.smtp.secure,
    auth: { user: cfg.smtp.user, pass: cfg.smtp.pass },
  });
  const info = await transporter.sendMail({
    from: fromAddr,
    to,
    subject: String(subject || '(无主题)'),
    text: text != null ? String(text) : undefined,
    html: html != null ? String(html) : undefined,
  });
  return { messageId: info.messageId, accepted: info.accepted, to, subject };
}

// ==================== 收件(IMAP / imapflow) ====================
/** 建立 IMAP 连接(调用方负责 logout) */
async function imapConnect(cfg) {
  const client = new ImapFlow({
    host: cfg.imap.host,
    port: cfg.imap.port,
    secure: cfg.imap.secure,
    auth: { user: cfg.imap.user, pass: cfg.imap.pass },
    logger: false,
  });
  await client.connect();
  return client;
}

/** 把一封邮件的原始 source 解析成简洁对象 */
async function parseMail(source, envelope, uid) {
  let parsed = {};
  try { parsed = await simpleParser(source); } catch (_) { /* ignore */ }
  const fromAddr = (envelope && envelope.from && envelope.from[0]) || {};
  return {
    uid,
    subject: (envelope && envelope.subject) || parsed.subject || '(无主题)',
    from: fromAddr.name ? `${fromAddr.name} <${fromAddr.address}>` : (fromAddr.address || (parsed.from && parsed.from.text) || '未知'),
    fromAddress: fromAddr.address || '',
    date: (envelope && envelope.date) || parsed.date || null,
    text: (parsed.text || '').trim(),
  };
}

/** 拉取最近 N 封邮件(返回按时间倒序) */
async function fetchRecent(botId, limit = 5) {
  const cfg = await loadConfig(botId);
  if (!cfg.hasImap) throw new Error('尚未配置 IMAP 收件服务器,请在插件设置中填写 imap_host(账号密码可复用 SMTP)');
  limit = Math.max(1, Math.min(20, parseInt(limit, 10) || 5));
  const client = await imapConnect(cfg);
  const out = [];
  const lock = await client.getMailboxLock('INBOX');
  try {
    const total = client.mailbox.exists || 0;
    if (total > 0) {
      const start = Math.max(1, total - limit + 1);
      for await (const msg of client.fetch(`${start}:*`, { envelope: true, source: true, uid: true })) {
        out.push(await parseMail(msg.source, msg.envelope, msg.uid));
      }
    }
  } finally {
    lock.release();
  }
  await client.logout().catch(() => {});
  return out.reverse(); // 最新在前
}

// ==================== peer 解析与发送(复用 push 的思路) ====================
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 : '';
}

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]);
  return bot && bot.context_token ? bot.context_token : null;
}

async function sendToPeer(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 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, Math.floor(Date.now() / 1000)]
  );
}

// ==================== 新邮件提示调度器 ====================
let schedulerStarted = false;
const lastPollAt = new Map(); // botId -> ms

function startScheduler() {
  if (schedulerStarted || global.__mailSchedulerStarted) return;
  schedulerStarted = true;
  global.__mailSchedulerStarted = true;
  setInterval(pollAllBots, 30000).unref();
  console.log('[mail] 新邮件提示调度器已启动');
}

async function pollAllBots() {
  try {
    await ensureTables();
    // 只轮询已启用本插件的机器人
    const bots = await db.rows("SELECT bot_id FROM plugins WHERE market_id='mail' AND enabled=1");
    const now = Date.now();
    // 清理已停用/删除 bot 的 lastPollAt 条目(防内存泄漏)
    const activeBotIds = new Set(bots.map(b => b.bot_id));
    for (const botId of lastPollAt.keys()) {
      if (!activeBotIds.has(botId)) lastPollAt.delete(botId);
    }
    for (const b of bots) {
      const botId = b.bot_id;
      try {
        const cfg = await loadConfig(botId);
        if (!cfg.notify || !cfg.hasImap) continue;
        const last = lastPollAt.get(botId) || 0;
        if (now - last < cfg.pollInterval * 1000) continue;
        lastPollAt.set(botId, now);
        await pollBot(botId, cfg);
      } catch (e) {
        console.error('[mail] 机器人', botId, '轮询失败:', e.message);
      }
    }
  } catch (e) {
    console.error('[mail] 调度错误:', e.message);
  }
}

async function pollBot(botId, cfg) {
  const state = await db.row('SELECT last_uid FROM mail_state WHERE bot_id=?', [botId]);
  const lastUid = state ? state.last_uid : 0;

  const client = await imapConnect(cfg);
  const lock = await client.getMailboxLock('INBOX');
  const fresh = [];
  let maxUid = lastUid;
  try {
    const total = client.mailbox.exists || 0;
    if (total > 0) {
      // 首次运行:只记录当前最大 UID,不推送历史邮件,避免刷屏
      if (lastUid === 0) {
        for await (const msg of client.fetch('1:*', { uid: true })) {
          if (msg.uid > maxUid) maxUid = msg.uid;
        }
      } else {
        for await (const msg of client.fetch({ uid: `${lastUid + 1}:*` }, { envelope: true, source: true, uid: true })) {
          if (msg.uid <= lastUid) continue;
          if (msg.uid > maxUid) maxUid = msg.uid;
          fresh.push(await parseMail(msg.source, msg.envelope, msg.uid));
        }
      }
    }
  } finally {
    lock.release();
  }
  await client.logout().catch(() => {});

  const now = Math.floor(Date.now() / 1000);
  await db.exec(
    'INSERT INTO mail_state (bot_id, last_uid, updated_at) VALUES (?,?,?) ON CONFLICT(bot_id) DO UPDATE SET last_uid=excluded.last_uid, updated_at=excluded.updated_at',
    [botId, maxUid, now]
  );

  if (!fresh.length) return;
  const peer = await defaultPeer(botId);
  if (!peer) return;

  for (const m of fresh.sort((a, b) => a.uid - b.uid)) {
    let body = m.text;
    if (cfg.aiSummary && body) {
      body = await aiSummarize(botId, m);
    } else if (body && body.length > 400) {
      body = body.slice(0, 400) + '…';
    }
    const dateStr = m.date ? new Date(m.date).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : '';
    const lines = [
      '[Mail] 收到新邮件',
      '发件人:' + m.from,
      '主题:' + m.subject,
      dateStr ? '时间:' + dateStr : '',
      '',
      body || '(无正文)',
    ].filter(Boolean);
    try { await sendToPeer(botId, peer, lines.join('\n')); } catch (e) {
      console.error('[mail] 推送新邮件失败:', e.message);
    }
  }
}

/** 用智能助手对邮件做摘要,失败回退原文截断 */
async function aiSummarize(botId, mail) {
  try {
    const pluginsLib = require('../../lib/plugins');
    const bot = await db.row('SELECT user_id FROM bots WHERE id=?', [botId]);
    const prompt = `请用中文简明总结下面这封邮件的核心内容(3 句以内,突出要点和需要处理的事项):\n\n主题:${mail.subject}\n发件人:${mail.from}\n正文:\n${(mail.text || '').slice(0, 3000)}`;
    const text = await pluginsLib.callAssistant({ botId, userId: bot ? bot.user_id : null, prompt });
    if (text && text.trim()) return '[Summary] 摘要:' + text.trim();
  } catch (e) {
    console.error('[mail] AI 摘要失败,回退原文:', e.message);
  }
  const t = mail.text || '';
  return t.length > 400 ? t.slice(0, 400) + '…' : t;
}

// ==================== 智能助手 AI 工具(function calling) ====================
const aiTools = [
  {
    type: 'function',
    function: {
      name: 'send_email',
      description: '发送一封电子邮件。当用户说“发邮件给XX”“给XX发个邮件”“帮我写封邮件发出去”等意图时调用。',
      parameters: {
        type: 'object',
        properties: {
          to: { type: 'string', description: '收件人邮箱地址,多个用逗号分隔' },
          subject: { type: 'string', description: '邮件主题' },
          body: { type: 'string', description: '邮件正文(纯文本)' },
        },
        required: ['to', 'subject', 'body'],
      },
    },
  },
  {
    type: 'function',
    function: {
      name: 'list_recent_emails',
      description: '查看收件箱最近的邮件列表(主题、发件人、时间)。当用户说“看看我的邮件”“最近有什么邮件”“收件箱”时调用。',
      parameters: {
        type: 'object',
        properties: {
          limit: { type: 'integer', description: '要查看的邮件数量,默认 5,最多 20' },
        },
      },
    },
  },
  {
    type: 'function',
    function: {
      name: 'read_email',
      description: '读取某封邮件的完整正文。当用户说“读第X封邮件”“看看第一封邮件写了什么”时调用;index 为 list_recent_emails 列表中的序号(从 1 开始)。',
      parameters: {
        type: 'object',
        properties: {
          index: { type: 'integer', description: '邮件序号,从 1 开始(1=最新一封)' },
        },
        required: ['index'],
      },
    },
  },
];

async function handleAiTool(name, args, ctx) {
  const botId = ctx.bot.id;
  switch (name) {
    case 'send_email': {
      try {
        const r = await sendMail(botId, { to: args.to, subject: args.subject, text: args.body });
        return `[OK] 邮件已发送给 ${r.to},主题「${r.subject}」。`;
      } catch (e) {
        return '发送邮件失败:' + e.message;
      }
    }
    case 'list_recent_emails': {
      try {
        const list = await fetchRecent(botId, args.limit || 5);
        if (!list.length) return '收件箱是空的。';
        const lines = list.map((m, i) => {
          const ds = m.date ? new Date(m.date).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : '';
          return `${i + 1}. 【${m.subject}】 来自 ${m.from}${ds ? ' · ' + ds : ''}`;
        });
        return '[Mail] 最近邮件:\n' + lines.join('\n') + '\n\n可说“读第 N 封邮件”查看正文。';
      } catch (e) {
        return '获取邮件失败:' + e.message;
      }
    }
    case 'read_email': {
      try {
        const idx = Math.max(1, parseInt(args.index, 10) || 1);
        const list = await fetchRecent(botId, Math.max(idx, 5));
        const m = list[idx - 1];
        if (!m) return `没有第 ${idx} 封邮件。`;
        let body = m.text || '(无正文)';
        if (body.length > 1500) body = body.slice(0, 1500) + '\n…(内容较长已截断)';
        const ds = m.date ? new Date(m.date).toLocaleString('zh-CN') : '';
        return `[Mail] 《${m.subject}》\n发件人:${m.from}${ds ? '\n时间:' + ds : ''}\n\n${body}`;
      } catch (e) {
        return '读取邮件失败:' + e.message;
      }
    }
    default:
      return `未知邮件工具:${name}`;
  }
}

// ==================== 插件入口 ====================
const PREFIX_RE = /^(邮箱|邮件|email|mail)\s*/i;

module.exports = {
  meta: {
    id: 'mail',
    name: '邮箱助手',
    version: '1.0.0',
    author: '奶狗',
    category: '消息处理',
    description: '配置 SMTP/IMAP 后,机器人可帮你收发邮件:智能助手支持自然语言「发邮件/看邮件/读邮件」,收到新邮件时自动在微信提示(可选 AI 摘要)。',
    entry: 'mail/index.js',
    commandPrefix: ['邮箱', '邮件', 'email', 'mail'],
    // 用通用表单渲染 SMTP / IMAP 配置
    settingsSchema: [
      { key: 'smtp_host', label: 'SMTP 发件服务器', type: 'text', placeholder: '如 smtp.qq.com', help: '发送邮件用的服务器地址' },
      { key: 'smtp_port', label: 'SMTP 端口', type: 'number', placeholder: '465(SSL)或 587' },
      { key: 'smtp_secure', label: 'SMTP 使用 SSL(465 端口开启)', type: 'switch' },
      { key: 'smtp_user', label: '邮箱账号', type: 'text', placeholder: '[email protected]' },
      { key: 'smtp_pass', label: '邮箱密码 / 授权码', type: 'password', help: 'QQ/163/Gmail 等需使用「授权码 / 应用专用密码」' },
      { key: 'smtp_from', label: '发件人显示(可选)', type: 'text', placeholder: '奶狗Bot <[email protected]>' },
      { key: 'imap_host', label: 'IMAP 收件服务器', type: 'text', placeholder: '如 imap.qq.com', help: '收取邮件用的服务器地址' },
      { key: 'imap_port', label: 'IMAP 端口', type: 'number', placeholder: '993(默认)' },
      { key: 'imap_user', label: 'IMAP 账号(留空复用 SMTP)', type: 'text', placeholder: '默认同邮箱账号' },
      { key: 'imap_pass', label: 'IMAP 密码 / 授权码(留空复用 SMTP)', type: 'password' },
      { key: 'notify', label: '收到新邮件时在微信提示', type: 'switch' },
      { key: 'ai_summary', label: '新邮件用智能助手 AI 摘要', type: 'switch' },
      { key: 'poll_interval', label: '收件轮询间隔(秒,最小 30,默认 120)', type: 'number', placeholder: '120' },
    ],
  },

  // 供智能助手对接
  aiTools,
  handleAiTool,
  // 供外部复用
  ensureTables, startScheduler, sendMail, fetchRecent, loadConfig,

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

    const text = (msg.content || '').trim();
    if (!text) return false;
    const m = text.match(PREFIX_RE);
    if (!m) return false;
    const rest = text.slice(m[0].length).trim();
    const botId = ctx.bot.id;

    try {
      // ── 发送邮件:邮件 发送 收件人|主题|正文 ──
      let sm = rest.match(/^(发送|发|send)\s+([\s\S]+)$/i);
      if (sm) {
        const parts = sm[2].split(/\s*[||]\s*/);
        if (parts.length < 3) {
          await ctx.sendText('格式:邮件 发送 收件人|主题|正文\n例如:邮件 发送 [email protected]|周报|本周工作已完成');
          return true;
        }
        const [to, subject, ...bodyArr] = parts;
        const r = await sendMail(botId, { to: to.trim(), subject: subject.trim(), text: bodyArr.join('|').trim() });
        await ctx.sendText(`[OK] 已发送给 ${r.to}\n主题:${r.subject}`);
        return true;
      }

      // ── 收取 / 最新 ──
      let rm = rest.match(/^(收取|收件|查看|列表|list)\s*(\d+)?$/i);
      if (rm) {
        const n = rm[2] ? parseInt(rm[2], 10) : 5;
        const list = await fetchRecent(botId, n);
        if (!list.length) { await ctx.sendText('收件箱是空的。'); return true; }
        const lines = list.map((mm, i) => {
          const ds = mm.date ? new Date(mm.date).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : '';
          return `${i + 1}. 【${mm.subject}】 ${mm.from}${ds ? ' · ' + ds : ''}`;
        });
        await ctx.sendText('[Mail] 最近邮件:\n' + lines.join('\n'));
        return true;
      }
      if (/^(最新|latest)$/i.test(rest)) {
        const list = await fetchRecent(botId, 1);
        if (!list.length) { await ctx.sendText('收件箱是空的。'); return true; }
        const mm = list[0];
        let body = mm.text || '(无正文)';
        if (body.length > 1200) body = body.slice(0, 1200) + '\n…(已截断)';
        const ds = mm.date ? new Date(mm.date).toLocaleString('zh-CN') : '';
        await ctx.sendText(`[Mail] 《${mm.subject}》\n发件人:${mm.from}${ds ? '\n时间:' + ds : ''}\n\n${body}`);
        return true;
      }

      // ── 状态 / 帮助 ──
      if (/^(状态|status)$/i.test(rest)) {
        const cfg = await loadConfig(botId);
        await ctx.sendText(
          '[Mail] 邮箱助手状态:\n' +
          '发件(SMTP):' + (cfg.hasSmtp ? '已配置 ' + cfg.smtp.host : '未配置') + '\n' +
          '收件(IMAP):' + (cfg.hasImap ? '已配置 ' + cfg.imap.host : '未配置') + '\n' +
          '新邮件提示:' + (cfg.notify ? '开启' : '关闭') + (cfg.aiSummary ? '(含 AI 摘要)' : '') + '\n' +
          '轮询间隔:' + cfg.pollInterval + ' 秒'
        );
        return true;
      }

      // 帮助
      await ctx.sendText(
        '[Mail] 邮箱助手 使用说明:\n' +
        '  邮件 发送 收件人|主题|正文  → 发送邮件\n' +
        '  邮件 收取 [数量]           → 拉取最近邮件(默认 5)\n' +
        '  邮件 最新                  → 查看最新一封\n' +
        '  邮件 状态                  → 查看配置状态\n\n' +
        '也可直接对智能助手说:“发邮件给 [email protected] 说明天开会” / “看看我最近的邮件”。\n' +
        '配置:机器人 → 插件设置 → 邮箱助手,填写 SMTP/IMAP。多数邮箱需用「授权码」。'
      );
      return true;
    } catch (e) {
      console.error('[mail] onMessage 出错:', e.message);
      try { await ctx.sendText('[邮箱] 操作失败:' + e.message); } catch (_) {}
      return true;
    }
  },
};

// 模块加载时自动启动 IMAP 新邮件轮询(不依赖用户主动触发邮箱指令)
startScheduler();