从零打造CyberClaw:AI 走出浏览器了
【从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.message、data.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 --noEmit0 错误 - 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"是下一个大坑,加上渠道和调度的扩展项:
- 多 agent 路由(关键词分发)——spec 明确的 Phase 4:飞书/WebUI 按关键词把消息分给不同智能体,而不是 FEISHU_AGENT_ID 定死一个。这是"多智能体"能力的入口,也是差距分析最后一块大短板
- 调度管理 WebUI「自动化」页面——API 已经独立可用,页面只是接上去的问题(页面依赖 API,API 不依赖页面)
- 飞书卡片交互 + 图片/文件消息——文本之外的消息类型,卡片能给调度任务做"确认/取消"按钮
- cron 表达式 + 时区——at 只跑一次,加上 cron 才能做"每周一 9 点"这种
- 向量检索——Phase 2 挂着的扩展,embedding API 换掉关键词纯函数(接口已预留)
- Telegram 渠道——渠道适配器模式已验证通用,等有代理或者哪天网络条件允许了再补
今天到这儿。AI 从"会说话、记得你、能干活"进化到"住进了你的手机、还会到点主动找你"——距离差距分析里那个 9.2 分的完全体 OpenClaw,又近了一大步。
仓库地址:https://github.com/Li-Esquire-codeverse/CyberClaw(欢迎 star 监督进度)
更多推荐


所有评论(0)