码桶

发现社区成员的开源项目

奶狗 /

ng-webot

公开
main
ng-webot/docs/插件开发指南.md
插件开发指南.md27.3 KB
# 插件开发指南

> 奶狗bot 平台(iLink 微信机器人)插件开发文档
> 适用版本:Node.js 移植版(Express + 插件系统)

本指南介绍如何为本平台开发插件。一个插件 = 一个独立目录,目录内含 `index.js`,导出 `meta` 元信息和 `onMessage` 消息处理函数。

---

## 1. 目录结构

所有插件放在 `plugins/<插件id>/index.js`:

```
plugins/
├── daily-briefing/        # 每日简报
│   └── index.js
├── random-image/          # 随机图片
│   └── index.js
├── random-baisi/          # 随机白丝
│   └── index.js
├── smart/                 # 智能助手
│   └── index.js
└── 你的插件/
    └── index.js
```

> ⚠️ **重要**:`<插件id>` 目录名必须全局唯一,且与 `meta.id` 一致。插件加载器会扫描 `plugins/` 目录下的所有子目录,自动 `require` 每个 `index.js`。

---

## 2. 插件文件基本格式

```js
const axios = require('axios');
const path = require('path');
const fs = require('fs');

module.exports = {
  // 元信息(必填)
  meta: {
    id: 'my-plugin',          // 唯一 ID,与目录名一致
    name: '我的插件',          // 显示名称
    version: '1.0.0',
    author: '开发者',
    category: '娱乐',          // 分类:AI对话 / 信息获取 / 工具 / 娱乐 / 消息处理
    description: '一句话描述这个插件做什么',
    entry: 'my-plugin/index.js',  // 入口路径(相对 plugins/)
  },

  // 消息处理函数(必填)
  async onMessage(msg, ctx) {
    const text = (msg.content || '').trim();
    if (text !== '触发词') return false;   // 不匹配返回 false,交给其他插件
    // ... 处理逻辑 ...
    return true;   // 已处理返回 true(阻止默认行为)
  },
};
```

### meta 字段说明

| 字段 | 必填 | 说明 |
|------|------|------|
| `id` | ✅ | 唯一标识,与目录名一致 |
| `name` | ✅ | 后台展示名称 |
| `version` | ✅ | 版本号 |
| `author` | ✅ | 作者 |
| `category` | ✅ | 分类(`娱乐`/`AI对话`/`信息获取`/`工具`/`消息处理`) |
| `description` | ✅ | 描述 |
| `entry` | ✅ | 插件入口相对路径,格式 `<id>/index.js` |

### onMessage 返回值约定

| 返回值 | 含义 |
|--------|------|
| `false` | 未命中,交后续插件处理 |
| `true` | 已处理,拦截后续插件 |
| `{ reply: '文本' }` | 框架自动发送该文本 |

---

## 3. ctx 上下文对象

插件通过 `ctx` 向用户发送消息:

### ctx.sendText(text)

发送文本消息。

```js
await ctx.sendText('你好,这是一条文本消息');
```

### ctx.sendMedia(filePath, mediaType, filename?)

发送本地文件(图片/视频/文件/语音)。

| 参数 | 类型 | 说明 |
|------|------|------|
| `filePath` | string | 本地文件路径 |
| `mediaType` | string | `image` / `video` / `file` / `voice` |
| `filename` | string | 仅 `file` 类型需要,文件名 |

```js
// 发送图片
await ctx.sendMedia('/path/to/image.jpg', 'image');

// 发送文件(需指定文件名)
await ctx.sendMedia('/path/to/doc.pdf', 'file', '文档.pdf');

// 发送视频
await ctx.sendMedia('/path/to/video.mp4', 'video');

// 发送语音
await ctx.sendMedia('/path/to/voice.amr', 'voice');
```

> 内部实现:先 `uploadMediaToCdn()` 加密上传到微信 CDN,再调用对应发送接口。图片类型会跳过缩略图生成(`no_need_thumb`)。

### ctx.bot / ctx.msg

| 属性 | 说明 |
|------|------|
| `ctx.bot` | 当前机器人信息(含 `bot_token`、`base_url`、`id` 等) |
| `ctx.msg` | 当前消息对象 `{ content, peer_id, context_token, msg_type }` |

