Skip to content
maojoeyPublic

About

飞书/Lark 人对人消息中转:别人私聊机器人 → 转发到你的私聊 → 你回复那条即回传给对方。A human-to-human message relay for Feishu/Lark: people DM your bot, it forwards each message as a card to you, you hit reply and it goes back to the sender.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

LarkRelay

飞书 / 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),也有自己的限制,见下面 「用户身份的四条限制」。

它做什么

两条采集线,外加一条路由:

  1. 别人私聊机器人(或在群里 @ 它)→ 消息与附件落库
  2. 机器人把它做成一张卡片转发到你和机器人的私聊,抬头写清「来自谁 · 哪条线」
  3. 附件自动下载存盘,并重新上传一份发给你
  4. 你对那张卡片按「回复」→ 内容原样回传给最初发消息的人,并给你一条「已发给 X」回执
  5. 你也可以通过命令行或 HTTP 接口主动发消息和文件
  6. 用户身份(你本人授权后)定时归档「别人私聊你本人」的消息——机器人天生看不到这条,见下节
  7. 主人在私聊里发「授权」两个字,随时能完成或重新完成第 6 条那次授权,不用登服务器敲命令; 令牌快到期或已经失效时,机器人也会主动发消息提醒并带上可点的授权链接
  8. 上游通知拉取器(可选功能,见下节):定时向内部另一个服务拉待发通知, 以你本人身份转发给指定的人

三条护栏(不是可选项)

你的私聊里会堆着来自不同人的转发卡片,回错人是这类系统最容易出的事故。所以:

  • 转发一律用卡片,抬头必须写清来自谁
  • 只认带「回复」的消息。你在私聊里裸打一行字,系统当备忘,不会转发给任何人
  • 每次回传都回一条带收件人姓名的回执

架构

                    飞书开放平台
                      │   ▲
       事件(长连接,默认)│   │ 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.mjs

RELAY_LIVE 不置为 1 时不会建立任何连接。 这是个刻意的门闩,理由见下一节。

一条容易踩死的规则

同一个飞书应用的事件订阅只能有一个消费者。

长连接模式下,同一应用的多个活跃连接之间事件是分流的,不是广播。两个地方同时消费, 消息就被随机分走一半,而连接状态一切正常,症状是「偶尔丢消息」,极难排查。

所以:给本项目单独建一个飞书应用,不要和别的机器人共用;本机的命令行工具也不要对 同一个应用跑事件消费。RELAY_LIVE 门闩能防住本项目自己,但管不住别的工具,靠约定。

配套兜底是定时对账:每 5 分钟拉一次会话历史与库里比对,漏掉的补录进来。如果发现 「连接一切正常却仍在漏消息」,会直接报出「疑似有第二个消费者」。

用户身份的四条限制

启用归档前要知道的四件事,都是协议或接口本身的限制,不是本项目能绕过的:

  1. 365 天硬顶。官方文档原文:用户授权应用 365 天后,必须通过用户重新授权获取令牌。 刷新再勤也推不掉——换算下来是「最长一年无人值守,一年后点一次同意」。快到期时服务会在 /healthz 的 user_identity.reauth_due_in_days 里倒数,提前 30 天在 reauth_warning 上报警告。
  2. 令牌只能有一个持有者。refresh_token 一次性,换新即作废旧的。如果两个地方各存一份、 各自刷新,会互相把对方的票顶废,症状是「隔几天断一次、找不出规律」,很难排查。
  3. 单聊会话可能枚举不到。官方「获取群列表」接口的文档写明不含单聊;本项目先试 types=p2p,group(官方 CLI 实测会传这个参数),拿不到单聊就自动降级成「按已知联系人 批量解析单聊 chat_id」。降级后归档只覆盖已经在联系人表里的人,功能不受影响,但名单要维护。
  4. 首次归档要回溯一段历史,回溯太久会被截断。第一次见到一个会话时,起点不是 「现在」,而是用 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 参与,就让它按需来读库。

状态

三个里程碑都已完成,测试全绿,并且已经在真实服务器上跑起来:

  1. 你与机器人之间双向收发文字与文件
  2. 用户身份归档「别人私聊你本人」的消息、以你本人名义回传、重新授权在对话里闭环
  3. 上游通知拉取器(可选):定时拉待发通知、按姓名精确解析收件人、以你本人身份转发、回执

后续方向:把收到的消息按来源分流到下游系统、卡片状态回写「已回复」。

Issue 和 PR 都欢迎。如果你也在找这个东西却没找到,那我们遇到的是同一个问题。

许可证

MIT

About

飞书/Lark 人对人消息中转:别人私聊机器人 → 转发到你的私聊 → 你回复那条即回传给对方。A human-to-human message relay for Feishu/Lark: people DM your bot, it forwards each message as a card to you, you hit reply and it goes back to the sender.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages