【从0开发类OpenClaw】Day 6:AI 走出浏览器了——飞书渠道 + 定时调度 + 一颗会主动说话的心

从零写一个类 OpenClaw 的 AI 助手框架,全程记录踩坑。今天是第六天:把 Day 5 路线图里的"渠道 + 调度"一次干完——AI 从网页里放出来了(飞书私聊/群聊,手机里直接对话),还长出了"主动说话"的能力(定时任务到点自己开口)。顺手补了 .env 加载、/agent 切换、长回复分段三个打磨项,外加一场"说好的 Telegram 被墙拦下"的临时改道。


前情回顾:Day 5 的结尾,我立了四杆 Flag——Telegram 渠道、飞书渠道、定时调度、journal 清理。今天开干,第一杆就倒了:Telegram 的 API 域名在国内根本连不上。计划赶不上变化,临时改道飞书,结果这一改,把"渠道适配器"的架构红利吃得干干净净——一天之内,渠道 + 调度 + 打磨全落地,还在手机里收到了 AI 的回复。

今天的剧情线:改道飞书 → 长连接接入 → SSE 渲染器 → 会话映射 → 调度执行器 → .env 加载 → 手机实测 → 收尾打磨

第一幕:说好的 Telegram,被一堵看不见的墙拦下了

Day 5 的"明天干点啥"里,Telegram Bot 排在第一个,理由是"接渠道只是插拔"。今天真要插的时候,发现插头都够不着插座:

api.telegram.org  →  UND_ERR_CONNECT_TIMEOUT(被墙,需要代理)

国内网络下 Telegram Bot API 根本不可达——我们连代理都懒得挂(能直连绝不绕路),直接换目标。选型横评:

渠道 国内直连 接入方式 结论
Telegram ❌ 被墙 长轮询 否决(网络条件不允许)
飞书(Feishu/Lark) ✅ 直连 WebSocket 长连接 ✅ 选中
微信 个人号有封号风险 否决(稳定性)
钉钉 HTTP 回调需公网地址 备选(要公网)

飞书胜出的三个理由:① 国内直连免代理;② 长连接模式免公网回调地址——本地开发就能跑通,不用买域名不用搞内网穿透;③ 团队/个人日常都在用,是"随身助手"最现实的入口。

spec 同步从 v0.1(Telegram)改到 v0.2(飞书)。这算是 Day 6 的第一个"坑"——不是代码坑,是环境坑:写代码前先确认目标服务在你的网络里能通,否则做完了全是白工。

飞书应用前置(一次性外部操作)

接飞书之前,得先在开放平台建应用(跟代码开发并行,不阻塞):

open.feishu.cn → 开发者后台 → 创建企业自建应用
  1. 开通权限:im:message(读消息)、im:message:send_as_bot(以机器人发消息)
  2. 事件订阅:添加 im.message.receive_v1,方式选「长连接」(WebSocket)
  3. 发布版本:测试企业直接发布;正式企业需管理员审批
  4. 拿到 App ID + App Secret → 配置到环境变量

关键设计决策:代码按"无凭据即优雅降级"写——凭据没就绪时 bot 模块空转,WebUI/API 完全不受影响。这样开发、测试、E2E 全都不被外部流程卡脖子。

第二幕:渠道 = 适配器,第一次"插拔"就尝到了甜头

Day 5 差距分析的核心结论是"补能力都是插拔不是重构",今天第一次实操验证。渠道模块的目录结构:

apps/api/src/
├── channels/
│   ├── channels.module.ts      条件注册:无凭据注入 null client
│   └── feishu/
│       ├── feishu.lark-client.ts  官方 SDK 薄封装(FeishuClientPort 端口)
│       ├── feishu.bot.ts          消息 → buildAgent → streamChat → 渲染 → 回复
│       ├── feishu.renderer.ts     SSE 事件 → 飞书文本(纯函数)
│       └── feishu.sessions.ts     chat_id ↔ conversationId 映射(SQLite)
└── schedule/                    调度(第三幕细说)

