S
Codex SDK 教程 TypeScript
第 08 章

实战:全能 AI 助手

实战:打造你的全能 AI 助手

终极 Boss 关 -- 用 Codex SDK 构建一个类似 OpenClaw 的 AI 助手框架:接入飞书/Discord 等通讯平台,支持 Claude Skills 扩展,内置定时任务调度。

我们要做什么

你可能听说过 OpenClaw -- 一个能接入各种通讯平台的开源 AI 助手。我们要做一个类似的东西,但用 Codex SDK 做 AI 引擎。

最终效果

  • 在飞书群里 @机器人 问问题 -> AI 自动回复
  • /skill list -> 查看已安装的能力插件
  • /report my-project -> 触发代码审查 Skill
  • 每天早上 9 点自动发送项目健康报告

技术架构

四层解耦,每层独立可替换:

+---------------------------------------------+
|              通讯平台 Adapters                |
|  +----------+  +----------+  +----------+   |
|  |   飞书    |  | Discord  |  |   CLI    |   |
|  +----+-----+  +----+-----+  +----+-----+   |
|       +--------------+--------------+        |
|                      |                       |
|                      v                       |
|              消息路由 Router                   |
|          (命令解析 + 分发)                     |
|                      |                       |
|                      v                       |
|    +------------------------------------+    |
|    |          AI 引擎 Engine             |    |
|    |       (Codex SDK + Thread)         |    |
|    +-------------------+----------------+    |
|           /            |            \        |
|    +---------+ +--------+ +----------+       |
|    | Skills  | | Memory | |Scheduler |       |
|    | 技能扩展 | | 线程记忆| | 定时任务  |       |
|    +---------+ +--------+ +----------+       |
+---------------------------------------------+

项目初始化

bashmkdir codex-assistant && cd codex-assistant
git init
npm init -y
npm install @openai/codex-sdk node-cron gray-matter glob
npm install -D typescript tsx @types/node

目录结构:

codex-assistant/
├── src/
│   ├── types.ts           # 统一类型定义
│   ├── adapters/
│   │   ├── base.ts        # 适配器抽象基类
│   │   ├── cli.ts         # CLI 调试适配器
│   │   └── feishu.ts      # 飞书适配器
│   ├── engine.ts          # AI 引擎(核心)
│   ├── skills.ts          # Skills 管理器
│   ├── scheduler.ts       # 定时任务调度器
│   ├── router.ts          # 消息路由
│   └── index.ts           # 启动入口
├── skills/                # Skills 存放目录
│   └── code-review.md     # 示例 Skill
├── tsconfig.json
└── package.json

tsconfig.json

json{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "esModuleInterop": true,
    "strict": true,
    "outDir": "./dist",
    "rootDir": "./src"
  },
  "include": ["src/**/*"]
}

第一步:统一消息类型(types.ts)

不管消息从哪个平台来(飞书、Discord、终端),统一成一个格式:

typescript// src/types.ts

/** 收到的消息 -- 统一格式 */
export interface Message {
  id: string;
  platform: "feishu" | "discord" | "slack" | "cli";
  channelId: string;
  userId: string;
  userName: string;
  content: string;
  timestamp: Date;
}

/** 要发送的回复 */
export interface Reply {
  content: string;
  channelId: string;
  replyToMessageId?: string;
}

/** 适配器接口 -- 每个通讯平台实现一个 */
export interface Adapter {
  readonly name: string;
  start(): Promise<void>;
  stop(): Promise<void>;
  onMessage(handler: (msg: Message) => Promise<void>): void;
  sendReply(reply: Reply): Promise<void>;
}

设计思路:Adapter 接口定义了"接收消息"和"发送回复"两个能力,跟具体平台无关。以后要接入新平台(Telegram、微信),只要实现这个接口就行。

第二步:平台适配器

适配器基类(adapters/base.ts)

typescript// src/adapters/base.ts
import { EventEmitter } from "events";
import type { Message, Reply, Adapter } from "../types.ts";

export abstract class BaseAdapter extends EventEmitter implements Adapter {
  abstract readonly name: string;

  abstract start(): Promise<void>;
  abstract stop(): Promise<void>;
  abstract sendReply(reply: Reply): Promise<void>;