### msg 消息对象

```js
{
  content: '用户发送的文本',
  peer_id: '[email protected]',  // 会话 ID
  context_token: 'xxxx',                 // 上下文票据(用于回复)
  msg_type: 'text',                      // 消息类型
}
```

---

## 4. 完整示例:图片类插件

以「随机白丝」(`plugins/random-baisi/index.js`) 为例:

```js
const axios = require('axios');
const path = require('path');
const fs = require('fs');

const API_URL = 'https://v2.xxapi.cn/api/baisi';
const TMP_DIR = path.join(__dirname, '..', '..', 'data', 'tmp');  // data/tmp/

module.exports = {
  meta: {
    id: 'random-baisi',
    name: '随机白丝',
    version: '1.0.0',
    author: '奶狗',
    category: '娱乐',
    description: '发送「白丝」即可获取一张随机白丝美图。',
    entry: 'random-baisi/index.js',
  },

  async onMessage(msg, ctx) {
    const text = (msg.content || '').trim();
    if (text !== '白丝') return false;

    try {
      // 1. 请求 API 拿到图片 URL
      const { data: resp } = await axios.get(API_URL, {
        timeout: 10000,
        headers: { 'User-Agent': 'Mozilla/5.0 (compatible; BaisiBot/1.0)' },
      });

      if (resp.code !== 200 || !resp.data) {
        await ctx.sendText('[白丝] 暂时未能获取图片,请稍后再试。');
        return true;
      }

      const imageUrl = resp.data;

      // 2. 下载到临时目录
      const ext = imageUrl.split('?')[0].split('.').pop().toLowerCase() || 'jpg';
      const tmpName = 'baisi_' + Date.now() + '.' + ext;
      if (!fs.existsSync(TMP_DIR)) fs.mkdirSync(TMP_DIR, { recursive: true });
      const tmpPath = path.join(TMP_DIR, tmpName);

      const response = await axios.get(imageUrl, {
        timeout: 15000,
        responseType: 'arraybuffer',
        headers: {
          'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
          'Referer': 'https://v2.xxapi.cn/',
        },
      });

      const buf = Buffer.from(response.data);
      fs.writeFileSync(tmpPath, buf);

      // 3. 发送图片
      await ctx.sendMedia(tmpPath, 'image');

      // 4. 清理临时文件
      try { fs.unlinkSync(tmpPath); } catch (_) {}
    } catch (err) {
      console.error('[random-baisi] 出错:', err.message);
      try { await ctx.sendText('[白丝] 获取失败,请稍后再试。'); } catch (_) {}
    }
    return true;
  },
};
```

### 发送图片的标准流程

1. 从图片 API 获取 URL(如 `https://v2.xxapi.cn/...`)
2. 用 `axios`(`responseType: 'arraybuffer'`)下载到 `data/tmp/` 临时目录
3. `ctx.sendMedia(tmpPath, 'image')` 发送
4. 发送后删除临时文件

> 临时目录统一用 `data/tmp/`,避免乱放文件。

---

## 5. 插件发现与安装机制

### 自动发现

`lib/plugins.js` 的 `loadAllDefinitions()` 在启动时扫描 `plugins/` 目录,加载所有合法插件。新建目录 + `index.js` 后**必须重启服务**才能被识别(Node.js `require` 缓存)。

### 安装到机器人

插件被「发现」不等于「启用」。需要:

1. 登录后台 → **插件市场**
2. 找到你的插件 → 点击**安装**(免费插件一键安装,付费插件消耗积分)
3. 安装后插件在 `plugins` 表中生成记录(`enabled=1`)

安装后会自动刷新插件缓存(`plugins.invalidate(botId)`)。

### 帮助菜单

在 `lib/plugins.js` 的 `BUILTIN_USAGE` 中添加条目,用户发送「菜单」即可看到插件说明:

```js
const BUILTIN_USAGE = {
  // ...
  'random-baisi': '发送「白丝」获取一张随机白丝美图',
};
```

---

## 6. 插件数据存储

插件可复用平台的数据库表(`lib/db` 的 `db.rows` / `db.row` / `db.exec`):

### plugin_settings(每机器人配置)

键值对,用户可在后台「插件设置」面板配置。

