个人AI助手插件开发实战:从零打造天气/翻译/待办三个实用插件
8/30/2026

文章链接:https://blog.csdn.net/qq_43201350/article/details/163717377

本文是"个人AI助手平台"系列第五篇。建议先阅读前四篇了解基础架构后再看本文。


一、为什么需要插件

前面的文章里我们搭好了架构、部署上了云、打通了四个IM通道。但一个只会聊天的 AI 助手太单薄了——用户问"明天会下雨吗",你需要查询天气;用户说"帮我记下这条待办",你需要持久化存储。

插件的价值

  • 能力扩展 :让 AI 能查天气、做翻译、管理待办——不再是一个只会聊天的花瓶

  • 热加载 :新增能力不用重启服务,开发者写好插件、丢进插件目录就生效

  • 社区生态 :参照 VS Code 插件市场模式,任何人都可以贡献插件

本文带你从零写出三个完整可运行的插件,并掌握插件开发范式。


二、插件规范(Plugin Specification)

先定义插件接口——所有插件必须遵守的契约:

// plugin-spec.ts
export interface PluginManifest {
  /** 插件唯一标识,如 "weather" */
  id: string;
  /** 显示名称 */
  name: string;
  /** 版本号 */
  version: string;
  /** 作者 */
  author: string;
  /** 一句话描述 */
  description: string;
  /** 所需权限 */
  permissions: string[];
  /** 触发关键词,如 ["天气", "weather"] */
  triggers: string[];
}
 
export interface PluginContext {
  /** 用户ID */
  userId: string;
  /** 所属群聊ID */
  channelId?: string;
  /** 插件数据存储路径(持久化用) */
  dataDir: string;
  /** 日志函数 */
  logger: PluginLogger;
  /** 发送消息到当前通道 */
  sendMessage: (text: string) => Promise<void>;
}
 
export interface PluginLogger {
  info: (msg: string) => void;
  warn: (msg: string) => void;
  error: (msg: string, err?: Error) => void;
}
 
/**
 * 插件基类——所有插件必须继承
 */
export abstract class BasePlugin {
  abstract readonly manifest: PluginManifest;
 
  /**
   * 初始化(插件加载时调用一次)
   */
  abstract onInit(ctx: PluginContext): Promise<void>;
 
  /**
   * 收到消息时调用
   * @returns true 表示已处理该消息(不再向下传递)
   */
  abstract onMessage(
    message: string,
    ctx: PluginContext
  ): Promise<{ handled: boolean; reply?: string }>;
 
  /**
   * 卸载插件时调用(清理资源)
   */
  abstract onDestroy(): Promise<void>;
}

三、插件管理器(动态加载)

插件管理器负责扫描插件目录、加载、卸载、热更新:

// plugin-manager.ts
import * as fs from 'fs';
import * as path from 'path';
import { BasePlugin, PluginContext, PluginManifest } from './plugin-spec';
 
interface LoadedPlugin {
  instance: BasePlugin;
  manifest: PluginManifest;
  filePath: string;
  lastModified: number;
}
 
export class PluginManager {
  private plugins: Map<string, LoadedPlugin> = new Map();
  private ctx: PluginContext;
  private hotWatchInterval: NodeJS.Timeout | null = null;
 
  constructor(ctx: PluginContext, private pluginsDir: string) {
    this.ctx = ctx;
  }
 
  /**
   * 启动:扫描并加载所有插件、启动热监听
   */
  async start(): Promise<void> {
    await this.loadAll();
    this.startHotWatch();
    this.ctx.logger.info(`插件管理器已启动,加载 ${this.plugins.size} 个插件`);
  }
 
  /**
   * 停止:卸载所有插件
   */
  async stop(): Promise<void> {
    if (this.hotWatchInterval) clearInterval(this.hotWatchInterval);
    for (const [id, loaded] of this.plugins) {
      await loaded.instance.onDestroy();
      this.ctx.logger.info(`插件 ${id} 已卸载`);
    }
    this.plugins.clear();
  }
 