  onMessage(handler: (msg: Message) => Promise<void>): void {
    this.on("message", handler);
  }

  protected emitMessage(msg: Message): void {
    this.emit("message", msg);
  }
}

CLI 调试适配器(adapters/cli.ts)

开发时用的,在终端里直接和 AI 对话:

typescript// src/adapters/cli.ts
import { createInterface, type Interface } from "readline/promises";
import { stdin as input, stdout as output } from "process";
import { randomUUID } from "crypto";
import { BaseAdapter } from "./base.ts";
import type { Reply } from "../types.ts";

export class CliAdapter extends BaseAdapter {
  readonly name = "cli";
  private rl: Interface | null = null;
  private running = false;

  async start(): Promise<void> {
    this.rl = createInterface({ input, output });
    this.running = true;

    console.log("AI 助手已启动(CLI 模式)");
    console.log("输入消息开始对话,输入 /help 查看命令列表");
    console.log("─".repeat(50));

    this.loop();
  }

  private async loop(): Promise<void> {
    while (this.running && this.rl) {
      try {
        const content = await this.rl.question("\n你: ");
        if (!content.trim()) continue;

        if (content.trim() === "/quit" || content.trim() === "/exit") {
          console.log("再见!");
          this.running = false;
          break;
        }

        this.emitMessage({
          id: randomUUID(),
          platform: "cli",
          channelId: "cli-default",
          userId: "local-user",
          userName: "你",
          content: content.trim(),
          timestamp: new Date(),
        });
      } catch {
        // readline 被关闭
        break;
      }
    }
  }

  async sendReply(reply: Reply): Promise<void> {
    console.log(`\n助手: ${reply.content}`);
  }

  async stop(): Promise<void> {
    this.running = false;
    this.rl?.close();
  }
}

飞书适配器(adapters/feishu.ts)

接入飞书机器人,通过 Webhook 接收消息,通过 API 发送回复:

typescript// src/adapters/feishu.ts
import { createServer, type Server, type IncomingMessage, type ServerResponse } from "http";
import { randomUUID } from "crypto";
import { BaseAdapter } from "./base.ts";
import type { Reply } from "../types.ts";

interface FeishuConfig {
  appId: string;
  appSecret: string;
  port?: number;
  verificationToken?: string;
}

export class FeishuAdapter extends BaseAdapter {
  readonly name = "feishu";
  private server: Server;
  private port: number;
  private appId: string;
  private appSecret: string;
  private accessToken: string = "";
  private tokenExpiry: number = 0;

  constructor(config: FeishuConfig) {
    super();
    this.appId = config.appId;
    this.appSecret = config.appSecret;
    this.port = config.port || 3000;

    this.server = createServer((req, res) => this.handleRequest(req, res));
  }

  async start(): Promise<void> {
    await this.refreshToken();
    this.server.listen(this.port);
    console.log(`飞书适配器已启动,监听端口 ${this.port}`);
    console.log(`请在飞书开放平台配置事件回调 URL: http://your-server:${this.port}/webhook`);
  }

  async stop(): Promise<void> {
    this.server.close();
  }

  // 获取/刷新 Tenant Access Token
  private async refreshToken(): Promise<void> {
    const res = await fetch("https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        app_id: this.appId,
        app_secret: this.appSecret,
      }),
    });
    const data = await res.json() as { tenant_access_token: string; expire: number };
    this.accessToken = data.tenant_access_token;
    this.tokenExpiry = Date.now() + (data.expire - 60) * 1000;
  }

  private async ensureToken(): Promise<string> {
    if (Date.now() >= this.tokenExpiry) {
      await this.refreshToken();
    }
    return this.accessToken;
  }

  // 处理飞书 Webhook 回调
  private async handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
    if (req.method !== "POST" || req.url !== "/webhook") {
      res.writeHead(404);
      res.end();
      return;
    }

    const body = await this.readBody(req);
    const data = JSON.parse(body);

    // 飞书的 URL 验证请求
    if (data.type === "url_verification") {
      res.writeHead(200, { "Content-Type": "application/json" });
      res.end(JSON.stringify({ challenge: data.challenge }));
      return;
    }

    // 处理消息事件
    if (data.header?.event_type === "im.message.receive_v1") {
      const event = data.event;
      const msgContent = JSON.parse(event.message.content);

      this.emitMessage({
        id: event.message.message_id,
        platform: "feishu",
        channelId: event.message.chat_id,
        userId: event.sender.sender_id.open_id,
        userName: event.sender.sender_id.open_id,
        content: msgContent.text?.replace(/@_all|@\w+/g, "").trim() || "",
        timestamp: new Date(parseInt(event.message.create_time) * 1000),
      });
    }

    res.writeHead(200);
    res.end("ok");
  }

  // 发送回复消息
  async sendReply(reply: Reply): Promise<void> {
    const token = await this.ensureToken();

    await fetch("https://open.feishu.cn/open-apis/im/v1/messages", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "Authorization": `Bearer ${token}`,
      },
      body: JSON.stringify({
        receive_id: reply.channelId,
        msg_type: "text",
        content: JSON.stringify({ text: reply.content }),
      }),
    });
  }

  private readBody(req: IncomingMessage): Promise<string> {
    return new Promise((resolve, reject) => {
      let body = "";
      req.on("data", (chunk) => (body += chunk));
      req.on("end", () => resolve(body));
      req.on("error", reject);
    });
  }
}

