Skip to content

飞书机器人控制内网 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 用:

toml
bindPort = 7000            # 控制通道
auth.method = "token"
auth.token = "<强随机字符串>"   # 与 frpc 必须一致

数据转发端口 :33390 不需要在 frps 显式 listen,它由 frpc 的 remotePort 声明、frps 自动放行。frps 自带 Dashboard(如 :7500)可查看代理在线状态,排查时很有用。

frpc 配置(内网机器)

toml
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
  • localPortremotePort 可以不同,这里都用 33390 只是为了一致好记;Nginx 反代时要对齐 remotePort

Nginx 反向代理

frps 暴露的是裸 TCP,飞书要的是 HTTPS。在公网服务器上用 Nginx 做两件事:终止 TLS/webhook/event 反代到 frps 的转发端口

nginx
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-sdkquery() 以子进程方式驱动 Claude Code,关键参数:

typescript
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 验证,哪层断了就查哪层:

bash
# 层 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 兜底。

基于 VitePress 构建