  /**
   * 处理消息——遍历所有插件,第一个处理的生效
   */
  async handleMessage(message: string): Promise<string | null> {
    for (const [id, loaded] of this.plugins) {
      try {
        const result = await loaded.instance.onMessage(message, this.ctx);
        if (result.handled && result.reply) {
          this.ctx.logger.info(`插件 [${id}] 处理了消息`);
          return result.reply;
        }
      } catch (err) {
        this.ctx.logger.error(`插件 [${id}] 处理消息出错`, err as Error);
      }
    }
    return null; // 无插件处理
  }
 
  /**
   * 扫描并加载所有插件
   */
  private async loadAll(): Promise<void> {
    if (!fs.existsSync(this.pluginsDir)) {
      fs.mkdirSync(this.pluginsDir, { recursive: true });
      return;
    }
 
    const files = fs.readdirSync(this.pluginsDir);
    for (const file of files) {
      if (file.endsWith('.js') || file.endsWith('.ts')) {
        await this.loadPlugin(path.join(this.pluginsDir, file));
      }
    }
  }
 
  /**
   * 加载单个插件
   */
  private async loadPlugin(filePath: string): Promise<void> {
    try {
      // 清除 require 缓存以支持热更新
      const resolvedPath = require.resolve(filePath);
      delete require.cache[resolvedPath];
 
      const PluginClass = require(filePath).default;
      const instance: BasePlugin = new PluginClass();
      const manifest = instance.manifest;
 
      if (this.plugins.has(manifest.id)) {
        this.ctx.logger.warn(`插件 ${manifest.id} 已存在,跳过重复加载`);
        return;
      }
 
      await instance.onInit(this.ctx);
      const stats = fs.statSync(filePath);
 
      this.plugins.set(manifest.id, {
        instance,
        manifest,
        filePath,
        lastModified: stats.mtimeMs,
      });
 
      this.ctx.logger.info(`插件 [${manifest.id}] v${manifest.version} 加载成功`);
    } catch (err) {
      this.ctx.logger.error(`加载插件失败: ${filePath}`, err as Error);
    }
  }
 
  /**
   * 启动热监听:每 3 秒检查插件文件是否变化
   */
  private startHotWatch(): void {
    this.hotWatchInterval = setInterval(async () => {
      for (const [id, loaded] of this.plugins) {
        try {
          const stats = fs.statSync(loaded.filePath);
          if (stats.mtimeMs > loaded.lastModified) {
            this.ctx.logger.info(`检测到插件 [${id}] 文件变化,热更新中...`);
            await loaded.instance.onDestroy();
            this.plugins.delete(id);
            await this.loadPlugin(loaded.filePath);
          }
        } catch (err) {
          // 文件可能被删除,忽略
        }
      }
    }, 3000);
  }
 
  /**
   * 获取已加载插件列表
   */
  getLoadedPlugins(): PluginManifest[] {
    return Array.from(this.plugins.values()).map(p => p.manifest);
  }
}

四、实战一:天气查询插件

4.1 设计思路

  • 触发词天气weather会下雨多少度

  • 依赖 :调用公开天气 API(Open-Meteo,免费无需 Key)

  • 返回格式 :城市名 + 温度 + 天气状况 + 穿衣建议

4.2 完整代码

// weather-plugin.ts
import { BasePlugin, PluginContext, PluginManifest } from './plugin-spec';
 
interface WeatherData {
  city: string;
  temperature: number;
  weatherCode: number;
  humidity: number;
  windSpeed: number;
}
 
export default class WeatherPlugin extends BasePlugin {
  readonly manifest: PluginManifest = {
    id: 'weather',
    name: '天气查询',
    version: '1.0.0',
    author: 'demo',
    description: '查询指定城市的实时天气',
    permissions: ['network'],
    triggers: ['天气', 'weather', '温度', '下雨', '下雪'],
  };
 