飞书开放平台配置步骤请参考后文"接入飞书的完整步骤"。

第三步:Skills 系统 -- 兼容 Claude Skills 格式

这是最有趣的部分。我们实现一套 Skills 系统,兼容 Claude 的 SKILL.md 格式。

SKILL.md 文件格式

每个 Skill 就是一个 Markdown 文件,头部用 YAML frontmatter 定义元数据:

yaml---
name: code-review
description: 自动代码审查,分析代码质量并给出改进建议
trigger: /review
version: "1.0.0"
author: your-name
tags:
  - code
  - review
  - quality
arguments:
  - name: target
    description: 审查目标(git ref 或文件路径)
    required: false
    default: "HEAD~1"
  - name: format
    description: 输出格式
    required: false
    default: "text"
---

## 指令

你是一个资深代码审查专家,拥有 10 年以上的软件开发经验。

请根据用户的要求审查代码,从以下维度分析:
1. Bug 和逻辑错误
2. 安全隐患
3. 性能问题
4. 代码风格
5. 可维护性

审查标准:
- 只报告真正有意义的问题
- 每个问题给出具体的修改建议
- 如果代码写得好,也要指出

Skills 管理器实现

typescript// src/skills.ts
import { readFileSync, writeFileSync, existsSync, mkdirSync, cpSync, unlinkSync } from "fs";
import { join, basename } from "path";
import { globSync } from "glob";
import matter from "gray-matter";

/** Skill 元数据(来自 YAML frontmatter) */
export interface SkillMeta {
  name: string;
  description: string;
  trigger?: string;
  version?: string;
  author?: string;
  tags?: string[];
  arguments?: SkillArgument[];
}

export interface SkillArgument {
  name: string;
  description: string;
  required?: boolean;
  default?: string;
}

/** 一个完整的 Skill */
export interface Skill {
  meta: SkillMeta;
  instructions: string;   // markdown body = AI 的指令
  filePath: string;
}

export class SkillManager {
  private skills: Map<string, Skill> = new Map();
  private skillsDir: string;

  constructor(skillsDir: string = "./skills") {
    this.skillsDir = skillsDir;
    if (!existsSync(skillsDir)) {
      mkdirSync(skillsDir, { recursive: true });
    }
    this.loadAll();
  }

  /** 加载 skills 目录下所有 .md 文件 */
  private loadAll(): void {
    const files = globSync(join(this.skillsDir, "**/*.md"));
    for (const file of files) {
      try {
        const skill = this.parseSkillFile(file);
        if (skill) {
          this.skills.set(skill.meta.name, skill);
        }
      } catch (err) {
        console.warn(`跳过无效 Skill 文件: ${file}`, err);
      }
    }
    console.log(`已加载 ${this.skills.size} 个 Skills`);
  }

  /** 解析单个 SKILL.md 文件 */
  private parseSkillFile(filePath: string): Skill | null {
    const raw = readFileSync(filePath, "utf-8");
    const { data, content } = matter(raw);
    const meta = data as SkillMeta;

    if (!meta.name) {
      console.warn(`Skill 文件缺少 name 字段: ${filePath}`);
      return null;
    }

    return {
      meta,
      instructions: content.trim(),
      filePath,
    };
  }

