飞书机器人控制内网 Claude Code 方案
背景
把内网开发机上的 Claude Code 暴露到飞书会话里,随时随地用手机/桌面端飞书对它下指令,让它远程读写、修改项目代码。核心难点是:内网机器没有公网 IP,飞书平台的事件回调打不进来,必须做内网穿透。
下文记录整体架构与穿透方案,飞书机器人的具体功能只做简要介绍。
整体架构
请求链路是一条「公网入、隧道穿、内网处理」的单向管道:
| 节点 | 位置 | 运行的服务 |
|---|---|---|
| 飞书服务器 | 公网 | 事件推送(飞书提供) |
| 公网服务器 | 公网 | Nginx + frps |
| 内网机器 | 内网 | frpc + Bot Service + Claude Code |
组件职责:
| 组件 | 职责 |
|---|---|
| 飞书应用 | 接收用户消息,POST 事件到回调 URL |
| Nginx | TLS 终止、反向代理、限流、隐藏内部端口 |
| frps | FRP 服务端,维护控制通道,转发 TCP 数据流 |
| frpc | FRP 客户端,主动连出公网,把本地端口注册上去 |
| Bot Service | 解析飞书消息、路由工作空间、调用 Claude Code、回复结果 |
| Claude Agent SDK | 以子进程方式运行 Claude Code,支持会话恢复 |
两种工作模式
Bot 支持两种接入模式,是否需要穿透取决于此:
| 模式 | 连接方向 | 是否需要穿透 |
|---|---|---|
webhook | 飞书 → Bot(飞书主动 POST 回调) | 需要,必须有公网可达的 HTTPS URL |
websocket | Bot → 飞书(Bot 主动建长连接) | 不需要,内网机器直连飞书即可 |
websocket 模式由飞书官方 SDK 的 WSClient 维持长连接,autoReconnect 自动重连,整个公网服务器/Nginx/frps 都可以省掉。下文以需要穿透的 webhook 模式为主——它更适合需要稳定回调、要做卡片交互、或想统一收口的场景。
内网穿透方案
为什么是 FRP
内网机器拿不到公网 IP,但能主动访问公网。FRP 利用这一点:内网的 frpc 主动连出到公网的 frps 建立一条隧道,公网侧再把外部请求沿这条隧道送回内网。这是一切反向穿透工具(frp、ngrok、nps)的共同思路。选 FRP 是因为它自托管、可控、支持 TCP/HTTPS 多协议、断线重连稳定。
隧道原理
FRP 跑两条逻辑通道:
- 控制通道:frpc 启动时主动连 frps 的
:7000,注册自己(auth.token鉴权),并保持长连接、定期心跳。这是「反向」的关键——连接由内网发起。 - 数据通道:外部请求打到 frps 的
:33390时,frps 通过控制通道通知 frpc,双方为这次流量建立数据流,frpc 再转发到本地127.0.0.1:33390(即 Bot)。
frps 配置(公网服务器)
监听控制端口,开放一个数据转发端口给 Bot 用:
bindPort = 7000 # 控制通道
auth.method = "token"
auth.token = "<强随机字符串>" # 与 frpc 必须一致 数据转发端口 :33390 不需要在 frps 显式 listen,它由 frpc 的 remotePort 声明、frps 自动放行。frps 自带 Dashboard(如 :7500)可查看代理在线状态,排查时很有用。
frpc 配置(内网机器)
serverAddr = "<公网服务器地址>"
serverPort = 7000
transport.tls.enable = true # 控制通道加密,防止 token 明文
transport.proxyURL = "http://127.0.0.1:7890" # 可选:内网出公网需走代理时
auth.method = "token"
auth.token = "<强随机字符串>" # 与 frps 一致
[[proxies]]
name = "feishu-bot"
type = "tcp"
localIP = "127.0.0.1"
localPort = 33390 # 本地 Bot 端口
remotePort = 33390 # 注册到公网的端口 几个关键点:
transport.tls.enable必须开,否则控制通道里的 token 走明文,且部分 frps 强制要求 TLS,不开会报tls handshake error。- 内网若不能直连公网(常见于公司网络),用
transport.proxyURL让 frpc 走本地代理出去,否则报i/o timeout。 localPort与remotePort可以不同,这里都用33390只是为了一致好记;Nginx 反代时要对齐remotePort。
Nginx 反向代理
frps 暴露的是裸 TCP,飞书要的是 HTTPS。在公网服务器上用 Nginx 做两件事:终止 TLS、把 /webhook/event 反代到 frps 的转发端口。
server {
listen 443 ssl http2;
server_name your-domain.com; # 占位,勿填真实 IP
ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
location /webhook/event {
proxy_pass http://127.0.0.1:33390; # 指向 frps 的 remotePort
proxy_set_header X-Forwarded-Proto https;
proxy_read_timeout 120s; # Claude 处理可能 >60s
}
location /health { proxy_pass http://127.0.0.1:33390; }
location / { return 404; } # 只暴露必要路径
}
server {
listen 80;
return 301 https://$host$request_uri; # 强制跳转 HTTPS
} 要点:
proxy_read_timeout 120s:Claude Code 一轮可能跑几十秒甚至更久,用 Nginx 默认的 60s 会超时。- 只暴露
/webhook/event和/health,其余路径直接 404,减少攻击面。 - 证书必须用合法证书(Let's Encrypt 等),不要用自签——见下方踩坑。
连接稳定性
- frpc 内建断线重连;两侧都用 systemd 托管,
Restart=always+RestartSec=5保证进程常驻。 - Bot 的 systemd 单元写
After=frpc.service+Wants=frpc.service,让穿透先起来再启动业务。
消息往返时序
理解一次完整交互各层的时序,对排查「消息发出去了没回」很有用:
关键设计:飞书要求回调在 3 秒内 响应,而 Claude 处理远超 3 秒。所以 Bot 收到事件后先同步回复一条「处理中」,再异步跑 Claude,跑完用「主动发消息」接口把最终结果推回去。
飞书机器人功能(简介)
Bot 是一个 Express 服务,职责是「把飞书消息翻译成 Claude Code 调用,再把结果翻译回飞书」。
核心命令:
| 命令 | 作用 |
|---|---|
/help | 显示帮助 |
/ws list / add / remove / switch | 管理多个项目工作空间 |
/sessions | 列出可用会话 |
/history | 查看历史 |
/status | 当前会话与工作空间状态 |
/git | 查看当前仓库信息 |
/reset | 重置会话(开新上下文) |
工作空间路由:通过消息前缀把指令路由到不同项目目录,例如 /proj-a 修复登录bug 会在 proj-a 对应的目录下执行;不带前缀则用默认工作空间。多个项目互不干扰。
会话管理:用 SQLite 按 (用户, 工作空间) 维度持久化 Claude Code 的 session_id,下次对话 resume 该 session,实现多轮上下文。
Claude Code 调用:通过 @anthropic-ai/claude-agent-sdk 的 query() 以子进程方式驱动 Claude Code,关键参数:
const opts = {
cwd: workspace, // 工作空间 = Claude Code 的工作目录
permissionMode: 'bypassPermissions', // 自动放行工具调用(无人值守)
maxTurns: 30, // 限制最大工具调用轮数
maxBudgetUsd: 1, // 单次费用上限
resume: sessionId, // 恢复会话,支持多轮
};
for await (const msg of query({ prompt, options: opts })) {
// system → 拿 session_id
// assistant → 累积文本 / 上报工具调用进度
// result → 最终结果 + 费用 + 耗时
} bypassPermissions 让 Claude Code 在无人值守下能直接读写文件、执行命令,配合 maxTurns / maxBudgetUsd 做资源兜底。Bot 还会把工具调用进度(如改了哪个文件)格式化后推到飞书,便于远程观察它「在干什么」。
踩坑:自签证书导致卡片回调失败
一个与穿透架构强相关的坑。公网服务器最初用自签证书,结果:
- 事件推送(
im.message.receive_v1)正常 —— 飞书这条链路容忍自签证书。 - 交互卡片回调(
card.action.trigger,点按钮切换工作空间时触发)报错 200080 —— 这条链路严格校验证书,直接拒绝连接。
排查时 Nginx access log 里根本没有回调请求,说明飞书侧在 SSL 握手阶段就失败了,不是 URL 配错。换成 Let's Encrypt 合法证书后立刻恢复。
教训:不要因为事件能收到就以为 HTTPS 配置没问题,飞书不同子服务对 SSL 校验策略不一致;只要用到卡片回调,就必须上合法证书。
部署与分层验证
逐层 curl 验证,哪层断了就查哪层:
# 层 1: Bot 本地(内网机器)
curl http://127.0.0.1:33390/health
# 期望: {"status":"ok","mode":"webhook"}
# 层 2: FRP 隧道(公网服务器上执行)
curl http://127.0.0.1:33390/health
# 失败 → 查 frpc 日志: journalctl -u frpc -f
# tls handshake error → 未开 transport.tls.enable
# authorization failed → 两端 token 不一致
# i/o timeout → 内网出不去,需配 transport.proxyURL
# 层 3: Nginx HTTPS(任意机器)
curl https://your-domain.com/health
# 失败 → 查 Nginx 配置 / 证书路径
# 层 4: 飞书事件订阅
# 回飞书后台保存回调 URL,SDK 自动处理 challenge 验证
# 层 5: 发消息端到端测试 要点
- 穿透的本质是「内网主动连出公网建反向隧道」,FRP 用控制 + 数据两条通道实现;选
websocket模式可完全省掉穿透。 - 公网侧三件套:Nginx 终止 TLS + 反代、frps 维护隧道;内网侧两件套:frpc 主动连出、Bot 监听本地端口。
- 安全收口:frpc 开
transport.tls.enable+ 强随机 token;Nginx 只暴露/webhook/event与/health;证书必须合法(自签会卡死卡片回调)。 - 稳定性靠 systemd
Restart=always+ frpc 内建重连,业务单元依赖 frpc 单元。 - Bot 侧两个关键设计:先回「处理中」满足飞书 3 秒要求,再用 SQLite 持久化 session 支持多轮;Claude 调用务必加
maxTurns/maxBudgetUsd兜底。