  // 城市名 → 经纬度 映射(简单版,生产环境应调用地理编码API)
  private cityCoords: Record<string, [number, number]> = {
    '北京': [39.9042, 116.4074],
    '上海': [31.2304, 121.4737],
    '广州': [23.1291, 113.2644],
    '深圳': [22.5431, 114.0579],
    '杭州': [30.2741, 120.1551],
    '成都': [30.5728, 104.0668],
    '武汉': [30.5928, 114.3055],
    '南京': [32.0603, 118.7969],
    '东京': [35.6762, 139.6503],
    '纽约': [40.7128, -74.0060],
    '伦敦': [51.5074, -0.1278],
    '巴黎': [48.8566, 2.3522],
    '新加坡': [1.3521, 103.8198],
  };
 
  private ctx!: PluginContext;
 
  // 天气码 → 中文描述 + emoji
  private weatherDesc: Record<number, string> = {
    0: '☀️ 晴天',
    1: '🌤 大部晴朗',
    2: '⛅ 多云',
    3: '☁️ 阴天',
    45: '🌫 雾',
    51: '🌧 小雨',
    61: '🌧 中雨',
    63: '🌧 大雨',
    71: '❄️ 小雪',
    75: '❄️ 大雪',
    80: '🌦 阵雨',
    95: '⛈ 雷暴',
  };
 
  async onInit(ctx: PluginContext): Promise<void> {
    this.ctx = ctx;
    ctx.logger.info('天气插件初始化完成');
  }
 
  async onDestroy(): Promise<void> {
    // 无需要清理的资源
  }
 
  async onMessage(
    message: string,
    ctx: PluginContext
  ): Promise<{ handled: boolean; reply?: string }> {
    const matched = this.matchTrigger(message);
    if (!matched) return { handled: false };
 
    const city = this.extractCity(message);
    if (!city) {
      return {
        handled: true,
        reply: '📭 请指定城市,例如:"北京今天天气怎么样"',
      };
    }
 
    const coords = this.cityCoords[city];
    if (!coords) {
      return {
        handled: true,
        reply: `🤔 暂时不支持"${city}"的天气查询,请尝试其他城市~`,
      };
    }
 
    try {
      const data = await this.fetchWeather(coords[0], coords[1]);
      const reply = this.formatReply(city, data);
      return { handled: true, reply };
    } catch (err) {
      ctx.logger.error('天气查询失败', err as Error);
      return { handled: true, reply: '😵 天气查询暂时不可用,请稍后再试' };
    }
  }
 
  /**
   * 匹配触发关键词
   */
  private matchTrigger(message: string): boolean {
    const lower = message.toLowerCase();
    return this.manifest.triggers.some(t => lower.includes(t));
  }
 
  /**
   * 从消息中提取城市名
   * 策略:遍历城市列表,第一个出现在消息中的即为目标城市
   */
  private extractCity(message: string): string | null {
    for (const city of Object.keys(this.cityCoords)) {
      if (message.includes(city)) return city;
    }
    return null;
  }
 