  /** 安装 Skill -- 从本地路径复制到 skills 目录 */
  install(sourcePath: string): Skill {
    if (!existsSync(sourcePath)) {
      throw new Error(`文件不存在: ${sourcePath}`);
    }

    const skill = this.parseSkillFile(sourcePath);
    if (!skill) {
      throw new Error(`无效的 Skill 文件: ${sourcePath}`);
    }

    const destPath = join(this.skillsDir, basename(sourcePath));
    cpSync(sourcePath, destPath);

    skill.filePath = destPath;
    this.skills.set(skill.meta.name, skill);
    console.log(`已安装 Skill: ${skill.meta.name}`);
    return skill;
  }

  /** 搜索 Skill -- 按名称、描述、标签模糊匹配 */
  search(query: string): Skill[] {
    const q = query.toLowerCase();
    return Array.from(this.skills.values()).filter((skill) => {
      const { name, description, tags } = skill.meta;
      return (
        name.toLowerCase().includes(q) ||
        description.toLowerCase().includes(q) ||
        (tags || []).some((t) => t.toLowerCase().includes(q))
      );
    });
  }

  /** 获取指定 Skill */
  get(name: string): Skill | undefined {
    return this.skills.get(name);
  }

  /** 列出所有 Skill */
  list(): Skill[] {
    return Array.from(this.skills.values());
  }

  /** 卸载 Skill */
  uninstall(name: string): boolean {
    const skill = this.skills.get(name);
    if (!skill) return false;

    try {
      unlinkSync(skill.filePath);
    } catch {
      // 文件可能已经不存在
    }
    this.skills.delete(name);
    console.log(`已卸载 Skill: ${name}`);
    return true;
  }

  /** 根据消息内容匹配 Skill trigger */
  matchTrigger(message: string): { skill: Skill; args: Record<string, string> } | null {
    for (const skill of this.skills.values()) {
      if (!skill.meta.trigger) continue;

      if (message.startsWith(skill.meta.trigger)) {
        // 解析参数:trigger 后面的内容按空格分割
        const argsStr = message.slice(skill.meta.trigger.length).trim();
        const argParts = argsStr ? argsStr.split(/\s+/) : [];
        const args: Record<string, string> = {};

        (skill.meta.arguments || []).forEach((argDef, i) => {
          args[argDef.name] = argParts[i] || argDef.default || "";
        });

        return { skill, args };
      }
    }
    return null;
  }

  /** 构建包含 Skill 指令的 Prompt */
  buildPrompt(skill: Skill, args: Record<string, string>, userMessage: string): string {
    let prompt = skill.instructions;

    // 替换参数占位符
    for (const [key, value] of Object.entries(args)) {
      prompt = prompt.replace(new RegExp(`\\{\\{${key}\\}\\}`, "g"), value);
    }

    // 附加用户原始消息和参数信息
    prompt += `\n\n---\n用户原始消息: ${userMessage}`;
    prompt += `\n参数: ${JSON.stringify(args)}`;

    return prompt;
  }
}

第四步:AI 引擎(engine.ts)

封装 Codex SDK,为每个聊天频道维护独立的对话线程:

typescript// src/engine.ts
import { Codex, type Thread } from "@openai/codex-sdk";
import type { SkillManager } from "./skills.ts";

interface EngineConfig {
  defaultModel?: string;
  sandboxMode?: "read-only" | "workspace-write" | "danger-full-access";
  reasoningEffort?: "minimal" | "low" | "medium" | "high" | "xhigh";
  systemPrompt?: string;
}

export class AIEngine {
  private codex: Codex;
  private threads: Map<string, Thread> = new Map();
  private skillManager: SkillManager;
  private config: EngineConfig;

  constructor(skillManager: SkillManager, config: EngineConfig = {}) {
    this.codex = new Codex();
    this.skillManager = skillManager;
    this.config = {
      defaultModel: config.defaultModel,
      sandboxMode: config.sandboxMode || "read-only",
      reasoningEffort: config.reasoningEffort || "medium",
      systemPrompt: config.systemPrompt || "你是一个友好的 AI 助手,擅长编程和技术问题。回复请用中文。",
    };
  }