新增依赖只有 2 个(spec 的 P6 原则:最小依赖):

"@larksuiteoapi/node-sdk": "^1.73.0",   // 飞书官方 SDK
"@nestjs/schedule": "^6.1.3"            // Nest cron 集成

坑①:npm 包名不是 lark-oapi,是 @larksuiteoapi/node-sdk

spec 初稿里写的是 lark-oapi——这是抄资料抄出来的错。飞书开放平台文档历史上叫 Lark,官方 Node SDK 的 npm 包名是 @larksuiteoapi/node-sdk(还有个叫 lark-oapi 的包根本不是官方维护版,版本老旧类型残缺)。

npm i lark-oapi 能装上,但类型残缺、API 对不上,等报错才发现装错包了。装依赖前先 npm view <包名> 确认官方包名,别信二手文档。

坑②:SDK 事件回调是"扁平结构",不是想象的嵌套

飞书 SDK 的 WSClient 用起来有个大坑:事件回调的 data 不是 { event: { message: ... } } 这种嵌套结构,而是扁平的——data.messagedata.sender 直接平铺在顶层:

const dispatcher = new lark.EventDispatcher({}).register({
  'im.message.receive_v1': async (data) => {
    // data 是扁平的!不是 data.event.message
    const msg = data?.message;            // ← 消息本体
    const sender = data?.sender?.sender_id?.open_id;  // ← 发送者
    const chatType = msg.chat_type === 'group' ? 'group' : 'p2p';
  },
});

// 关键:EventDispatcher 是作为参数传给 wsClient.start() 的
await wsClient.start({ eventDispatcher: dispatcher });

两个细节:① EventDispatcher 通过 wsClient.start({ eventDispatcher }) 注入,不是单独 start;② 回调结构要靠实际打印 data 字段确认,别信直觉。SDK 类型里这段是 any,编译器不会帮你兜底。

坑③:关连接是 close(),不是 stop()

onModuleDestroy 里要优雅关闭长连接。直觉写 wsClient.stop()——这个方法根本不存在,运行时才炸。SDK 的 WSClient 用的是 wsClient.close()

async stop() {
  try {
    await wsClient.close();   // 不是 stop()!
  } catch (err) {
    this.logger.warn(`飞书长连接停止异常: ...`);
  }
}

群聊 @提及:一条 SQL 都省了

群里 @机器人 才回复,这是群聊的准入规则。原本以为要查机器人自己的 open_id 再比对 mentions,结果 SDK 的 mentions 数组里自带 mentioned_type: 'bot' 标记——不用查机器人 id,一行判断搞定:

const mentionBot = mentions.some((m) => m.mentioned_type === 'bot');

优雅降级:无凭据 = 注入 null

channels.module.ts 里用工厂判断凭据,缺失就注入 null

{
  provide: FEISHU_CLIENT,
  useFactory: () => {
    const appId = process.env.FEISHU_APP_ID?.trim();
    const appSecret = process.env.FEISHU_APP_SECRET?.trim();
    if (!appId || !appSecret) {
      Logger.warn('飞书 bot 未启用:缺少 FEISHU_APP_ID / FEISHU_APP_SECRET', 'ChannelsModule');
      return null;   // ← 关键:注入 null,bot 服务内部判空跳过
    }
    return createLarkClient(appId, appSecret);
  },
},

FeishuBotService.onModuleInit()if (!this.client) return——静默跳过。渠道缺凭据是常态,用"注入空实现"而不是"模块启动失败",这是 P2 优雅降级原则落地的方式。

第三幕:SseRenderer——SSE 事件到飞书文本的"翻译官"

渠道接上后,核心问题来了:WebUI 的 SSE 流式输出(reasoning_delta / choices.delta / tool_start / tool_end)怎么变成飞书消息?

设计成纯函数渲染器(零 IO、可单测),规则很直白:

reasoning_delta  → 忽略(思考过程不刷屏,v1 决策)
choices[].delta.content → 累积到 content(不逐 token 发,[DONE] 后整发)
tool_start      → 即时动作:发「🔧 工具名 执行中…」
tool_end        → 即时动作:发「🔧 工具名 ✅ 完成 / ❌ 失败」
[DONE]          → flush() 返回最终正文
export class SseRenderer {
  private content = '';
  private done = false;

  /** 消费一个 SSE 事件,返回 0~1 个要立即发送的动作 */
  ingest(evt: ChatSseEvent): RenderAction[] {
    if (this.done) return [];
    if (evt && typeof evt === 'object' && 'event' in evt) {
      const e = evt as { event: string; tool?: string; ok?: boolean };
      if (e.event === 'tool_start' && e.tool) {
        return [{ kind: 'tool_status', tool: e.tool }];          // 工具提示即时发
      }
      if (e.event === 'tool_end' && e.tool) {
        return [{ kind: 'tool_status', tool: e.tool, ok: e.ok }];
      }
      return [];                                                 // agent_start / reasoning 忽略
    }
    if (evt && typeof evt === 'object' && 'choices' in evt) {
      for (const c of evt.choices ?? []) {
        const text = c?.delta?.content ?? '';
        if (text) this.content += text;                          // 正文只累积
      }
    }
    return [];
  }

  flush(): string | null { /* [DONE] 后取最终正文 */ }
}

为什么 reasoning 要忽略:飞书是聊天场景,逐 token 发思考过程会疯狂刷屏;而且思考型模型的长 reasoning 在聊天窗口里就是噪音。这是渠道差异驱动的渲染决策——WebUI 能展示 thinkContent 折叠,飞书文本做不到,v1 干脆不展示。

为什么正文要攒着整发:逐 token 发 = 十几条碎片消息,飞书消息列表直接爆炸。攒到 [DONE] 一次发,工具状态用即时小消息报进度,节奏刚好。

第四幕:会话映射——chat_id 和 conversationId 的"户口本"

飞书消息没有 conversationId 概念,只有 chat_id(单聊/群聊各自一个)。要让连续对话上下文连贯,必须把 chat_id 稳定映射到 CyberClaw 的 conv-<uuid>

CREATE TABLE IF NOT EXISTS feishu_sessions (
  chat_id TEXT PRIMARY KEY,        -- 飞书 chat_id(单聊/群聊统一)
  conversation_id TEXT NOT NULL,   -- CyberClaw 会话 id(conv-<uuid>)
  agent_id TEXT NOT NULL,
  created_at TEXT NOT NULL,
  updated_at TEXT NOT NULL
);

关键逻辑在 getOrCreate同一 chat_id + 同一 agentId → 复用同一个 conversationId(thread_id 复用,上下文连贯);agentId 变了 → 新建 conversationId(新 agent 不读到旧 agent 的历史,防止串味)。

getOrCreate(chatId: string, agentId: string): string {
  const existing = this.get(chatId);
  const now = new Date().toISOString();
  if (existing && existing.agentId === agentId) {
    return existing.conversationId;          // 复用,上下文连贯
  }
  const conversationId = `conv-${randomUUID()}`;  // agent 变化 → 新会话
  this.upsert({ chatId, conversationId, agentId, createdAt: existing?.createdAt ?? now, updatedAt: now });
  return conversationId;
}

顺手做了一件事:飞书会话也同步进 WebUI 的 conversations 表——标题取消息前 30 字,这样 WebUI 里能看到飞书交互、能查看历史(Day 4 的 getHistory 直接复用)。渠道和 WebUI 的会话清单不再割裂。

重头戏:调度——AI 第一次"主动说话"

渠道是"别人问它答",调度是"到点自己开口"。差距分析里这块是 0/10,今天从零到一。

存储:schedules + schedule_runs 两张表

schedules     任务定义:id / type(at|every) / cron / payload(JSON) / enabled
schedule_runs 执行历史:id / schedule_id / started_at / finished_at / status / output