  /**
   * 调用 Open-Meteo 免费天气 API
   */
  private async fetchWeather(lat: number, lon: number): Promise<WeatherData> {
    const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}&current=temperature_2m,weather_code,relative_humidity_2m,wind_speed_10m`;
 
    const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
    if (!res.ok) throw new Error(`天气API返回 ${res.status}`);
 
    const json = await res.json();
    return {
      city: '',
      temperature: json.current.temperature_2m,
      weatherCode: json.current.weather_code,
      humidity: json.current.relative_humidity_2m,
      windSpeed: json.current.wind_speed_10m,
    };
  }
 
  /**
   * 格式化回复消息
   */
  private formatReply(city: string, data: WeatherData): string {
    const desc = this.weatherDesc[data.weatherCode] || '未知';
    const advice = this.getAdvice(data.temperature, data.weatherCode);
 
    return [
      `📍 **${city}天气**`,
      ``,
      `🌡 温度:**${data.temperature}°C**`,
      `💧 湿度:${data.humidity}%`,
      `🌬 风力:${data.windSpeed} km/h`,
      `天气:${desc}`,
      ``,
      `💡 ${advice}`,
    ].join('\n');
  }
 
  /**
   * 简单穿衣建议
   */
  private getAdvice(temp: number, weatherCode: number): string {
    const parts: string[] = [];
 
    if (temp < 10) parts.push('天气寒冷,记得穿羽绒服🧥');
    else if (temp < 20) parts.push('温度适中,建议穿外套🧣');
    else if (temp < 30) parts.push('天气舒适,短袖就够了👕');
    else parts.push('天气炎热,注意防晒防暑🌞');
 
    if ([51, 61, 63, 80].includes(weatherCode)) parts.push('出门记得带伞🌂');
    if (weatherCode === 45) parts.push('能见度低,开车注意安全🚗');
 
    return parts.join(';');
  }
}

4.3 运行效果

用户:上海明天天气怎么样
AI:  📍 上海天气
      🌡 温度:28°C
      💧 湿度:65%
      🌬 风力:12 km/h
      天气:⛅ 多云
      💡 天气舒适,短袖就够了👕

五、实战二:翻译插件

5.1 设计思路

  • 触发词翻译

  • 格式翻译 [源语言→目标语言] 文本 (语言参数可选,默认中文↔英文)

  • 依赖 :调免费翻译 API(Lingva,无 Key 需要)

5.2 完整代码

// translate-plugin.ts
import { BasePlugin, PluginContext, PluginManifest } from './plugin-spec';
 
interface LangPair {
  source: string;
  target: string;
  label: string;
}
 
export default class TranslatePlugin extends BasePlugin {
  readonly manifest: PluginManifest = {
    id: 'translate',
    name: '翻译助手',
    version: '1.0.0',
    author: 'demo',
    description: '多语言翻译,支持中/英/日/韩/法/德',
    permissions: ['network'],
    triggers: ['翻译', 'translate'],
  };
 
  // 语言简称 → 名称映射
  private langMap: Record<string, string> = {
    '中': 'zh', '中文': 'zh', 'cn': 'zh', 'zh': 'zh',
    '英': 'en', '英文': 'en', 'en': 'en',
    '日': 'ja', '日文': 'ja', '日语': 'ja', 'ja': 'ja',
    '韩': 'ko', '韩文': 'ko', '韩语': 'ko', 'ko': 'ko',
    '法': 'fr', '法文': 'fr', '法语': 'fr', 'fr': 'fr',
    '德': 'de', '德文': 'de', '德语': 'de', 'de': 'de',
    '俄': 'ru', '俄文': 'ru', '俄语': 'ru', 'ru': 'ru',
    '西': 'es', '西班牙': 'es', 'es': 'es',
  };
 
  private ctx!: PluginContext;
 
  async onInit(ctx: PluginContext): Promise<void> {
    this.ctx = ctx;
    ctx.logger.info('翻译插件初始化完成');
  }
 
  async onDestroy(): Promise<void> {}
 
  async onMessage(
    message: string,
    ctx: PluginContext
  ): Promise<{ handled: boolean; reply?: string }> {
    if (!message.includes('翻译') && !message.toLowerCase().includes('translate')) {
      return { handled: false };
    }
 
    const parsed = this.parseMessage(message);
    if (!parsed) {
      return {
        handled: true,
        reply: [
          '📝 翻译助手使用方法:',
          '',
          '• `翻译 [语言→语言] 文本`',
          '• `翻译 文本` (自动中英互译)',
          '',
          '示例:',
          '• `翻译 中→英 你好世界`',
          '• `翻译 Hello, how are you?`',
          '• `翻译 日→中 こんにちは`',
        ].join('\n'),
      };
    }
 
    try {
      const result = await this.doTranslate(
        parsed.text,
        parsed.source,
        parsed.target
      );
      return {
        handled: true,
        reply: [
          `🌐 翻译结果 (${parsed.sourceLabel} → ${parsed.targetLabel})`,
          '',
          `原文:${parsed.text}`,
          `译文:**${result}**`,
        ].join('\n'),
      };
    } catch (err) {
      ctx.logger.error('翻译失败', err as Error);
      return { handled: true, reply: '😵 翻译服务暂时不可用,请稍后再试' };
    }
  }
 
  /**
   * 解析用户消息
   * 格式:翻译 中→英 文本 / 翻译 文本
   */
  private parseMessage(message: string): {
    text: string;
    source: string;
    target: string;
    sourceLabel: string;
    targetLabel: string;
  } | null {
    // 去掉"翻译"关键词
    let content = message.replace(/^(翻译|translate)\s*/i, '').trim();
    if (!content) return null;
 
    // 尝试匹配 "语言→语言 文本" 格式
    const langPattern = /^([\u4e00-\u9fa5]+|en|ja|ko|fr|de|ru|es)\s*[-→>to]+\s*([\u4e00-\u9fa5]+|en|ja|ko|fr|de|ru|es)\s+(.+)$/i;
    const langMatch = content.match(langPattern);
 
    if (langMatch) {
      const srcLang = this.langMap[langMatch[1].toLowerCase()] || 'auto';
      const tgtLang = this.langMap[langMatch[2].toLowerCase()] || 'en';
      return {
        text: langMatch[3].trim(),
        source: srcLang,
        target: tgtLang,
        sourceLabel: langMatch[1],
        targetLabel: langMatch[2],
      };
    }
 
    // 不指定语言 → 自动检测
    const isChinese = /[\u4e00-\u9fa5]/.test(content);
    return {
      text: content,
      source: 'auto',
      target: isChinese ? 'en' : 'zh',
      sourceLabel: isChinese ? '中文' : '自动检测',
      targetLabel: isChinese ? '英文' : '中文',
    };
  }
 
  /**
   * 执行翻译(调用 Lingva API)
   */
  private async doTranslate(text: string, source: string, target: string): Promise<string> {
    const url = `https://lingva.ml/api/v1/${source}/${target}/${encodeURIComponent(text)}`;
 