  /** 处理聊天消息 */
  async chat(channelId: string, message: string): Promise<string> {
    // 1. 检查是否匹配某个 Skill
    const match = this.skillManager.matchTrigger(message);
    let prompt: string;

    if (match) {
      prompt = this.skillManager.buildPrompt(match.skill, match.args, message);
      console.log(`   匹配 Skill: ${match.skill.meta.name}`);
    } else {
      prompt = message;
    }

    // 2. 如果是首次对话,注入 system prompt
    if (!this.threads.has(channelId)) {
      prompt = `${this.config.systemPrompt}\n\n---\n\n${prompt}`;
    }

    // 3. 获取或创建该频道的对话线程
    const thread = this.getOrCreateThread(channelId);

    // 4. 调用 Codex SDK
    try {
      const turn = await thread.run(prompt);
      return turn.finalResponse || "(AI 没有返回文本回复)";
    } catch (err) {
      const msg = err instanceof Error ? err.message : String(err);
      console.error(`   AI 引擎错误: ${msg}`);
      return `抱歉,处理时出了点问题: ${msg}`;
    }
  }

  /** 获取或创建线程 */
  private getOrCreateThread(channelId: string): Thread {
    let thread = this.threads.get(channelId);
    if (!thread) {
      thread = this.codex.startThread({
        model: this.config.defaultModel,
        sandboxMode: this.config.sandboxMode,
        modelReasoningEffort: this.config.reasoningEffort,
        skipGitRepoCheck: true,
      });
      this.threads.set(channelId, thread);
    }
    return thread;
  }

  /** 重置某个频道的对话 */
  resetThread(channelId: string): void {
    this.threads.delete(channelId);
    console.log(`   已重置频道 ${channelId} 的对话`);
  }

  /** 重置所有对话 */
  resetAll(): void {
    this.threads.clear();
    console.log("   已重置所有对话");
  }
}

第五步:定时任务调度器(scheduler.ts)

node-cron 实现 Cron 表达式驱动的定时任务:

typescript// src/scheduler.ts
import cron from "node-cron";
import { randomUUID } from "crypto";

/** 定时任务配置 */
export interface ScheduledTask {
  id: string;
  name: string;
  cronExpression: string;  // Cron 表达式,如 "0 9 * * *"(每天9点)
  prompt: string;          // 要发给 AI 的指令
  channelId: string;       // 结果发到哪个频道
  skillName?: string;      // 可选:绑定某个 Skill
  enabled: boolean;
}

type TaskExecutor = (task: ScheduledTask) => Promise<void>;

export class Scheduler {
  private tasks: Map<string, { config: ScheduledTask; job: cron.ScheduledTask }> = new Map();
  private executor: TaskExecutor;

  constructor(executor: TaskExecutor) {
    this.executor = executor;
  }

  /** 添加定时任务 */
  add(taskConfig: Omit<ScheduledTask, "id" | "enabled">): ScheduledTask {
    const task: ScheduledTask = {
      ...taskConfig,
      id: randomUUID().slice(0, 8),
      enabled: true,
    };

    // 验证 Cron 表达式
    if (!cron.validate(task.cronExpression)) {
      throw new Error(`无效的 Cron 表达式: ${task.cronExpression}`);
    }

    const job = cron.schedule(task.cronExpression, async () => {
      console.log(`\n定时任务触发: ${task.name}`);
      try {
        await this.executor(task);
      } catch (err) {
        console.error(`   任务执行失败: ${err}`);
      }
    });

    this.tasks.set(task.id, { config: task, job });
    console.log(`定时任务已添加: ${task.name} (${task.cronExpression}) [ID: ${task.id}]`);
    return task;
  }

  /** 删除定时任务 */
  remove(id: string): boolean {
    const entry = this.tasks.get(id);
    if (!entry) return false;

    entry.job.stop();
    this.tasks.delete(id);
    console.log(`定时任务已删除: ${entry.config.name}`);
    return true;
  }

  /** 列出所有定时任务 */
  list(): ScheduledTask[] {
    return Array.from(this.tasks.values()).map((e) => e.config);
  }

  /** 停止所有任务 */
  stopAll(): void {
    for (const { job } of this.tasks.values()) {
      job.stop();
    }
    console.log("所有定时任务已停止");
  }
}