```js
const db = require('../../lib/db');

// 读取配置
const rows = await db.rows(
  'SELECT config_key, config_value FROM plugin_settings WHERE bot_id=? AND plugin_id=?',
  [bot.id, 'my-plugin']
);
const cfg = {};
rows.forEach(r => cfg[r.config_key] = r.config_value);
```

保存接口:`POST /plugin_settings_save`(路由见 `routes/market.js`)。

### plugin_rules(关键词/指令回复)

用于 reply 类插件,存储匹配规则。

```js
// 表结构
// plugin_rules (bot_id, plugin_id, type[keyword|command], match, reply, enabled, created_at)
```

### 自定义表

插件也可自行建表(在 `lib/db.js` 初始化时 `CREATE TABLE IF NOT EXISTS`)。

---

## 7. 对接智能助手(smart)—— 通用互通协议 ⭐

`smart` 插件无需指令前缀,由 AI 自动判断意图。**智能助手与第三方插件是「双向互通」的:**

1. **插件 → 智能助手(注册工具)**:你的插件把能力声明成 AI 工具,自然语言触发时由 AI 自动调用(见 §7.1)。
2. **插件 → 智能助手(直接生成)**:你的插件在自己的业务逻辑里,直接调用智能助手做 AI 文本生成 / function calling(见 §11)。

两种方式都不需要修改 `smart` 的代码。

### 7.1 只需两步

在你的插件里做两件事,smart 就会在运行时自动发现并注册你的工具:

1. **在 `meta.aiTools` 里声明 OpenAI function schema**(数组,可声明多个工具);
2. **导出 `handleAiTool(name, args, ctx)`**,返回一段字符串(供 AI 转述给用户)。

```js
module.exports = {
  meta: {
    id: 'delta-password',
    name: '三角洲每日密码',
    // ... 其它 meta 字段 ...

    // ① 声明 AI 工具(smart 自动注册进 function calling)
    aiTools: [
      {
        type: 'function',
        function: {
          name: 'get_delta_password',              // 全局唯一,勿与内置工具重名
          description: '获取《三角洲行动》当日各地图密码门密码。当用户问“三角洲密码”“XX地图今天密码多少”等意图时调用。',
          parameters: {
            type: 'object',
            properties: {
              map_name: { type: 'string', description: '地图名称(可选),不填返回全部' },
            },
          },
        },
      },
    ],
  },

  // ② AI 调用工具时的处理入口;可直接用 ctx 发文本/图片,并返回给 AI 一句结果说明
  async handleAiTool(name, args, ctx) {
    if (name !== 'get_delta_password') return '未知工具:' + name;
    // ... 执行逻辑,可 await ctx.sendText(...) / ctx.sendMedia(...) ...
    return '密码信息已发送给用户,请简短确认即可,不要重复罗列。';
  },

  // 常规指令入口照常保留(精确指令仍可直接触发,不依赖 AI)
  async onMessage(msg, ctx) { /* ... */ },
};
```

### 7.2 工作原理

- `smart` 在每次处理消息前调用 `collectPluginTools(botId)`,遍历该机器人**已安装且启用**的插件,收集它们 `meta.aiTools`(或导出的 `aiTools`)声明的工具,合并进传给 AI 的 `tools`。
- AI 决定调用某工具时,`smart` 的 `executeTool()` 在内置 `switch` 未命中时,会转发到声明该工具的插件的 `handleAiTool(name, args, ctx)`。
- **返回值约定**:内容若已通过 `ctx.sendText/sendMedia` 直接发给用户,`handleAiTool` 应返回一句「已发送,请简短确认,勿重复罗列」,避免 AI 再把内容复述一遍。

### 7.3 注意事项

- 工具 `name` 必须全局唯一,且**不要与内置工具重名**(内置:`search_knowledge` / `set_reminder` / `list_reminders` / `delete_reminder` / `get_daily_briefing` / `get_random_image` / `create_note` / `save_url_to_knowledge` / `write_knowledge` / `save_image_to_knowledge` / `search_note` / `read_note`);重名时先声明者优先。
- 插件必须**已安装并启用**到该机器人,其工具才会被收集(未安装的插件不会污染 AI 工具列表)。
- `handleAiTool` 抛错会被 smart 捕获并返回错误说明给 AI,不会中断整个对话。
- 常规 `onMessage` 精确指令入口建议保留:既支持「三角洲密码」这类硬指令,也支持自然语言(走 smart)。两条路径可复用同一份核心逻辑(见 `delta-password/index.js` 的 `runQuery()`)。