    const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
    if (!res.ok) throw new Error(`翻译API返回 ${res.status}`);
 
    const json = await res.json();
    return json.translation;
  }
}

5.3 运行效果

用户:翻译 中→日 天气不错
AI:  🌐 翻译结果 (中 → 日)
      原文:天气不错
      译文:いい天気ですね

用户:翻译 What's the weather like in Tokyo?
AI:  🌐 翻译结果 (自动检测 → 中文)
      原文:What's the weather like in Tokyo?
      译文:东京的天气怎么样?

六、实战三:待办管理插件

6.1 设计思路

  • 触发词待办todo备忘

  • 操作添加 / 完成 / 列表 / 删除

  • 存储 :JSON 文件持久化,按用户隔离

  • 亮点 :支持自然语言解析——“明天下午3点给小李打电话” → 自动提取时间、内容

6.2 完整代码

// todo-plugin.ts
import { BasePlugin, PluginContext, PluginManifest } from './plugin-spec';
import * as fs from 'fs';
import * as path from 'path';
 
interface TodoItem {
  id: string;
  content: string;
  createdAt: string;
  completedAt: string | null;
  dueDate: string | null;
  priority: 'low' | 'medium' | 'high';
  tags: string[];
}
 
interface TodoStore {
  [userId: string]: TodoItem[];
}
 
export default class TodoPlugin extends BasePlugin {
  readonly manifest: PluginManifest = {
    id: 'todo',
    name: '待办管理',
    version: '1.0.0',
    author: 'demo',
    description: '待办事项管理:添加/完成/列表/删除',
    permissions: ['storage'],
    triggers: ['待办', 'todo', '备忘', '提醒我', '记一下'],
  };
 
  private store!: TodoStore;
  private storePath!: string;
  private ctx!: PluginContext;
 