cron 字段语义按类型不同:at 存 ISO 时间串(2026-08-22T09:00:00+08:00),every 存间隔描述(10s / 5m / 2h / 1d)。校验在存储层做死:

const EVERY_RE = /^\d+\s*[smhd]$/;   // 10s / 5m / 2h / 1d
// type 必须是 at|every;payload 必须含非空 prompt;at 必须是合法时间——全在 create 里拦

执行器:agent-turn,复用整条链路

调度的 payload 设计成 agent-turn{ agentId?, prompt, pushTo? })——让 agent “说一句话”,然后可选推送到飞书:

async runAgentTurn(payload: SchedulePayload): Promise<string> {
  const agentId = payload.agentId?.trim() || this.defaultAgentId();
  const built = await this.chatService.buildAgent(agentId);   // 记忆注入自动生效!
  const renderer = new SseRenderer();
  for await (const evt of this.chatService.streamChat(
    built,
    [{ role: 'user', content: payload.prompt }],
    undefined,
    `schedule:${built.agent.id}`,   // 调度任务自己的线程
  )) {
    renderer.ingest(evt);           // 复用渲染器取最终文本
  }
  const text = renderer.flush() ?? '';
  if (payload.pushTo === 'feishu' && payload.chatId && this.feishuBot) {
    await this.feishuBot.sendTextToChat(payload.chatId, text || '(任务执行完毕,无输出)');
  }
  return text;
}

这里没有一行是新的对话逻辑——buildAgent + streamChat 原样复用,Phase 2 的记忆注入、Day 5 的真实工具在调度任务里自动生效。这就是 P4"记忆天然继承":调度任务和飞书对话共用同一套 agent 引擎,/agent 选的智能体是谁,定时任务就用谁干活。

坑④:setInterval 会阻止 Node 进程退出

写调度执行器时踩了个隐蔽的坑:Node 里活动的 setInterval 会阻止进程退出。后端测试跑完 jest 应该退出,但进程挂住不动——查了半天发现是每个任务的 interval 定时器还活着。

解法:定时器注册后立即 unref(),让它在"没有其他活动句柄"时不阻塞退出:

const timer = setInterval(() => void this.execute(rec), secs * 1000);
timer.unref?.();   // ← 关键:不阻止进程退出
this.timers.push({ id: rec.id, timer });

坑⑤:@Optional 注入,让调度模块不依赖渠道模块

ScheduleService 要推送结果到飞书(pushTo: 'feishu'),所以依赖 FeishuBotService;还要做 journal 清理,依赖 MemoryStore。但如果调度模块强依赖渠道模块,模块耦合就来了——@Optional() 声明可选依赖

@Optional() private readonly feishuBot?: FeishuBotService,
@Optional() @Inject(MEMORY_STORE) private readonly memoryStore?: MemoryStore,

这两个依赖在模块图里存在就用、不存在就 undefined——调度模块不强制渠道/记忆模块必须注册,符合 P2 优雅降级。顺带验证了 Day 5 学到的"token 由本模块或 imports 的模块提供":ScheduleModule imports 了 ChannelsModule 和 MemoryModule,所以注入才能解析。

内置任务:journal 清理,Phase 2 欠的账顺手还了

Day 5 挂着"journal 过期清理(依赖调度能力)"的 Flag,今天调度能力有了,直接做成内置任务:

// 默认每 7 天清理 30 天前的日记;MEMORY_PRUNE_INTERVAL / MEMORY_PRUNE_RETENTION_DAYS 可配
const PRUNE_INTERVAL = process.env.MEMORY_PRUNE_INTERVAL ?? '7d';
const PRUNE_RETENTION_DAYS = Number(process.env.MEMORY_PRUNE_RETENTION_DAYS ?? 30);

MemoryStore.pruneJournal(days):扫 journal 目录,按文件名日期前缀判断,早于保留期的删掉。第一个用调度能力干的真活——“AI 自己维护自己的记忆库”,感觉像是系统长出了自愈能力。

管理 API(无 UI,本轮明确不做页面)