### 内置已集成能力示例

| 工具名 | 触发场景 |
|--------|----------|
| `search_knowledge` | 搜索 IMA 知识库 |
| `set_reminder` | 设置提醒 |
| `get_daily_briefing` | 获取每日新闻简报 |
| `get_random_image` | 获取随机图片 |
| `save_image_to_knowledge` | 把用户最近发送的图片存入 IMA 知识库(需用户明确要求) |
| `get_delta_password` | 三角洲每日密码(插件通过 aiTools 对接的范例) |

> 图片识别:用户发送图片时,smart 会用视觉模型自动识别内容(认花/认物/识文字),**不再自动存知识库**;仅当用户明确说「把图片存到知识库」时才调用 `save_image_to_knowledge` 保存。图片识别需 AI 模型支持视觉(如 `gpt-4o` / `gpt-4o-mini` / `qwen-vl` 等)。

---

## 8. 插件执行顺序

`lib/plugins.js` 的 `onMessage()` 调度逻辑:

1. **菜单优先**:消息为「菜单/帮助/help/menu」时直接返回帮助文本
2. **smart 插件优先**:`smart` 先执行(AI 自动判断意图),返回 `true` 则拦截
3. **其余插件按安装顺序**:逐个调用 `onMessage`,命中返回 `true` 即停

> ⚠️ 注意:若多个插件匹配同一条消息,`smart` 会优先处理。确保你的触发词不会与普通聊天冲突。

---

## 9. 调试技巧

### 控制台日志

用 `console.log` / `console.error` 打点,日志会输出到启动服务的终端(或 PM2 日志)。

```js
console.log('[my-plugin] 拿到数据:', JSON.stringify(data));
console.error('[my-plugin] 失败:', err.message);
```

### 本地测试插件加载

```bash
node -e "const m=require('./plugins/my-plugin/index.js'); console.log(m.meta)"
```

### 常见问题

| 现象 | 原因 |
|------|------|
| 市场看不到插件 | 文件为空 / `meta.id` 缺失 / 未重启服务 |
| 插件不触发 | 触发词不匹配 / 未安装到机器人 / smart 已拦截 |
| 图片发送失败 | 临时文件路径错误 / 文件过小 / CDN 上传失败(看 `console.error`) |

> ✅ 修改插件代码后,**必须重启 server.js 和 worker.js**,否则运行的是旧代码。

---

## 10. 快速上手清单

- [ ] 在 `plugins/<id>/index.js` 创建文件
- [ ] 导出 `meta`(含必填字段)+ `onMessage`
- [ ] 在 `lib/plugins.js` 的 `BUILTIN_USAGE` 添加菜单说明
- [ ] 重启服务(`node server.js` + `node worker.js`)
- [ ] 后台插件市场安装插件
- [ ] 发消息测试,看控制台日志

---

## 11. 第三方插件直接调用智能助手(AI 生成)⭐

除了把能力「注册成工具」交给 AI 调度(§7),你的插件还能**主动**调用智能助手完成 AI 文本生成。典型场景:

- 插件拿到结构化数据后,让 AI 改写成自然语言回复(如天气插件把 JSON 转成「今天晴,22℃…」)。
- 插件需要「理解语义」时(情感分析、摘要、翻译),直接调 AI,而非自己写规则。
- 插件内部的多轮推理 / 调用外部工具后再让 AI 总结。

### 11.1 调用方式

推荐走 `lib/plugins.js` 的 `callAssistant(opts)`(自动定位 smart,无需硬编码路径):

```js
const plugins = require('../../lib/plugins');

async function myLogic(bot, msg, ctx) {
  // 假设已经从某 API 拿到了冷冰冰的数据
  const raw = await fetchSomeData();

  const reply = await plugins.callAssistant({
    botId: bot.id,
    userId: bot.user_id,
    prompt: '把下面的数据用亲切的中文口语总结成 2 句话:\n' + JSON.stringify(raw),
    // 可选:覆盖系统提示词
    // systemPrompt: '你是一个简洁的播报员',
    // 可选:多轮上下文
    // history: [{ role: 'user', content: '...' }, { role: 'assistant', content: '...' }],
  });

  await ctx.sendText(reply);
}
```