  async onInit(ctx: PluginContext): Promise<void> {
    this.ctx = ctx;
    this.storePath = path.join(ctx.dataDir, 'todos.json');
    this.store = this.loadStore();
    ctx.logger.info(`待办插件初始化完成,共加载 ${this.countAll()} 条待办`);
  }
 
  async onDestroy(): Promise<void> {
    this.saveStore();
  }
 
  async onMessage(
    message: string,
    ctx: PluginContext
  ): Promise<{ handled: boolean; reply?: string }> {
    if (!this.matchTrigger(message)) return { handled: false };
 
    const userId = ctx.userId;
 
    // 命令路由
    if (/^查看|列表|所有|list/i.test(message)) {
      return { handled: true, reply: this.listTodos(userId) };
    }
    if (/^(完成|done|✅).+/i.test(message)) {
      return { handled: true, reply: await this.completeTodo(userId, message) };
    }
    if (/^(删除|delete|删).+/i.test(message)) {
      return { handled: true, reply: await this.deleteTodo(userId, message) };
    }
    if (/^(添加|新增|add|待办|todo|备忘)/i.test(message)) {
      return { handled: true, reply: await this.addTodo(userId, message) };
    }
 
    // 默认:尝试当作新增
    return { handled: true, reply: await this.addTodo(userId, message) };
  }
 
  private matchTrigger(message: string): boolean {
    const lower = message.toLowerCase();
    return this.manifest.triggers.some(t => lower.includes(t)) ||
      /^(待办|todo|备忘|查看|列表|完成|done||删除|delete|添加|新增|add)/i.test(message);
  }
 
  /**
   * 添加待办
   */
  private async addTodo(userId: string, message: string): Promise<string> {
    const content = this.extractContent(message);
    if (!content) {
      return '📝 请说明要记录的待办内容,例如:"待办 明天下午3点开会"';
    }
 
    if (!this.store[userId]) this.store[userId] = [];
 
    const dueDate = this.extractDueDate(content);
    const priority = this.extractPriority(content);
    const tags = this.extractTags(content);
 
    const item: TodoItem = {
      id: this.generateId(),
      content,
      createdAt: new Date().toISOString(),
      completedAt: null,
      dueDate,
      priority,
      tags,
    };
 
    this.store[userId].push(item);
    this.saveStore();
 
    const parts = ['✅ 已添加待办:'];
    parts.push(`📌 **${content}**`);
    if (dueDate) parts.push(`⏰ 截止:${dueDate}`);
    if (priority !== 'medium') parts.push(`🔴 优先级:${this.priorityLabel(priority)}`);
    if (tags.length > 0) parts.push(`🏷 标签:${tags.join(', ')}`);
    parts.push(`📋 当前共 ${this.store[userId].length} 条待办`);
 
    return parts.join('\n');
  }
 
  /**
   * 完成待办(按序号)
   */
  private async completeTodo(userId: string, message: string): Promise<string> {
    const numMatch = message.match(/(\d+)/);
    if (!numMatch) return '请指定序号,例如:"完成 3"';
 
    const index = parseInt(numMatch[1]) - 1;
    const todos = this.store[userId];
    if (!todos || index < 0 || index >= todos.length) {
      return `❌ 序号 ${numMatch[1]} 不存在,当前共 ${todos?.length || 0} 条待办`;
    }
 
    const item = todos[index];
    if (item.completedAt) return `⚠️ "${item.content}" 已经完成了`;
 
    item.completedAt = new Date().toISOString();
    this.saveStore();
 
    return `🎉 已完成:**${item.content}**`;
  }
 
  /**
   * 删除待办
   */
  private async deleteTodo(userId: string, message: string): Promise<string> {
    const numMatch = message.match(/(\d+)/);
    if (!numMatch) return '请指定序号,例如:"删除 3"';
 
    const index = parseInt(numMatch[1]) - 1;
    const todos = this.store[userId];
    if (!todos || index < 0 || index >= todos.length) {
      return `❌ 序号 ${numMatch[1]} 不存在`;
    }
 
    const item = todos.splice(index, 1)[0];
    this.saveStore();
 
    return `🗑 已删除:**${item.content}**`;
  }
 