调度管理只做 REST API(spec ADR-7 决策:API 独立可用,页面列为扩展):

GET    /api/claw/schedules         → 任务列表 + 最近 5 次运行记录
POST   /api/claw/schedules         → {type, cron, payload} 创建(校验失败 400)
DELETE /api/claw/schedules/:id     → 删除(停用定时器 + 删记录)

创建任务走 ScheduleService.createTask()——持久化 + 注册定时器一步到位,下次重启从 DB 重新加载(onModuleInit 遍历 enabled 任务逐个注册),任务不丢。

第六幕:.env 加载——零依赖的正规军来了

接飞书要 FEISHU_APP_ID/SECRET,但项目里没有 dotenv、没有 ConfigModule——process.env 只认系统环境变量,配置全靠 export,重启就丢。这在 Day 5 排查时已经诟病过。

本来想引 dotenv,结果发现 Node ≥20.6 原生自带 process.loadEnvFile——零新增依赖:

// config/env.ts
const candidates = [
  join(findRepoRoot(process.cwd()) ?? process.cwd(), 'apps', 'api', '.env'),  // 开发默认
  resolve(process.cwd(), '.env'),                                            // 直接跑 dist 场景
];
for (const file of candidates) {
  if (existsSync(file)) { process.loadEnvFile(file); break; }
}
// main.ts 第一行——必须在任何业务模块 import 之前执行(副作用 import 置顶)
import './config/env';

两个要点:① 副作用 import 必须放文件最顶部,否则业务模块加载时 process.env 还是空的;② loadEnvFile 不覆盖已有系统环境变量(系统环境变量 > .env 文件),符合惯例且安全。apps/api/.env 已被 gitignore 保护,凭据不入库。

高潮:手机里收到 AI 回复的那一刻

凭据就绪后启动后端,日志出现 飞书 bot 长连接已启动——然后我在手机飞书里给机器人发了一句测试消息,几秒后收到了回复

我:你好,介绍一下你自己
AI:你好!我是你的 AI 助手……(来自「法律文书智能体」,deepseek-v4-flash)

那一刻的震撼不亚于 Day 4 的"你是小李呀"——AI 从浏览器里走出来了,它现在住在我的手机里。随后验证的完整矩阵:

场景 结果
飞书私聊问答(含工具调用提示 🔧) ✅ 手机实测
同一 chat_id 连续对话上下文连贯 ✅ thread_id 复用
群聊 @机器人 才回复 ✅ 单测 + 设计验证
无凭据启动优雅降级(WARN 不崩) ✅ E2E-6
at 任务到点执行一次并自动移除 ✅ E2E-4
every 任务按间隔执行,schedule_runs 记录完整 ✅ E2E-5
pushTo:‘feishu’ 任务推送 ✅ 单测 mock + 联调
记忆跨渠道生效 ✅ 经 buildAgent 天然继承

收尾打磨:/agent 命令 + 长回复分段

主链路通了,用户体验还有两个短板,一并修掉。

/agent:飞书里直接切换智能体

spec 的开放问题 #2"会话映射的 agent 切换"——本来列为扩展,但想想这是高频需求(4 个智能体轮流用),直接做了:/agent 命令会话级切换。

/agent            → 列出所有启用智能体
/agent 法律文书    → 切换到「法律文书智能体」(新建 conversationId,独立上下文)

匹配优先级:ID 精确 → 名称精确 → 名称包含(忽略大小写)。切换后 sessions.switchAgent() 新建会话,并同步一条「飞书会话(切换至 xxx)」到 WebUI 会话列表——WebUI 能看到飞书里切了哪些智能体。

长回复分段:4000 字符/段,渲染层不再截断

v1 的 renderer 对超长正文做了截断——长回答会被砍尾巴,这在正经使用中不能忍。改成发送层分段

export const FEISHU_TEXT_CHUNK_SIZE = 4000;   // 保守值,避免中文多字节撑爆