也可以直接 `require` smart 插件(两者等价):

```js
const smart = require('../../plugins/smart');
const reply = await smart.generate({ botId, userId, prompt: '...' });
```

### 11.2 opts 参数

| 参数 | 必填 | 说明 |
|------|------|------|
| `botId` | ✅ | 机器人 ID,决定使用哪种模型来源(见 §12) |
| `userId` | ⚠️ | 用户 ID,**用于扣减 AI Token 额度**;不传则不扣额度(慎用,可能绕过配额) |
| `prompt` | ✅ | 发给 AI 的内容 |
| `systemPrompt` | | 覆盖默认系统提示词 |
| `maxTokens` / `temperature` | | 覆盖生成参数 |
| `history` | | `[{role,content}]` 多轮上下文,置于 prompt 之前 |
| `tools` | | OpenAI function 工具定义数组,启用 function calling |
| `toolHandler` | | `async (name, args) => string`,配合 `tools` 执行工具并返回结果字符串 |

### 11.3 带 function calling 的进阶调用

插件也能借助 AI 的 function calling 完成「AI 决定调哪个工具」的链路:

```js
const reply = await plugins.callAssistant({
  botId,
  userId,
  prompt: '帮我查一下明天北京的天气',
  tools: [/* 你的 OpenAI function schema */],
  toolHandler: async (name, args) => {
    if (name === 'get_weather') return await callWeatherApi(args.city);
    return '未知工具';
  },
});
```

### 11.4 注意事项

- `generate` / `callAssistant` 内部会自动按 §12 的模型配置(站长模型或自定义模型轮询)发起请求,插件无需关心具体 key。
- 若 `userId` 传入且 Token 已用尽,会抛出 `AI Token 额度已用尽`;插件应捕获异常并给用户友好提示。
- 强烈建议传入 `userId`,让 AI 用量纳入站点配额统计,避免被滥用。
- 调用失败会抛出 `Error`,请用 `try/catch` 包裹,避免拖垮插件主流程。

---

## 12. 智能助手模型配置(站长模型 / 自定义模型 / 轮询)⭐

智能助手的 AI 模型来源在「机器人级」配置(存储于 `plugin_settings`,`plugin_id='smart'`),由 `ai_source` 字段决定:

### 12.1 两种来源

| 来源 | `ai_source` | 说明 | 配置位置 |
|------|-------------|------|----------|
| **站长模型**(默认) | `owner` | 使用管理员后台「AI 接口配置」里统一填写的 API Base / Key / 模型 | 后台 → 站点设置 → AI 接口 |
| **自定义模型** | `custom` | 机器人使用自己的 API Key,与其他机器人 / 站长隔离 | 机器人 → 插件设置 → 智能助手 |

> 未设置 `ai_source` 时向后兼容旧逻辑:若旧字段 `use_custom_ai=1` 且 `ai_api_base`、`ai_api_key` 齐全,则视为 `custom`(单模型);否则为 `owner`。

### 12.2 自定义模型 + 轮询

选择「自定义模型」后,可填写**多个**模型(结构存于 `ai_custom_models`,JSON 数组):

```json
[
  { "name": "主力", "api_base": "https://api.openai.com", "api_key": "sk-xxx", "model": "gpt-4o-mini" },
  { "name": "备用", "api_base": "https://api.deepseek.com", "api_key": "sk-yyy", "model": "deepseek-chat" }
]
```

运行时行为(`callAIWithFallback`):

- **轮询(负载均衡)**:每次请求按轮询游标选一个模型,多个模型被均摊流量,`modelCursor` 在进程内自增。
- **故障转移**:若选中的模型调用失败(网络错误 / 返回空),按顺序自动切换下一个模型,直到成功或全部失败。

因此在单个机器人上配置多个同款或不同款模型,即可实现**高可用 + 限流分摊**,无需改任何代码。

### 12.3 前端配置入口

- 机器人「插件设置 → 智能助手」面板(`SmartAiConfig.tsx`):切换「站长模型 / 自定义模型」,自定义模式下可增删多个模型,每个含 名称 / API Base / API Key / 模型名,并可选填自定义系统提示词。保存即写入 `plugin_settings`。
- 多模型会自动轮询,界面已标注「添加多个模型后自动轮询(负载均衡 + 故障转移)」。