  /**
   * 查看待办列表
   */
  private listTodos(userId: string): string {
    const todos = this.store[userId] || [];
    if (todos.length === 0) return '📭 当前没有待办事项~';
 
    const active = todos.filter(t => !t.completedAt);
    const completed = todos.filter(t => t.completedAt);
 
    const lines: string[] = ['📋 **待办列表**', ''];
 
    if (active.length > 0) {
      lines.push('⏳ 未完成:');
      active.forEach((t, i) => {
        const num = todos.indexOf(t) + 1;
        const pri = t.priority !== 'medium' ? ` ${this.priorityEmoji(t.priority)}` : '';
        const due = t.dueDate ? ` ⏰${t.dueDate}` : '';
        lines.push(`  ${num}. ${t.content}${pri}${due}`);
      });
      lines.push('');
    }
 
    if (completed.length > 0) {
      lines.push('✅ 已完成:');
      completed.slice(-3).forEach(t => {
        lines.push(`  ~~${t.content}~~`);
      });
    }
 
    lines.push('');
    lines.push(`📊 总计 ${todos.length} 条,未完成 ${active.length} 条`);
 
    return lines.join('\n');
  }
 
  // ===== 辅助方法 =====
 
  private extractContent(message: string): string | null {
    return message
      .replace(/^(添加|新增|add\s+|待办|todo|备忘|记一下|提醒我)\s*/i, '')
      .trim() || null;
  }
 
