1. 项目概述一个飞书机器人的诞生最近在折腾一个内部效率工具核心需求是让团队在飞书里就能直接调用一些自动化脚本比如定时拉取数据报表、监控系统状态、或者快速查询一些内部信息。一开始想用现成的方案但要么功能太臃肿要么定制化程度不够最后还是决定自己动手基于飞书开放平台从零开始搭建一个轻量级的机器人服务。这个项目我称之为feishu-bot本质上是一个连接飞书消息与后端业务逻辑的桥梁。它解决的问题很直接让非技术同事也能通过简单的飞书消息触发复杂的后台任务并实时获取结果。比如运营同学在群里机器人问“今天的新增用户数”机器人就能自动查询数据库并返回一个格式清晰的卡片消息。这背后涉及到飞书事件订阅、消息解析、安全验证、以及业务逻辑分发等一系列环节。适合对飞书生态、Node.js后端开发以及企业级机器人搭建感兴趣的开发者参考无论是想做个部门小工具还是构建更复杂的流程自动化中枢这个项目的核心骨架都能提供清晰的思路。2. 核心架构与设计思路拆解2.1 为什么选择自建而非第三方框架市面上其实有一些封装好的飞书机器人SDK或者SaaS平台。选择自建首要考虑的是控制力和轻量级。第三方SDK往往为了通用性引入了大量你可能用不到的依赖和抽象层在调试和问题排查时反而会增加复杂度。自建意味着你可以精确控制每一个网络请求、每一处错误处理并且能根据自身业务特点进行深度定制比如实现特定的中间件、定制化的日志系统或者与公司内部的用户权限体系无缝集成。其次自建有助于理解底层原理。飞书开放平台的接口设计代表了现代企业级API的一种典型范式包括事件订阅、签名验证、卡片消息交互等。亲手实现一遍对理解Webhook机制、OAuth2.0流程、以及如何设计一个健壮的机器人服务有莫大好处。这不仅仅是完成一个功能更是一次对后端架构和API设计的实战演练。2.2 整体技术栈选型与考量项目核心采用Node.js Express作为服务端框架。Node.js的异步非阻塞特性非常适合处理机器人场景下的高并发、短耗时IO操作如解析消息、调用外部API。Express则提供了最小化且灵活的路由和中间件支持。对于飞书接口的调用没有使用重量级SDK而是直接采用axios库发起HTTP请求。这样做的好处是依赖干净并且能清晰地看到每次交互的请求体和响应体便于调试。身份认证方面飞书机器人主要使用“自定义机器人”的Webhook模式以及需要更多权限的“应用”模式涉及tenant_access_token的管理。本项目重点覆盖了后者因为它能实现更丰富的交互能力。数据存储方面鉴于机器人可能需要记忆上下文如多轮对话或缓存访问令牌选择了一个轻量级的SQLite数据库通过better-sqlite3驱动进行操作。对于简单的键值对存储如token缓存也可以使用内存对象或Redis但SQLite以其零配置、单文件的特点非常适合作为初期或轻量级持久化方案。2.3 核心交互流程设计机器人的核心工作流程可以抽象为以下几步事件接收与验证飞书服务器通过HTTP POST将用户事件如消息、按钮点击推送到我们预设的Webhook URL。请求头中会包含签名我们必须第一时间进行验签确保请求来源合法防止伪造攻击。事件解析与路由验证通过后解析事件体JSON格式识别事件类型message、card_action等和具体内容。根据事件类型和内容关键词将请求路由到对应的业务处理器Handler。业务逻辑处理处理器执行具体的业务逻辑可能是查询数据库、调用内部API、执行一个Python脚本等。这里是业务核心需要保证逻辑的健壮性和错误处理。消息组装与回复将业务逻辑处理的结果按照飞书消息模板支持文本、富文本、交互式卡片组装成合规的JSON消息体。消息发送调用飞书的消息发送接口如/im/v1/messages将组装好的消息发送回用户或群聊。这个流程看似线性但每个环节都有不少细节和“坑”。比如飞书的验签算法、tenant_access_token的自动刷新机制、卡片消息的复杂结构、以及如何优雅地处理异步耗时任务等。3. 关键实现细节与核心代码解析3.1 安全第一实现飞书事件验签飞书服务器发送的每个Webhook请求都会在头部携带X-Lark-Signature、X-Lark-Request-Timestamp、X-Lark-Request-Nonce三个字段。我们需要用自己应用配置的Verification Token和Encrypt Key如果开启了加密来验证签名的有效性。这是防止恶意请求的第一道防线。验签的核心逻辑是将timestamp、nonce、token或encrypt_key以及请求体body按特定顺序拼接成一个字符串然后进行某种哈希运算通常是SHA256或MD5再将结果与收到的signature进行比对。// 示例验签中间件 (middleware/verification.js) const crypto require(crypto); function verifySignature(req, res, next) { const timestamp req.headers[x-lark-request-timestamp]; const nonce req.headers[x-lark-request-nonce]; const signature req.headers[x-lark-signature]; const body req.rawBody || JSON.stringify(req.body); // 注意需要获取原始请求体字符串 // 1. 检查时间戳防止重放攻击通常允许5分钟误差 const now Math.floor(Date.now() / 1000); if (Math.abs(now - parseInt(timestamp)) 300) { return res.status(403).json({ code: 1, msg: Invalid timestamp }); } // 2. 拼接签名字符串 const token process.env.FEISHU_VERIFICATION_TOKEN; // 从环境变量读取 const stringToSign ${timestamp}\n${nonce}\n${token}\n${body}; // 3. 计算SHA256签名 const hash crypto.createHash(sha256); hash.update(stringToSign); const computedSignature hash.digest(hex); // 4. 比对签名 if (computedSignature ! signature) { console.error(Signature verification failed., { computedSignature, receivedSignature: signature }); return res.status(403).json({ code: 1, msg: Invalid signature }); } // 验签通过继续后续处理 next(); }注意这里有一个关键点req.body通常已被body-parser等中间件解析为JSON对象。但飞书的签名是基于原始的请求体字符串计算的。因此你必须在body-parser中间件之前通过req.on(data)的方式将原始数据流保存到req.rawBody中或者在body-parser中配置verify选项来实现。这是第一个容易踩坑的地方。3.2 令牌管理自动刷新 tenant_access_token调用大多数飞书API如发送消息、读取通讯录都需要在请求头中携带Authorization: Bearer {tenant_access_token}。这个令牌有效期为2小时且调用频率有限制。因此实现一个自动刷新和缓存的令牌管理器是必不可少的。设计思路是在内存或数据库中维护一个令牌对象包含token本身和expire过期时间戳。每次需要调用API前检查令牌是否即将过期例如剩余有效期小于5分钟。如果是则先调用飞书的/auth/v3/tenant_access_token/internal接口刷新令牌更新缓存然后再使用新令牌发起业务请求。// 示例令牌管理服务 (service/tokenService.js) const axios require(axios); const db require(../db); // 假设使用SQLite class TokenService { constructor() { this.cache null; } async getTenantAccessToken() { // 1. 尝试从内存缓存获取 if (this.cache this.cache.expire Date.now() 5 * 60 * 1000) { return this.cache.token; } // 2. 尝试从数据库获取可选用于服务重启后恢复 let storedToken await db.get(SELECT token, expire FROM tokens WHERE type tenant); if (storedToken storedToken.expire Date.now() 5 * 60 * 1000) { this.cache storedToken; return storedToken.token; } // 3. 缓存无效请求新令牌 const appId process.env.FEISHU_APP_ID; const appSecret process.env.FEISHU_APP_SECRET; const response await axios.post(https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal, { app_id: appId, app_secret: appSecret, }); if (response.data.code 0) { const newToken response.data.tenant_access_token; const expire Date.now() (response.data.expire - 60) * 1000; // 提前60秒过期留出缓冲 // 4. 更新缓存和数据库 this.cache { token: newToken, expire }; await db.run( INSERT OR REPLACE INTO tokens (type, token, expire) VALUES (?, ?, ?), [tenant, newToken, expire] ); return newToken; } else { throw new Error(Failed to get tenant_access_token: ${JSON.stringify(response.data)}); } } } module.exports new TokenService();实操心得令牌刷新一定要做好错误处理和重试机制。比如在刷新令牌的HTTP请求失败时不能直接让整个业务请求失败可以考虑使用旧的未过期的令牌再试一次或者记录告警。同时将令牌持久化到数据库可以避免服务重启后所有请求都因无令牌而失败直到第一个需要令牌的请求触发刷新。3.3 消息路由与处理器设计当验签通过后我们需要根据事件类型分发到不同的处理器。飞书的事件类型很多我们主要关注im.message.receive_v1接收消息和card.action卡片按钮点击。一个清晰的路由设计可以提高代码的可维护性。我们可以建立一个路由映射表将事件类型和消息内容模式映射到对应的处理函数。// 示例事件路由器 (router/eventRouter.js) const messageHandlers require(../handlers/messageHandlers); const cardActionHandlers require(../handlers/cardActionHandlers); async function routeEvent(event) { const { type, event: eventDetail } event; switch (type) { case im.message.receive_v1: const messageType eventDetail.message.message_type; const content JSON.parse(eventDetail.message.content); // 消息内容也是JSON字符串 const text content.text || ; // 根据消息文本关键词路由 if (text.includes(用户数)) { return await messageHandlers.handleUserStats(eventDetail); } else if (text.includes(帮助)) { return await messageHandlers.handleHelp(eventDetail); } else { return await messageHandlers.handleDefault(eventDetail); } break; case card.action: const actionValue eventDetail.action.value; // 卡片交互传递的值 // 根据actionValue路由到不同的卡片处理器 if (actionValue.command refresh_chart) { return await cardActionHandlers.handleRefreshChart(eventDetail); } break; default: console.log(Unhandled event type: ${type}); return { code: 0, msg: Event received but not handled. }; } }在处理器内部就是纯业务逻辑了。例如handleUserStats可能会去查询数据库然后组装一个卡片消息返回。3.4 构建与发送飞书卡片消息文本消息简单但卡片消息功能强大可以包含图片、按钮、分栏、备注等复杂元素。飞书卡片的构建是一个JSON结构体有一定的学习成本。建议先在飞书开放平台的“消息卡片搭建工具”中拖拽设计然后导出JSON再在代码中将其模板化。// 示例构建一个简单的统计卡片 (utils/cardBuilder.js) function buildUserStatsCard(newUsers, activeUsers, date) { return { config: { wide_screen_mode: true }, header: { title: { tag: plain_text, content: 用户数据日报 }, template: blue, }, elements: [ { tag: div, text: { tag: lark_md, content: **统计日期** ${date} }, }, { tag: div, fields: [ { is_short: true, text: { tag: lark_md, content: **新增用户**\n**${newUsers}** 人 } }, { is_short: true, text: { tag: lark_md, content: **活跃用户**\n**${activeUsers}** 人 } }, ], }, { tag: action, actions: [ { tag: button, text: { tag: plain_text, content: 刷新数据 }, type: primary, value: JSON.stringify({ command: refresh_chart, date: date }), // 点击时回传的值 }, { tag: button, text: { tag: plain_text, content: 查看详情 }, type: default, url: https://your-bi-system.com/dashboard, // 跳转链接 }, ], }, ], }; }发送消息时需要用到之前获取的tenant_access_token并指定接收者用户的open_id或chat_id。// 示例发送消息服务 (service/messageService.js) const axios require(axios); const tokenService require(./tokenService); async function sendCardMessage(receiveIdType, receiveId, cardContent) { const token await tokenService.getTenantAccessToken(); const url https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type${receiveIdType}; try { const response await axios.post(url, { receive_id: receiveId, msg_type: interactive, content: JSON.stringify(cardContent), }, { headers: { Authorization: Bearer ${token}, Content-Type: application/json, }, }); return response.data; } catch (error) { console.error(Failed to send message:, error.response?.data || error.message); throw error; } }4. 部署与运维实操要点4.1 本地开发与调试技巧开发阶段最大的挑战是如何让飞书的服务器能访问到你本地的服务。推荐使用ngrok或localtunnel这类内网穿透工具它们能为你生成一个临时的公网HTTPS地址将流量转发到本地。# 使用 ngrok 暴露本地 3000 端口 ngrok http 3000运行后你会得到一个类似https://abc123.ngrok.io的地址。将这个地址配置到飞书开放平台应用的事件订阅“请求地址”中。这样你在本地代码中打的日志、断点都能实时响应飞书发来的事件了。注意事项免费版的ngrok地址每次重启都会变化需要重新去飞书后台修改。对于需要长期调试的场景可以考虑付费版固定域名或者使用一些云服务商提供的开发测试域名。4.2 服务器部署方案选型对于生产环境你需要一个稳定的公网服务器和域名。部署方案有多种传统云服务器购买一台云服务器如1核2G配置安装Node.js环境使用PM2进程管理工具来守护你的应用。这是最可控的方式。# 使用PM2启动并管理进程 pm2 start app.js --name feishu-bot pm2 save pm2 startup # 设置开机自启Serverless容器服务如果你希望更省心可以考虑Docker 容器服务。将应用打包成Docker镜像部署到云厂商的容器实例服务上。这种方式易于扩展和版本回滚。纯Serverless函数对于流量波峰波谷明显的场景可以考虑将机器人逻辑拆解部署到云函数如阿里云函数计算、腾讯云SCF上。但需要注意飞书的事件是同步HTTP调用要求函数必须在短时间内通常5秒内响应否则飞书会认为超时。因此对于耗时较长的业务需要结合异步消息队列来改造流程。我个人更推荐第一种或第二种方案对于初期项目可控性更强调试也方便。4.3 环境变量与配置管理绝对不要将App ID、App Secret、Verification Token等敏感信息硬编码在代码中。使用环境变量来管理。# .env 文件示例 FEISHU_APP_IDcli_xxxxxx FEISHU_APP_SECRETxxxxxx FEISHU_VERIFICATION_TOKENxxxxxx FEISHU_ENCRYPT_KEY # 如果启用了加密则填写 SERVER_PORT3000 DATABASE_PATH./data/bot.db在代码中使用dotenv包或在部署平台配置相应的环境变量。// app.js 入口文件 if (process.env.NODE_ENV ! production) { require(dotenv).config(); } console.log(App ID starts with: ${process.env.FEISHU_APP_ID?.substring(0, 6)});4.4 日志与监控一个线上运行的机器人必须有完善的日志记录。建议使用winston或pino这样的日志库将日志按级别info, error, debug输出到文件和控制台并做好日志轮转。const winston require(winston); const logger winston.createLogger({ level: info, format: winston.format.json(), transports: [ new winston.transports.File({ filename: error.log, level: error }), new winston.transports.File({ filename: combined.log }), ], }); // 在事件处理器中记录 logger.info(Message received, { messageId: event.message.message_id, sender: event.sender.sender_id }); logger.error(Failed to call internal API, { error: err.message });同时要对关键指标进行监控例如API调用成功率、消息响应延迟、令牌刷新失败次数等。可以集成简单的健康检查接口方便运维。5. 常见问题排查与性能优化5.1 事件接收失败验签不通过这是新手最常遇到的问题。请按以下清单逐一核对时间戳检查服务器时间是否与标准时间同步误差是否在5分钟内原始请求体你是否正确获取了HTTP请求的原始字符串rawBodybody-parser会修改req.body导致验签用的字符串与实际不符。Token配置飞书应用后台的Verification Token和环境变量中的FEISHU_VERIFICATION_TOKEN是否完全一致注意前后空格。加密开关如果应用后台开启了“事件加密”那么验签算法会不同需要使用Encrypt Key进行解密后再验签。请确认你的代码逻辑与后台配置匹配。调试时可以将收到的签名、自己计算的签名、以及用于计算的各个参数timestamp, nonce, token, body都打印到日志中进行比对。5.2 消息发送失败令牌无效或权限不足Token过期检查你的令牌管理逻辑是否在令牌过期前进行了刷新打印令牌的获取和过期时间确认刷新逻辑正确。权限不足发送消息需要机器人具备“获取用户发给机器人的单聊消息”和“发送消息”等权限。请到飞书开放平台后台在“权限管理”页面为你使用的应用添加所需权限并确保已发布新版开发版本和线上版本权限是独立的。接收ID类型错误调用发送消息API时receive_id_type必须与receive_id匹配。如果是发给用户用open_id或user_id如果是发给群用chat_id。确保你传递的ID是正确的类型。5.3 机器人响应超时飞书服务器等待Webhook响应的超时时间较短。如果你的业务处理逻辑非常耗时比如超过3秒可能会导致飞书重试推送甚至认为机器人无响应。解决方案异步处理在Webhook处理器中只做最基本的验证和事件入库然后立即返回成功。之后再用一个后台工作进程从队列中取出任务慢慢处理处理完成后主动调用飞书的“回复消息”API发送结果。使用“消息加急”模式对于耗时操作可以先回复一条“正在处理请稍候…”的文本消息。等处理完成后再更新这条消息的内容飞书API支持更新已发送的消息或者发送一条新的消息。// 伪代码异步处理模式 app.post(/webhook, verifySignature, async (req, res) { // 1. 快速验证和解析 const event req.body; // 2. 将事件放入消息队列如Redis List、RabbitMQ await redis.lpush(feishu_events, JSON.stringify(event)); // 3. 立即返回成功告诉飞书“我已收到” res.json({ challenge: event.challenge }); // 对于URL验证事件需返回challenge }); // 另一个后台Worker进程 async function processEventWorker() { while (true) { const eventStr await redis.brpop(feishu_events, 0); const event JSON.parse(eventStr); // 这里执行可能耗时的业务逻辑 const result await longRunningTask(event); // 逻辑完成后主动发送消息 await messageService.sendTextMessage(event.sender.sender_id, 处理完成${result}); } }5.4 数据库并发与性能如果机器人用户量增大SQLite在并发写入时可能会遇到锁问题。对于读多写少的场景SQLite可以胜任。但如果写入频繁应考虑升级到更专业的数据库如PostgreSQL或MySQL。一个优化技巧是对于令牌这类高频读取、低频写入的数据可以使用内存缓存如Node.js的Map对象作为一级缓存并设置合理的过期时间将数据库作为持久化备份这样可以极大减少数据库访问压力。5.5 卡片消息交互的“值”传递当用户点击卡片上的按钮时飞书会将整个按钮上配置的value字段回传。这个value必须是字符串。通常我们会将一个JSON对象序列化成字符串传过去。// 设置按钮值 value: JSON.stringify({ command: query, id: 123, page: 1 }) // 在卡片动作处理器中解析 const actionValue JSON.parse(event.action.value); console.log(actionValue.command); // query这里要特别注意JSON序列化和反序列化的错误处理。如果value格式不正确解析会抛出异常导致整个处理器崩溃。务必用try...catch包裹解析逻辑。6. 扩展思路与高级玩法基础功能跑通后可以考虑以下几个方向进行扩展让机器人变得更强大6.1 实现多轮对话与上下文管理现在的机器人大多是“一问一答”。要实现多轮对话比如用户问“订会议室”机器人接着问“什么时间”需要维护对话上下文。可以为每个用户或每个会话在内存或数据库中保存一个上下文对象记录当前对话的状态和已收集的信息。// 简单的上下文管理器 const userContexts new Map(); function getContext(userId) { if (!userContexts.has(userId)) { userContexts.set(userId, { state: idle, data: {} }); } return userContexts.get(userId); } // 在消息处理器中 const context getContext(event.sender.sender_id); if (context.state awaiting_time) { // 用户正在回答“时间”问题 context.data.time messageText; context.state awaiting_participants; await sendMessage(请输入参会人员用逗号分隔); } else { // 开始新对话 // ... }为了避免内存泄漏需要为上下文设置超时清理机制例如30分钟无活动后清除。6.2 集成AI能力结合大语言模型LLM可以让机器人理解更自然的语言。例如将用户消息发送给云服务商提供的LLM API如OpenAI GPT、文心一言等让AI解析用户意图并生成结构化的指令再由机器人执行。async function parseIntentWithAI(userMessage) { const prompt 用户说“${userMessage}”。请判断其意图并返回JSON格式{“intent”: “query_user_stats”|“schedule_meeting”|..., “parameters”: {...}}; const aiResponse await callLLMAPI(prompt); return JSON.parse(aiResponse); }这样机器人就不再局限于关键词匹配能处理更模糊、更复杂的请求。6.3 构建插件化系统如果希望机器人功能可以被方便地扩展可以设计一个插件系统。每个插件负责一类功能如“数据查询插件”、“审批插件”并对外暴露一个统一的接口例如canHandle(message)和handle(message)。主路由根据所有插件的canHandle结果来决定将消息分发给哪个插件处理。这种架构使得新增功能就像新增一个插件文件一样简单符合开闭原则非常适合团队协作开发。6.4 添加监控与告警除了业务日志还可以为机器人添加性能监控和业务告警。例如使用Prometheus客户端库暴露指标如请求数、处理延迟、错误率并用Grafana展示。当错误率超过阈值或关键业务处理失败时通过飞书机器人自己或其他渠道如钉钉、企业微信发送告警消息形成闭环。从零搭建一个飞书机器人就像搭积木从最核心的事件接收和发送开始逐步添加令牌管理、消息路由、卡片交互、异步处理等模块。整个过程会遇到不少细节问题但每解决一个对飞书开放平台和企业级应用开发的理解就深一层。这个项目最让我有成就感的地方在于它用一个相对简单的技术栈解决了一个非常实际的团队协作痛点看到非技术同事能轻松地用起来那种感觉比单纯实现一个技术Demo要实在得多。如果你也在考虑做类似工具建议先从最小的可用版本开始就实现一个“echo”功能用户发什么机器人回复什么把验签、收消息、发消息这个闭环跑通后续的所有功能都是在这个基础上的叠加。