async sendTextChunked(chatId: string, text: string): Promise<void> {
  if (text.length <= FEISHU_TEXT_CHUNK_SIZE) {
    await this.client.sendText(chatId, text);
    return;
  }
  // 按 4000 切段,每段加(i/n)前缀,逐段发送
  for (let i = 0; i < chunks.length; i++) {
    const prefix = chunks.length > 1 ? `${i + 1}/${chunks.length})\n` : '';
    await this.client.sendText(chatId, `${prefix}${chunks[i]}`);
  }
}

分层原则:renderer 只负责"把事件翻译成文本"(纯函数),截断/分段是发送层的事(有 IO)——职责清晰,renderer 单测不用 mock 任何东西。顺带 sendTextToChat 也走分段,调度推送长文同样完整送达。

验证:218 → 226 全绿,tsc 零错误

  • 后端 API:226 个测试全绿(主提交 a64b004 新增 61 个单测 → 218 全绿;打磨提交 3d6a3f1 再 +8 → 226 全绿)
  • api tsc --noEmit 0 错误
  • E2E:飞书手机实测回复、at/every 调度执行、优雅降级、/agent 切换全部通过
  • 前端 webui:未改动,100 测试保持全绿

今天的成果

* f3e9ed8 merge: feature/channels-feishu-polish → dev (.env loading + /agent switch + chunked replies)
* 3d6a3f1 feat(channels): feishu /agent command switch + chunked long replies
* db0f50c feat(api): load .env via Node native process.loadEnvFile
* e4eaf7a merge: feature/channels-schedule → dev (Feishu channel + scheduler)
* a64b004 feat(channels-schedule): Feishu bot channel + at/every scheduler

(5 个 commit,全部推送到 origin/dev。)

  • AI 走出浏览器:飞书私聊/群聊(@提及)可用,手机实测收到回复
  • 渠道 = 适配器得到验证:buildAgent + streamChat 零改动,SSE 事件渲染成飞书文本
  • AI 会主动说话了:at/every 调度 + agent-turn 执行 + schedule_runs 运行历史
  • 调度结果可推送:pushTo:‘feishu’ 到指定会话
  • Phase 2 的账还清:journal 过期清理做成内置调度任务
  • 零依赖 .env 加载:Node 原生 process.loadEnvFile,凭据安全入库
  • 体验打磨:/agent 命令切换智能体、长回复 4000 字分段完整送达

明天干点啥(Phase 4:多智能体与渠道深耕)

差距分析里"多智能体 2/10"是下一个大坑,加上渠道和调度的扩展项:

  1. 多 agent 路由(关键词分发)——spec 明确的 Phase 4:飞书/WebUI 按关键词把消息分给不同智能体,而不是 FEISHU_AGENT_ID 定死一个。这是"多智能体"能力的入口,也是差距分析最后一块大短板
  2. 调度管理 WebUI「自动化」页面——API 已经独立可用,页面只是接上去的问题(页面依赖 API,API 不依赖页面)
  3. 飞书卡片交互 + 图片/文件消息——文本之外的消息类型,卡片能给调度任务做"确认/取消"按钮
  4. cron 表达式 + 时区——at 只跑一次,加上 cron 才能做"每周一 9 点"这种
  5. 向量检索——Phase 2 挂着的扩展,embedding API 换掉关键词纯函数(接口已预留)
  6. Telegram 渠道——渠道适配器模式已验证通用,等有代理或者哪天网络条件允许了再补

今天到这儿。AI 从"会说话、记得你、能干活"进化到"住进了你的手机、还会到点主动找你"——距离差距分析里那个 9.2 分的完全体 OpenClaw,又近了一大步。


仓库地址:https://github.com/Li-Esquire-codeverse/CyberClaw(欢迎 star 监督进度)

Logo

中国智能体开发者社区,聚焦智能体与大模型开发,提供前沿资讯、实用工具链、开源项目及行业案例。通过技术沙龙、开发者大赛等活动,促进经验交流与协作,助力开发者快速构建创新智能应用。

更多推荐