  /**
   * 尝试从内容中提取截止日期关键词
   */
  private extractDueDate(content: string): string | null {
    if (/明天/.test(content)) {
      const d = new Date();
      d.setDate(d.getDate() + 1);
      return d.toISOString().slice(0, 10);
    }
    if (/后天/.test(content)) {
      const d = new Date();
      d.setDate(d.getDate() + 2);
      return d.toISOString().slice(0, 10);
    }
    const dateMatch = content.match(/(\d{4}[-/]\d{1,2}[-/]\d{1,2})/);
    if (dateMatch) return dateMatch[1].replace(/\//g, '-');
    return null;
  }
 
  private extractPriority(content: string): 'low' | 'medium' | 'high' {
    if (/紧急|重要|高优|urgent|important|/.test(content)) return 'high';
    if (/不急|低优|low/.test(content)) return 'low';
    return 'medium';
  }
 
  private extractTags(content: string): string[] {
    const tags: string[] = [];
    const tagMatch = content.match(/#(\S+)/g);
    if (tagMatch) {
      tags.push(...tagMatch.map(t => t.slice(1)));
    }
    return tags;
  }
 
  private priorityLabel(p: string): string {
    switch (p) {
      case 'high': return '🔴 高';
      case 'low': return '🟢 低';
      default: return '🟡 中';
    }
  }
 
  private priorityEmoji(p: string): string {
    switch (p) {
      case 'high': return '🔴';
      case 'low': return '🟢';
      default: return '';
    }
  }
 
  private generateId(): string {
    return Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
  }
 
  private loadStore(): TodoStore {
    try {
      if (fs.existsSync(this.storePath)) {
        return JSON.parse(fs.readFileSync(this.storePath, 'utf-8'));
      }
    } catch (err) {
      this.ctx?.logger?.warn('加载待办数据失败,使用空数据');
    }
    return {};
  }
 
  private saveStore(): void {
    try {
      const dir = path.dirname(this.storePath);
      if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
      fs.writeFileSync(this.storePath, JSON.stringify(this.store, null, 2));
    } catch (err) {
      this.ctx?.logger?.error('保存待办数据失败', err as Error);
    }
  }
 
  private countAll(): number {
    return Object.values(this.store).reduce((sum, arr) => sum + arr.length, 0);
  }
}

6.3 运行效果

用户:待办 明天下午3点给客户发方案 #工作
AI:  ✅ 已添加待办:
      📌 明天下午3点给客户发方案
      ⏰ 截止:2026-06-10
      🏷 标签:工作
      📋 当前共 3 条待办

用户:查看待办
AI:  📋 待办列表
      ⏳ 未完成:
        1. 买水果
        2. 写周报
        3. 明天下午3点给客户发方案 🏷工作

      ✅ 已完成:
        ~~检查服务器日志~~

      📊 总计 4 条,未完成 3 条

用户:完成 2
AI:  🎉 已完成:写周报

七、三个插件的对比总结

维度天气插件翻译插件待办插件
数据来源外部 API外部 API本地 JSON
权限需求networknetworkstorage
触发方式模糊匹配关键词命令+自然语言多命令路由
状态管理无状态无状态有状态持久化
错误处理API 超时降级API 超时降级文件读写容错
复杂度⭐⭐⭐⭐⭐⭐⭐⭐

这三个插件覆盖了插件开发的主流范式,可以作为你自己写插件的模板。


八、插件开发最佳实践

8.1 触发词匹配策略

优先级从高到低:
1. 精确命令:/todo add xxx、/translate en→zh —— 最可靠
2. 关键词匹配:"天气"、"翻译" —— 兼顾自然
3. 兜底模式:未匹配到任何插件 → 交给 AI 自主回复

8.2 错误处理三板斧

try {
  const result = await callExternalAPI(params);
  return formatResult(result);
} catch (err) {
  if (err.name === 'TimeoutError') {
    return '⏳ 请求超时,请稍后重试';        // 超时降级
  } else if (err.message.includes('429')) {
    return '🔥 请求太频繁,请稍后再试';        // 限流降级
  } else {
    ctx.logger.error('未知错误', err);
    return '😵 服务暂时不可用';               // 通用兜底
  }
}

8.3 优先级与互斥策略

多个插件可能匹配同一条消息,处理顺序很重要:

// 按优先级排序插件
const priorityOrder = ['todo', 'weather', 'translate'];  // 待办优先,因为用户意图最明确
 
async handleMessage(message: string) {
  for (const id of priorityOrder) {
    const plugin = this.plugins.get(id);
    if (plugin) {
      const result = await plugin.instance.onMessage(message, ctx);
      if (result.handled) return result.reply;
    }
  }
  return null; // 无插件处理,交给AI
}

8.4 参数校验防御

// 最脆弱的环节:用户输入
// 永远不要信任用户的输入
 
onMessage(message, ctx) {
  const city = this.extractCity(message);
  if (!city || city.length > 20) return fallback();      // 长度校验
  if (!/^[\u4e00-\u9fa5\w\s-]+$/.test(city)) return fallback();  // 字符白名单
  // ...
}

8.5 插件开发 Checklist

在发布插件前,确保:

  • manifest 信息完整(id/name/version/triggers)

  • onInit 不抛异常(哪怕数据目录不存在也能降级)

  • onMessage 始终返回 { handled, reply } ,不依赖外部插件

  • onDestroy 清理资源(定时器/文件句柄/HTTP 连接)

  • 所有外部调用有超时( AbortSignal.timeout

  • 所有 I/O 操作有 try-catch

  • 不修改 ctx 传入对象

  • 不阻塞事件循环(避免同步大计算)


九、收尾

三个插件从头到尾写完,你应该已经掌握了个人AI助手的插件开发范式。这套插件体系的特点是:

  1. 统一基类BasePlugin 约束了接口契约

  2. 热加载 :改完代码,3 秒后自动生效,无需重启

  3. 管道模式 :消息按优先级流过每个插件,第一个处理的生效

  4. 按需加载 :不需要的插件不编译、不依赖


下一篇预告 :系列终章——横评对比同类方案(Dify/Coze/FastGPT 等),帮你搞清楚各种 AI 助手平台的区别与选型依据。


本系列所有代码均为教学演示,生产环境请根据实际情况调整。