### 12.4 开发者自检

插件里想确认某个机器人当前用哪种模型来源:

```js
const smart = require('../../plugins/smart');
const mode = await smart.getAiMode(botId);
// mode = { source: 'owner' | 'custom', customModels: number }
```

---

## 13. Webhook 推送插件(消息推送 + 智能助手 + 定时推送)⭐

插件 id:`push`(目录 `plugins/push/`)。它提供类似 **Server 酱 / Bark** 的外部消息推送能力,并内置「智能助手 AI 润色」与「定时推送」。

### 13.1 它能做什么

| 能力 | 说明 |
|------|------|
| **Webhook 接收** | 外部系统(监控告警、CI、IoT、自建服务)`POST` 到专属链接,消息即推送到绑定的微信会话 |
| **智能助手(AI 润色/生成)** | 推送内容可先交给智能助手处理:总结、翻译、改写,或由 AI 直接生成内容 |
| **定时推送** | 按每天 / 每周 / 相对时间 / 绝对时间,自动把内容(或 AI 生成内容)推送给会话 |

### 13.2 在微信里使用(聊天指令)

| 指令 | 作用 |
|------|------|
| `推送 绑定` | 生成专属 Webhook 链接(绑定当前会话),并返回 curl 示例 |
| `推送 重绑` | 重新生成链接(旧链接立即失效) |
| `推送 链接` | 查看当前会话的链接与 AI 开关 |
| `推送 解绑` | 解除推送通道 |
| `推送 定时 <时间> <内容>` | 添加定时推送,如 `推送 定时 每天9点 早安,该喝水啦` |
| `推送 定时列表` | 查看待执行的定时推送 |
| `推送 取消定时 <编号>` | 取消某条定时推送 |
| `推送` | 输出完整使用说明 |

### 13.3 外部系统如何调用(Webhook API)

**端点**:`POST /api/push/webhook/<token>`
(`<token>` 由「推送 绑定」生成;也可放 `?token=` 或请求头 `x-push-token`;支持 `GET` 与 `form-urlencoded`)

**请求体**(JSON):

| 字段 | 必填 | 说明 |
|------|------|------|
| `content` / `text` / `msg` | ✅ | 要推送的消息正文 |
| `title` | | 标题(不开启 AI 时拼在正文前) |
| `ai` | | `true`/`1` 时启用智能助手处理(请求级优先于通道默认) |
| `ai_prompt` | | 给智能助手的指令,如「把上面的内容总结成 3 个要点」 |

**示例**:

```bash
curl -X POST https://你的域名/api/push/webhook/<token> \
  -H "Content-Type: application/json" \
  -d '{"title":"服务器告警","content":"CPU 使用率 95%","ai":true}'
```

**返回**:

```json
{ "ok": true, "pushed": true, "ai": true }
```

> 若启用了 AI 但智能助手未配置 / 调用失败,会自动回退推送原文,保证推送不中断。

### 13.4 前端配置入口

机器人「插件设置 → Webhook 推送」面板(`PushConfig.tsx`):
- **推送通道**:查看/复制/重置/解绑专属链接;可开关「智能助手 AI 润色」并填写 AI 指令。
- **定时推送**:增删定时任务(时间描述 + 内容 + 是否用 AI 生成)。

后端接口(`routes/push.js`,均需登录 + 机器人归属校验):
`/api/push/channels`、`/api/push/bind`、`/api/push/unbind`、`/api/push/channel_update`、`/api/push/schedule_add`、`/api/push/schedule_del`。

### 13.5 插件导出能力(供其他代码复用)

`plugins/push` 导出以下方法,可在其他插件 / 路由中直接调用:

```js
const push = require('../../plugins/push');
await push.bindChannel(botId, peerId);          // 绑定通道,返回 {id, token, ...}
await push.handleWebhook(token, body);          // 处理一次外部推送
await push.addSchedule(botId, peerId, { content, time_desc, ai_enabled, ai_prompt });
const { channels, schedules } = await push.listForBot(botId);
```

### 13.6 与智能助手的关联