Cron 表达式速查

表达式 含义
0 9 * * * 每天 9:00
0 9 * * 1 每周一 9:00
0 */2 * * * 每 2 小时
*/30 * * * * 每 30 分钟
0 9 1 * * 每月 1 号 9:00

第六步:消息路由(router.ts)

解析命令并分发到对应的处理器:

typescript// src/router.ts
import type { Message } from "./types.ts";
import type { AIEngine } from "./engine.ts";
import type { SkillManager } from "./skills.ts";
import type { Scheduler } from "./scheduler.ts";

export class Router {
  constructor(
    private engine: AIEngine,
    private skillManager: SkillManager,
    private scheduler: Scheduler,
  ) {}

  /** 路由消息 -- 返回回复内容 */
  async handle(msg: Message): Promise<string> {
    const { content } = msg;

    // 内置命令
    if (content === "/help") return this.handleHelp();
    if (content === "/reset") return this.handleReset(msg.channelId);
    if (content.startsWith("/skill")) return this.handleSkillCommand(content);
    if (content.startsWith("/schedule")) return this.handleScheduleCommand(content, msg.channelId);

    // 普通消息 -> AI 引擎
    return this.engine.chat(msg.channelId, content);
  }

  private handleHelp(): string {
    return [
      "AI 助手命令列表:",
      "",
      "直接输入文字 -- 跟 AI 对话",
      "",
      "Skills 管理:",
      "  /skill list              -- 查看已安装 Skills",
      "  /skill search <关键词>    -- 搜索 Skills",
      "  /skill install <路径>     -- 安装 Skill",
      "  /skill info <名称>        -- 查看 Skill 详情",
      "  /skill uninstall <名称>   -- 卸载 Skill",
      "",
      "定时任务:",
      '  /schedule add <名称> <cron> <指令> -- 添加定时任务',
      "  /schedule list            -- 查看所有定时任务",
      "  /schedule remove <ID>     -- 删除定时任务",
      "",
      "其他:",
      "  /reset  -- 重置当前对话",
      "  /help   -- 显示本帮助",
    ].join("\n");
  }

  private handleReset(channelId: string): string {
    this.engine.resetThread(channelId);
    return "对话已重置,让我们重新开始吧!";
  }

  private handleSkillCommand(content: string): string {
    const parts = content.split(/\s+/);
    const subCmd = parts[1];
    const arg = parts.slice(2).join(" ");

    switch (subCmd) {
      case "list": {
        const skills = this.skillManager.list();
        if (skills.length === 0) return "暂无已安装的 Skills。";
        return [
          "已安装的 Skills:",
          "",
          ...skills.map((s) => {
            const trigger = s.meta.trigger ? ` (触发: ${s.meta.trigger})` : "";
            return `  - ${s.meta.name}${trigger} -- ${s.meta.description}`;
          }),
        ].join("\n");
      }

      case "search": {
        if (!arg) return "用法: /skill search <关键词>";
        const results = this.skillManager.search(arg);
        if (results.length === 0) return `未找到匹配 "${arg}" 的 Skill。`;
        return [
          `搜索结果 (${results.length} 个):`,
          "",
          ...results.map((s) => `  - ${s.meta.name} -- ${s.meta.description}`),
        ].join("\n");
      }

      case "install": {
        if (!arg) return "用法: /skill install <文件路径>";
        try {
          const skill = this.skillManager.install(arg);
          return `已安装 Skill: ${skill.meta.name} -- ${skill.meta.description}`;
        } catch (err) {
          return `安装失败: ${err instanceof Error ? err.message : err}`;
        }
      }

      case "info": {
        if (!arg) return "用法: /skill info <名称>";
        const skill = this.skillManager.get(arg);
        if (!skill) return `未找到 Skill: ${arg}`;
        const argsList = (skill.meta.arguments || [])
          .map((a) => `    - ${a.name}${a.required ? " (必填)" : ""}: ${a.description}`)
          .join("\n");
        return [
          `Skill: ${skill.meta.name}`,
          `   描述: ${skill.meta.description}`,
          `   版本: ${skill.meta.version || "未知"}`,
          `   作者: ${skill.meta.author || "未知"}`,
          `   触发: ${skill.meta.trigger || "无"}`,
          `   标签: ${(skill.meta.tags || []).join(", ") || "无"}`,
          argsList ? `   参数:\n${argsList}` : "   参数: 无",
        ].join("\n");
      }

      case "uninstall": {
        if (!arg) return "用法: /skill uninstall <名称>";
        return this.skillManager.uninstall(arg)
          ? `已卸载 Skill: ${arg}`
          : `未找到 Skill: ${arg}`;
      }

      default:
        return "用法: /skill [list|search|install|info|uninstall]";
    }
  }

  private handleScheduleCommand(content: string, channelId: string): string {
    const parts = content.split(/\s+/);
    const subCmd = parts[1];

    switch (subCmd) {
      case "add": {
        // /schedule add <name> <cron> <prompt...>
        const name = parts[2];
        const cronExpr = parts[3];
        const prompt = parts.slice(4).join(" ");

        if (!name || !cronExpr || !prompt) {
          return '用法: /schedule add <名称> <cron表达式> <AI指令>\n示例: /schedule add morning-report "0 9 * * *" 生成今日代码仓库健康报告';
        }

        try {
          const task = this.scheduler.add({
            name,
            cronExpression: cronExpr,
            prompt,
            channelId,
          });
          return `定时任务已添加!\n   名称: ${task.name}\n   计划: ${task.cronExpression}\n   ID: ${task.id}`;
        } catch (err) {
          return `添加失败: ${err instanceof Error ? err.message : err}`;
        }
      }

      case "list": {
        const tasks = this.scheduler.list();
        if (tasks.length === 0) return "暂无定时任务。";
        return [
          "定时任务列表:",
          "",
          ...tasks.map((t) =>
            `  [${t.id}] ${t.name} -- ${t.cronExpression} -> "${t.prompt.slice(0, 30)}${t.prompt.length > 30 ? "..." : ""}"`
          ),
        ].join("\n");
      }

      case "remove": {
        const id = parts[2];
        if (!id) return "用法: /schedule remove <ID>";
        return this.scheduler.remove(id)
          ? `定时任务已删除: ${id}`
          : `未找到任务: ${id}`;
      }

      default:
        return "用法: /schedule [add|list|remove]";
    }
  }
}

第七步:启动入口(index.ts)

把所有组件组装起来:

typescript// src/index.ts
import { SkillManager } from "./skills.ts";
import { AIEngine } from "./engine.ts";
import { Scheduler } from "./scheduler.ts";
import { Router } from "./router.ts";
import { CliAdapter } from "./adapters/cli.ts";
// 需要接入飞书时取消下面的注释:
// import { FeishuAdapter } from "./adapters/feishu.ts";

async function main() {
  console.log("正在启动 AI 助手...\n");

  // 1. 初始化 Skills 系统
  const skillManager = new SkillManager("./skills");

  // 2. 初始化 AI 引擎
  const engine = new AIEngine(skillManager, {
    sandboxMode: "read-only",
    reasoningEffort: "medium",
    systemPrompt: "你是一个友好的 AI 助手,擅长编程和技术问题。回复简洁明了,用中文。",
  });

  // 3. 选择适配器
  // CLI 模式(开发调试用)
  const adapter = new CliAdapter();

  // 飞书模式(生产环境用)
  // const adapter = new FeishuAdapter({
  //   appId: process.env.FEISHU_APP_ID!,
  //   appSecret: process.env.FEISHU_APP_SECRET!,
  //   port: 3000,
  // });

  // 4. 初始化定时调度器
  const scheduler = new Scheduler(async (task) => {
    console.log(`\n执行定时任务: ${task.name}`);
    const result = await engine.chat(task.channelId, task.prompt);
    await adapter.sendReply({
      content: `[定时任务: ${task.name}]\n\n${result}`,
      channelId: task.channelId,
    });
  });

  // 5. 初始化消息路由
  const router = new Router(engine, skillManager, scheduler);

  // 6. 注册消息处理器
  adapter.onMessage(async (msg) => {
    console.log(`\n[${msg.platform}] ${msg.userName}: ${msg.content}`);
    const reply = await router.handle(msg);
    await adapter.sendReply({
      content: reply,
      channelId: msg.channelId,
      replyToMessageId: msg.id,
    });
  });

  // 7. 启动!
  await adapter.start();

  // 优雅退出
  process.on("SIGINT", async () => {
    console.log("\n\n正在停止...");
    scheduler.stopAll();
    await adapter.stop();
    process.exit(0);
  });
}

