飞书 / Lark 的人对人消息中转:别人私聊机器人 → 转发到你的私聊 → 你回复那条 → 自动回传给对方。
English: A human-to-human message relay for Feishu / Lark. People DM your bot; the bot forwards each message as a card into your private chat with it; you hit reply on that card and your words go straight back to the original sender. Messages and attachments are archived to SQLite. Long-connection (WebSocket) by default, webhook as a fallback, single Docker container, no runtime dependency beyond the official SDK. See docs/SETUP.md to get started.
找过一圈,飞书生态里没有现成的实现。GitHub 上 feishu-bot 话题 169 个仓库、feishu 话题
star 过百的 50 个,翻下来只有三类东西:
| 类别 | 在做什么 |
|---|---|
| 接大模型的问答机器人 | 用户说话 → 模型回答 |
| 接本地编码智能体的桥 | 飞书当远程终端,消息喂给编码 CLI |
| 单向通知推送 | 告警、日报转发进群 |
没有一个是「人 ↔ 机器人 ↔ 人」。 这个模式在 Telegram 生态里很常见(feedback-bot 话题下
十几个实现),但那边的动机是隐藏管理员身份;飞书这边一个都没有。
本项目填的就是这个空。典型场景:
- 老师 / 助教收学生提问和作业,材料自动归档,回复不用切工具
- 社群主理人收反馈,所有对话集中在一个窗口
- 任何「多对一」的收件场景,需要留档、附件落盘、统一回复入口
机器人永远看不到别人私聊你本人的消息。 这是所有 IM 的规则,不是飞书的限制——只用机器人, 覆盖的就只有「主动去找机器人的人」,别人直接发给你本人的那部分是天生的盲区。
本项目两条采集线都做了,一个应用、两种身份:
| 身份 | 能看到什么 | 用来做什么 |
|---|---|---|
| 机器人 | 别人私聊机器人的消息、群里 @ 它的 | 实时收、转发卡片给你 |
| 用户(你本人授权) | 别人私聊你本人的消息 | 定时归档,补上机器人的盲区 |
两条线合起来才覆盖「所有发给你的消息」;只开机器人那一条,归档永远是不完整的。 用户身份需要你本人额外授权一次(见 docs/SETUP.md),也有自己的限制,见下面 「用户身份的四条限制」。
两条采集线,外加一条路由:
- 别人私聊机器人(或在群里 @ 它)→ 消息与附件落库
- 机器人把它做成一张卡片转发到你和机器人的私聊,抬头写清「来自谁 · 哪条线」
- 附件自动下载存盘,并重新上传一份发给你
- 你对那张卡片按「回复」→ 内容原样回传给最初发消息的人,并给你一条「已发给 X」回执
- 你也可以通过命令行或 HTTP 接口主动发消息和文件
- 用户身份(你本人授权后)定时归档「别人私聊你本人」的消息——机器人天生看不到这条,见下节
- 主人在私聊里发「授权」两个字,随时能完成或重新完成第 6 条那次授权,不用登服务器敲命令; 令牌快到期或已经失效时,机器人也会主动发消息提醒并带上可点的授权链接
- 上游通知拉取器(可选功能,见下节):定时向内部另一个服务拉待发通知, 以你本人身份转发给指定的人
你的私聊里会堆着来自不同人的转发卡片,回错人是这类系统最容易出的事故。所以:
- 转发一律用卡片,抬头必须写清来自谁
- 只认带「回复」的消息。你在私聊里裸打一行字,系统当备忘,不会转发给任何人
- 每次回传都回一条带收件人姓名的回执
飞书开放平台
│ ▲
事件(长连接,默认)│ │ OpenAPI:发消息 / 上传 / 下载 / 拉历史
或 HTTPS webhook │ │
▼ │
┌─────────────────────────┴──────────────────────────────────┐
│ 单容器,只绑 127.0.0.1:8310 │
│ │
│ transport/ws ────┐ │
│ transport/webhook ┼─▶ normalize() ─▶ handleEvent() │
│ health/reconcile ─┘ 机器人可见的三种来源 │ 只去重落库 │
│ 定时对账,补录漏掉的 ▼ │
│ health/archiver ──▶ 用户身份 asUser,定时拉「私聊你本人」的 │
│ 会话(机器人看不到,见「一个必须先说清楚的前提」) ┘ │
│ messages(new) │
│ │ │
│ core/worker │
│ ┌─────────────────────────┼────────────┐ │
│ ▼ ▼ ▼ │
│ 下载附件 生成转发卡片 回复路由 │
│ │ reply_to→routes│
│ core/outbox(幂等 + 退避重试)│
│ │
│ http:/healthz、/api/*(Bearer)、/lark/events(webhook 时)、│
│ /lark/oauth/callback(授权回调,可选;默认不对外开放) │
│ watchdog:该重启就 exit(1),交给 Docker 拉起 │
└──────────────────────────────────────────────────────────────┘
│ 数据卷:config / db / files / outgoing
四种消息来源(长连接、webhook、定时对账、用户身份归档)共用同一个 handleEvent,
所以切换传输方式或加一条采集线都不会改变落库行为。前三种覆盖的是「机器人可见」的消息,
归档那一条覆盖的是机器人天生看不到的「私聊你本人」的消息——两者是互补关系,不是重复兜底。
handleEvent 只做去重落库然后立刻返回,下载和发送都在异步 worker 里做,
这样 webhook 的 3 秒超时永远不会被触发。
四份文档,按顺序看:
| 文档 | 内容 |
|---|---|
| docs/SETUP.md | 从零跑起来:飞书后台建应用、权限、部署、验收 |
| docs/OPERATIONS.md | 日常运维:部署、回滚、看日志、排错、切 webhook |
| docs/DECISIONS.md | 为什么这么设计,以及为什么不用现成项目 |
| docs/PITFALLS.md | 开发中真实踩到的十一个坑,每个都带根因与防法 |
| CHANGELOG.md | 每个版本真正要紧的取舍与修掉的坑 |
本机跑测试:
npm install
npm test # 174 项,全部离线本机起一个不连飞书的实例(用内置的假 API 实现):
RELAY_CONFIG=./config.json RELAY_SECRETS=./secrets.json node src/index.mjsRELAY_LIVE 不置为 1 时不会建立任何连接。 这是个刻意的门闩,理由见下一节。
同一个飞书应用的事件订阅只能有一个消费者。
长连接模式下,同一应用的多个活跃连接之间事件是分流的,不是广播。两个地方同时消费, 消息就被随机分走一半,而连接状态一切正常,症状是「偶尔丢消息」,极难排查。
所以:给本项目单独建一个飞书应用,不要和别的机器人共用;本机的命令行工具也不要对
同一个应用跑事件消费。RELAY_LIVE 门闩能防住本项目自己,但管不住别的工具,靠约定。
配套兜底是定时对账:每 5 分钟拉一次会话历史与库里比对,漏掉的补录进来。如果发现 「连接一切正常却仍在漏消息」,会直接报出「疑似有第二个消费者」。
启用归档前要知道的四件事,都是协议或接口本身的限制,不是本项目能绕过的:
- 365 天硬顶。官方文档原文:用户授权应用 365 天后,必须通过用户重新授权获取令牌。
刷新再勤也推不掉——换算下来是「最长一年无人值守,一年后点一次同意」。快到期时服务会在
/healthz的user_identity.reauth_due_in_days里倒数,提前 30 天在reauth_warning上报警告。 - 令牌只能有一个持有者。
refresh_token一次性,换新即作废旧的。如果两个地方各存一份、 各自刷新,会互相把对方的票顶废,症状是「隔几天断一次、找不出规律」,很难排查。 - 单聊会话可能枚举不到。官方「获取群列表」接口的文档写明不含单聊;本项目先试
types=p2p,group(官方 CLI 实测会传这个参数),拿不到单聊就自动降级成「按已知联系人 批量解析单聊 chat_id」。降级后归档只覆盖已经在联系人表里的人,功能不受影响,但名单要维护。 - 首次归档要回溯一段历史,回溯太久会被截断。第一次见到一个会话时,起点不是
「现在」,而是用
archive.backfill_days(默认 30 天)往前找——这跟补断线漏消息用的reconcile.overlap_sec(十分钟量级)是两回事,两者混用过一次,后果是账面显示 「N 个会话成功」,其实刚上线只收进了最近十分钟,历史全空,很唬人。另外单个会话的历史 翻页上限是 20 页 × 50 条 = 1000 条,backfill_days开得太大、老会话消息又多的话, 更早的部分会被截断收不进来。
如果你还有另一个内部服务会产生「要转发给具体某人」的通知(提醒、催办之类),本项目可以
定时去拉、以你本人身份发给指定的人、再回执给那个服务——不需要给那个服务开任何入站口,
本服务全程只出站,见 docs/DECISIONS.md「为何拉而不是被推」。
在 config.json 里加一段就能开启,secrets.json 配套加令牌:
{
"upstream_notices": {
"url": "https://internal.example.com/notices",
"interval_sec": 60,
"group_members_ttl_sec": 21600
}
}{
"upstream_token": "从上游服务那边拿到的 Bearer token"
}段缺失 = 功能关闭,服务照常启动;段在但没填 url 或没配 upstream_token 会在启动时直接报错。
协议是本项目单方面定的一个极简约定,接的时候上游要实现这两个端点:
GET {url}(带Authorization: Bearer <upstream_token>)→{ "ok": true, "notices": [{ "id": "uuid", "kind": "...", "username": "...", "name": "张三", "text": "...", "created_at": 1234567890 }] }POST {url}/{id}/ack(同样带 Bearer)→ 收到本服务上报的处理结果:{ "ok": true, "relayId": 123 }(已转发,relayId是本项目出站队列里的行号)或{ "ok": false, "error": "人能看懂的中文原因" }(没能转发,比如找不到收件人); 上游对同一个 id 重复收到 ack 应该直接回{ "ok": true },不当错误处理。
收件人只用 name 按精确同名解析:候选池是「你的单聊对端」+「机器人所在群的成员」,
去掉全/半角空白后完全相等、且恰好一个命中才会发;0 个或多个命中都不发,只把中文原因
通过 ack 回给上游(比如「飞书里没找到「张三」(私聊和所在群都没有)」)。这是刻意的保守
设计——飞书没有按姓名搜索租户成员的接口,open_id 又是按应用隔离的,能力边界内宁可不发
也不发错人,详见 docs/DECISIONS.md「为何只按单聊/群成员精确同名解析」。
/healthz 里的 upstream 段能看到这条线的状态:enabled、last_at、last_error、
handed_24h(累计成功转发数,不是严格的滚动 24 小时窗口,与 missed_24h 是同一种记法)。
| 项 | 值 |
|---|---|
| 运行时 | Node 22.5+(生产用 24) |
| 运行时依赖 | 只有 @larksuiteoapi/node-sdk 一个 |
| 存储 | SQLite,用 Node 内置的 node:sqlite,无原生模块,不需要编译器 |
| 镜像 | 约 389MB(基于 node:24-bookworm-slim,不装任何 apt 包) |
| 内存 | 实测常驻约 66MB,容器限 256MB |
| 测试 | 174 项,全部离线 |
选 node:sqlite 而不是 better-sqlite3,是因为后者构建时要拉预编译二进制,slim 镜像里没有
编译器,网络一抖构建就废。内置模块零依赖,构建永远可重现。
- 密钥只在服务器,
600权限,仓库里只有*.example.json;日志做了脱敏。 - 管理接口只绑
127.0.0.1,全部要 Bearer 令牌;/healthz免鉴权但只读。 - 出站文件只能来自
outgoing/目录,接口做路径规范化,防目录穿越。 - 附件三道闸:单文件上限、软硬配额、磁盘剩余下限。摘要流式计算,不把文件整份读进内存。
- 不要把收到的消息喂给有文件系统权限的智能体。 陌生人发来的文本是不可信输入, 接进能跑命令的 agent 等于把提示注入直接送进去。本项目刻意做成「哑」的:只转发和归档; 要让 AI 参与,就让它按需来读库。
三个里程碑都已完成,测试全绿,并且已经在真实服务器上跑起来:
- 你与机器人之间双向收发文字与文件
- 用户身份归档「别人私聊你本人」的消息、以你本人名义回传、重新授权在对话里闭环
- 上游通知拉取器(可选):定时拉待发通知、按姓名精确解析收件人、以你本人身份转发、回执
后续方向:把收到的消息按来源分流到下游系统、卡片状态回写「已回复」。
Issue 和 PR 都欢迎。如果你也在找这个东西却没找到,那我们遇到的是同一个问题。
MIT