- 推送的 AI 能力走的是 `lib/plugins.js` 的 `callAssistant(opts)`(即智能助手「自定义模型 / 站长模型 / 轮询」那一套,详见 §12),会按机器人配置自动选择模型并扣 Token 额度。
- 插件在 `meta` 中声明了 `commandPrefix: ['推送', 'push']`。**智能助手遇到以这些前缀开头的消息会让位**(见 `smart/index.js` 的 commandPrefix 让位机制),从而让你的指令型插件能正确接管,不会被 AI 当成闲聊回复。

> 想让你的指令型插件也被智能助手「让位」?在 `meta` 里加上 `commandPrefix: ['你的指令前缀']` 即可(数组,支持多个前缀,匹配大小写不敏感)。

---

## 14. 邮箱助手插件(SMTP 收发 + IMAP 新邮件提示 + 智能助手)⭐

插件 id:`mail`(目录 `plugins/mail/`)。配置 SMTP/IMAP 后,机器人即可帮你收发邮件,并在收到新邮件时自动在微信提示。

### 14.1 它能做什么

| 能力 | 说明 |
|------|------|
| **发邮件(SMTP)** | 用 `nodemailer` 通过你的邮箱账号发送邮件 |
| **收邮件(IMAP)** | 用 `imapflow` + `mailparser` 拉取收件箱最近邮件、读取正文 |
| **新邮件提示** | 后台轮询 IMAP,检测到新邮件(UID 增长)时把发件人/主题/正文推送到微信,可选 **AI 摘要** |
| **智能助手** | 通过 function calling 让 AI 自然语言「发邮件 / 看邮件 / 读邮件」 |

### 14.2 在微信里使用(聊天指令)

| 指令 | 作用 |
|------|------|
| `邮件 发送 收件人|主题|正文` | 发送邮件(分隔符 `|` 或 `|`;正文可含多段) |
| `邮件 收取 [数量]` | 拉取最近若干封邮件(默认 5,最多 20) |
| `邮件 最新` | 查看最新一封邮件正文 |
| `邮件 状态` | 查看 SMTP/IMAP 配置与轮询状态 |
| `邮箱` / `邮件` | 输出使用说明 |

也可直接对智能助手说:「发邮件给 [email protected] 说明天上午十点开会」「看看我最近的邮件」「读第 1 封邮件」。

### 14.3 配置(机器人 → 插件设置 → 邮箱助手)

用通用 `settingsSchema` 表单渲染,主要字段:

- **SMTP(发件)**:`smtp_host`、`smtp_port`(465/587)、`smtp_secure`(465 开)、`smtp_user`、`smtp_pass`、`smtp_from`(可选)
- **IMAP(收件)**:`imap_host`、`imap_port`(默认 993)、`imap_user`/`imap_pass`(留空则复用 SMTP 账号密码)
- **提示**:`notify`(收到新邮件提示开关)、`ai_summary`(AI 摘要开关)、`poll_interval`(轮询秒数,最小 30,默认 120)

> 多数邮箱(QQ / 163 / Gmail 等)需在邮箱设置里开启 IMAP/SMTP 服务并使用「**授权码 / 应用专用密码**」,而不是登录密码。

### 14.4 智能助手对接(aiTools)

`plugins/mail` 在 `meta.aiTools` 声明了 `send_email` / `list_recent_emails` / `read_email` 三个工具,并导出 `handleAiTool(name,args,ctx)`。因此 **无需修改 smart**,安装启用后智能助手会自动获得收发邮件能力(详见 §11「对接智能助手」的通用互通协议)。

### 14.5 新邮件检测原理

- 建表 `mail_state(bot_id, last_uid)` 记录每个机器人已读到的最大 IMAP UID。
- 进程内单例调度器(`global.__mailSchedulerStarted`)每 30s 触发,按各机器人的 `poll_interval` 节流后连接 IMAP,拉取 `UID > last_uid` 的新邮件推送到「最近一次收到消息的会话」。
- **首次运行只记录当前最大 UID、不推送历史邮件**,避免刷屏。
- AI 摘要走 `lib/plugins.callAssistant`(站长/自定义模型 + Token 扣减),失败自动回退原文截断。

### 14.6 依赖

新增 npm 依赖:`imapflow`(IMAP 客户端)、`mailparser`(邮件解析);发件复用已有的 `nodemailer`。