main().catch((err) => {
  console.error("启动失败:", err);
  process.exit(1);
});

创建示例 Skill

写一个示例 Skill 文件,方便测试:

yaml# skills/code-review.md

---
name: code-review
description: 自动代码审查,分析代码质量并给出改进建议
trigger: /review
version: "1.0.0"
author: demo
tags:
  - code
  - review
arguments:
  - name: target
    description: 审查目标(git ref 或文件路径)
    required: false
    default: "HEAD~1"
---

## 指令

你是一个资深代码审查专家。请分析以下代码变更:

审查目标: {{target}}

请从以下维度分析:
1. Bug 和逻辑错误
2. 安全隐患
3. 性能问题
4. 代码风格

每个问题给出具体的修改建议。如果代码写得好,也请指出。

试一试

启动 AI 助手:

bashnpx tsx src/index.ts

然后试试这些操作:

你: 你好,你能做什么?
助手: 我是一个 AI 助手,我可以帮你...

你: /help
助手: AI 助手命令列表...

你: /skill list
助手: 已安装的 Skills:
  - code-review (触发: /review) -- 自动代码审查...

你: /skill info code-review
助手: Skill: code-review
   描述: 自动代码审查...

你: /review HEAD~3
助手: (AI 会根据 code-review Skill 的指令进行代码审查)

你: /schedule add daily-check "0 9 * * *" 检查当前项目有没有新的安全漏洞
助手: 定时任务已添加!
   名称: daily-check
   计划: 0 9 * * *
   ID: a1b2c3d4

你: /schedule list
助手: 定时任务列表:
  [a1b2c3d4] daily-check -- 0 9 * * * -> "检查当前项目有没有新的安全漏洞"

你: /reset
助手: 对话已重置,让我们重新开始吧!

接入飞书的完整步骤

  1. 创建飞书应用

  2. 配置机器人能力

    • 在应用的"添加应用能力"里,开启"机器人"
    • 设置机器人名称和头像
  3. 配置事件订阅

    • 在"事件订阅"页面,设置请求 URL:http://your-server:3000/webhook
    • 订阅事件:im.message.receive_v1(接收消息)
  4. 配置权限

    • 开通权限:im:message(获取和发送消息)
  5. 启动服务

    bashexport FEISHU_APP_ID="cli_xxxxx"
    export FEISHU_APP_SECRET="xxxxx"
    npx tsx src/index.ts
  6. 发布应用

    • 在开放平台提交审核,审核通过后即可在飞书中使用

扩展方向

这个框架的每一层都可以继续扩展:

更多适配器

  • Discord:用 discord.js 库,监听 messageCreate 事件
  • Slack:用 Bolt for JavaScript,配置 Event Subscriptions
  • Telegram:用 telegraf
  • 微信企业号:类似飞书,Webhook + API

Skill 增强

  • Skill 市场:从 GitHub 仓库一键安装 Skills(/skill install https://github.com/...
  • Skill 链:一个 Skill 触发另一个,形成工作流
  • Skill 权限:不同用户能用不同的 Skills

更多能力

  • 线程持久化:用 thread.id + resumeThread() 实现跨重启的长期记忆
  • Web 管理面板:Express/Fastify 搭一个管理页面,可视化管理 Skills 和定时任务
  • Docker 部署:打包成 Docker 镜像,一键部署到云服务器
  • 多租户:不同的群/频道使用不同的 AI 配置和 Skills

小结

这一章我们从零构建了一个完整的 AI 助手框架:

组件 干什么的 用到的技术
Adapter 接入通讯平台 EventEmitter、HTTP Server
Router 解析命令、分发消息 字符串解析
Engine AI 对话核心 Codex SDK、Thread 管理
Skills 能力扩展 YAML frontmatter、gray-matter
Scheduler 定时任务 node-cron

整个框架 500 行左右的 TypeScript,但五脏俱全:可插拔的平台适配、兼容 Claude 的 Skills 格式、Cron 定时调度。

你可以基于这个框架,快速构建出属于自己的 AI 助手!