跳到主要内容

飞书群消息接入日报管线 — 设计文档

0. 背景

当前社区日报管线 (bot/wechat-bot/) 只采集微信群消息:lldb 解 SQLCipher → extract_day.py 抽消息 → generate_report.py 评分 → wechat-summary-bot 出海报。

社区已开通 1 个飞书群,希望把飞书群作为第二个信息源汇入同一份日报,最终产出仍是单份 <date>.detailed.md、单张海报。

1. 目标

  • 飞书群消息每日批量接入,与微信侧并列
  • 零改动复用现有 prompt / 评分 / 去重 / 匿名化 / 周末补跑逻辑
  • generate_report.py 改动控制在 ~30 行以内
  • 飞书侧失败不影响微信侧,反之亦然
  • 显示阶段区分来源:飞书群标 "飞书群",微信群标 "微信群"

2. 非目标 (YAGNI)

  • 实时 webhook / 事件订阅
  • 给飞书群回贴日报
  • 飞书 image OCR
  • 飞书机器人交互式命令
  • 多飞书 tenant
  • 重写微信侧以抽公共库

3. 整体架构

                                                         ┌─────────────────────┐
│ Feishu Open API │
│ (im/v1/messages) │
└──────────┬──────────┘
│ 每日定时
Mac 微信 (4.1.2) │ Asia/Shanghai
│ lldb attach │ 0:00–24:00
▼ ▼
(1)(2) 解 db (vendor/wechat-db-decrypt-macos) ┌─────────────────────────┐
│ │ bot/feishu-bot/scripts/ │
▼ │ extract_day.py │
(3) bot/wechat-bot/scripts/extract_day.py └──────────┬──────────────┘
→ bot/wechat-bot/data/daily/<date>.json │

bot/feishu-bot/data/daily/<date>.feishu.json
│ │
└──────────────────────┬──────────────────────────┘

(4) bot/wechat-bot/scripts/generate_report.py (改 ~30 行)
- 读 <date>.json + <date>.feishu.json
- 合并为 (group_label → messages[]) 字典
- 每群独立调 LLM(与现一致)
- 渲染时按 platform 字段加群类型前缀

bot/wechat-bot/data/reports/<date>.detailed.md (单一最终产物)
bot/wechat-bot/data/reports/<date>.json

wechat-summary-bot 出海报,发布到站点 /daily

性质:

  • 飞书与微信两支独立、可重跑
  • 没有"实时",每日批量
  • prompt / 评分 / 去重 / 匿名化全部复用
  • 来源差异只体现在显示标签

4. 飞书自建应用 & 权限

应用形态:仅团队内可见的自建应用,启用机器人能力,不上架。

权限范围(最小集合)

权限 scope用途
im:message.group_msg读群消息(iter_messages/im/v1/messages?container_id_type=chat
im:chat:readonly列机器人在的群 + 拿群名(inventory.py / get_chat_name

不申请:发送消息、文件上传、企业通讯录、外部联系人、群成员列表。

注:scope 名以飞书 API 报错时实际给出的为准。早期草稿曾列:

  • im:message:readonly(实际拉群消息时飞书要求 im:message.group_msg,参见 飞书错误码 230027
  • im:chat.member:read(飞书后台无此名)
  • contact:user.id:readonly(联系人接口未被使用)

share_user 类型当前用 open_id 作为占位、不解析真名,所以不需要联系人权限。

回调订阅:本期不开。

机器人入群:手动 @ 拉一次。

身份信息bot/feishu-bot/.env):

FEISHU_APP_ID=cli_xxxxxxxx
FEISHU_APP_SECRET=xxxxxxxxxxxx
FEISHU_CHAT_IDS=oc_xxxxxxxxxxxx # 当前 1 个,逗号分隔可扩展
FEISHU_GROUP_LABELS=oc_xxx=Hermes Agent 中文社区飞书群 1;oc_yyy=Hermes Agent 中文社区飞书群 2

FEISHU_GROUP_LABELS 可选:把每个 chat_id 显式映射到日报里出现的群名前缀。多条目用 ; 分隔,单条 chat_id=label= 分隔。缺省时按 "Hermes Agent 中文社区飞书群" + FEISHU_CHAT_IDS 中的顺序号补。

5. 抽取脚本 bot/feishu-bot/scripts/extract_day.py

输入:日期(默认前一天,Asia/Shanghai 0:00 → 次日 0:00)。

输出:bot/feishu-bot/data/daily/<date>.feishu.json

5.1 输出 schema

与微信侧 bot/wechat-bot/data/daily/<date>.json 严格对齐,方便 generate_report.py 直接拼接 groups 列表。

{
"date": "2026-04-30",
"tz": "Asia/Shanghai",
"platform": "feishu",
"window_start": 1714492800,
"window_end": 1714579200,
"groups": [
{
"group_id": "oc_xxx",
"group_name": "Hermes Agent 中文社区飞书群 1",
"platform": "feishu",
"chat_name": "Hermes 中文社区",
"message_count": 87,
"messages": [
{ "ts": 1714492800, "time": "09:01:27", "sender_wxid": "ou_aaa", "sender_name": "", "type": "text", "text": "..." },
{ "ts": 1714492810, "time": "09:01:30", "sender_wxid": "ou_bbb", "sender_name": "", "type": "post", "text": "标题\n\n正文段一\n[查看链接](https://...)" },
{ "ts": 1714492820, "time": "09:02:00", "sender_wxid": "ou_ccc", "sender_name": "", "type": "share", "text": "[转发链接] 文章标题 — https://..." },
{ "ts": 1714492830, "time": "09:02:10", "sender_wxid": "ou_ddd", "sender_name": "", "type": "file", "text": "[文件] xxx.pdf" }
]
}
]
}

字段说明:

  • groups列表(与微信侧一致),不是字典
  • group_id = 飞书 chat_id
  • group_name = 日报里实际显示的名字(已加 "飞书群" 后缀;按 FEISHU_GROUP_LABELS 或顺序号补)
  • chat_name = 飞书原始群名(仅排查用,下游不消费)
  • 每条 message 的字段名完全沿用微信侧:sender_wxidopen_id(明知命名不准,为对齐成本忍受);sender_name 留空字符串(open_id 已是不可读,下游 format_transcript 会回落到 wxid)
  • type 是抽取期记录的消息类型(text / post / share / file);generate_report.py 不消费此字段,仅供调试

message_count 是被保留消息数(抽取后),不是源数。

5.2 实现要点

模块拆分

  • bot/feishu-bot/scripts/_feishu.py — 唯一入口,包含:
    • get_tenant_token(app_id, app_secret) -> str
    • class FeishuClient:封装 GET/POST + token 注入 + 退避重试
    • iter_messages(chat_id, start_ts, end_ts) -> Iterator[dict]:处理分页 page_token
    • get_chat_name(chat_id) -> str:带本次运行内 LRU 缓存
    • decode_message(raw) -> dict | None:消息类型分发
  • extract_day.py:CLI 层,调度 _feishu.py、写文件
  • inventory.py:排查用,列机器人在的群、最近 7 日消息量

认证

  • POST /open-apis/auth/v3/tenant_access_token/internaltenant_access_token
  • TTL ~2h,本期每次脚本启动时拿一次,不写盘缓存(避免凭证泄露与刷新逻辑复杂化)
  • 检测 code == 99991663 或 401 → 刷新后重试一次

取消息

  • GET /open-apis/im/v1/messages?container_id_type=chat&container_id=...&start_time=...&end_time=...&page_size=50
  • 分页跟 page_token 直到 has_more=false
  • 时间戳:飞书 API 的 start_time/end_time秒级 Unix 时间戳的字符串

消息类型分发 (decode_message):

msg_type处理
textbody = content.text
post / post_v2_decode_post():递归遍历 content 列表,文字片段拼接,@/a 元素保留显示文本 + URL,图片/at 标记成 [图片] / @xxx。多段拼接保留 \n\n
share_chatchatIdget_chat_name(chatId)body = "[转发链接] 群名 — https://..."
share_useruserId → 基本信息,body = "[转发名片] xxx"
filebody = "[文件] " + content.file_name不下载
image / audio / video / sticker / red_packet / system返回 None,调用方丢弃
未知 type返回 None,记 warn

_decode_post 处理 content 是 JSON 字符串嵌套数组的情况,解析失败时 fallback 成 [富文本无法解析] 占位。

群名解析

  • 默认 GET /open-apis/im/v1/chats/:chat_idname
  • FEISHU_GROUP_LABELS 里有显式映射,用映射作为输出 key,群真实名当 chat_name 字段保留
  • 若没有映射且 FEISHU_CHAT_IDS 有多个 → 按顺序补 "飞书群 1 / 2 / 3 ..."

匿名化:抽取阶段不做generate_report.pysanitize_highlights() 已对人名/wxid 做清洗,open_id 是随机字符串不会被 LLM 当成人名输出。<date>.feishu.json 里保留 open_id 仅供调试 / 复算。

CLI:与微信侧 extract_day.py 对齐:

python3 bot/feishu-bot/scripts/extract_day.py                # 默认昨日
python3 bot/feishu-bot/scripts/extract_day.py 2026-04-30 # 显式日期
python3 bot/feishu-bot/scripts/extract_day.py --all # 自机器人入群以来每天
python3 bot/feishu-bot/scripts/extract_day.py --dry-run # 不写盘
python3 bot/feishu-bot/scripts/extract_day.py --no-overwrite # 已存在则报错退出

依赖:仅 requests + python-dotenv,不引入飞书官方 SDK。

周末补跑:与微信侧 bot/wechat-bot/scripts/extract_day.py 对齐——周末不出日报。extract_day.py 内部实现"未传日期 + 当日是周一 → 自动循环抽取周六、周日、周一",行为与微信侧完全一致。手动传日期时不补跑。

6. generate_report.py 改造点

唯一需要改既有代码的地方。改动局限两点:

6.1 数据加载(_run_single_day 头部)

当前 _run_single_day 直接读 DAILY_DIR / f"{date_str}.json"。改为新建一个 _load_daily(date_str) 辅助函数:

  • bot/wechat-bot/data/daily/<date>.json(微信)
  • bot/feishu-bot/data/daily/<date>.feishu.json(飞书)—— 路径解析用 ROOT.parent / "feishu-bot" / "data" / "daily"
  • 两份都不存在 → 返回 None,调用方维持原有"找不到当日数据"报错
  • 任一存在 → 取第一份的顶层元数据(datetzwindow_*),把所有份的 groups 列表串接成一份

由于飞书 schema 已与微信对齐,串接后的 groups 列表可直接进入 for g in data["groups"] if g["message_count"] >= MIN_MESSAGES_PER_GROUP 流程,无需再做规范化。

6.2 来源标签 _display_source()

完全不用改

  • 微信侧 group_name"Hermes Agent 中文社区 N",正则 (Hermes Agent 中文社区)\s*(\d+) 命中 → "...微信群 N"
  • 飞书侧 group_name 已经是 "Hermes Agent 中文社区飞书群 N","社区" 与数字之间隔着 "飞书群",正则命中 → 原样输出

→ 两类来源能自然区分,不引入新分支。

6.3 LLM 输入预处理

保持现状——飞书消息的 sender_wxidopen_idformat_transcript 已有 m["sender_name"] or m["sender_wxid"] or "?" 的回落,会显示成 [time] ou_xxxx: text,LLM 不会把 open_id 当人名。

share / file 类型的 text 字段已带 [转发链接] / [文件] 前缀,LLM 看得懂。

6.4 去重不用改

dedupe_highlights() key 是 (topic.lower(), first-24-normalized-chars(summary)),与来源无关。飞书与微信群讨论同一话题会被自然合并成一条 highlight,source 字段拼成 "微信群 3 / 飞书群 1"prune_report.py 的 dedupe key 与此一致,不动。

总改动行数估计:≤ 25 行(仅在 _run_single_day 头部抽出 _load_daily() 函数 + 调整一行调用)。

7. 目录结构

bot/feishu-bot/
├── README.md # 用法 + 飞书自建应用配置步骤
├── CLAUDE.md # 给 AI 的快速 context
├── .env.example # FEISHU_APP_ID / SECRET / CHAT_IDS / GROUP_LABELS
├── .gitignore # data/, .env
├── scripts/
│ ├── _feishu.py # 客户端、token、消息解码(single source of truth)
│ ├── extract_day.py # CLI,与微信侧对齐
│ └── inventory.py # 列机器人在的群、最近 7 日消息量
├── data/
│ └── daily/<date>.feishu.json # 抽取产物,gitignored
└── tests/
├── fixtures/ # 离线 JSON:post_*.json、share_*.json、file_*.json
└── test_decoders.py # 单测

8. 配置 & 凭证

  • bot/feishu-bot/.env — 飞书 secrets,不串到 wechat-bot 的 .env
  • generate_report.py 在合并飞书数据时不需要任何 Feishu 凭证——它只读已经落盘的 JSON
  • .gitignore 必须包含 bot/feishu-bot/data/bot/feishu-bot/.env

9. 错误处理 & 幂等性

场景行为
飞书 API 5xx / 网络抖动指数退避 3 次;仍失败则整次抽取失败、不写半成品(与微信侧"上游失败不污染下游"一致)
tenant_access_token 过期检测 99991663 / 401,刷新后重试 1 次
限流(429)退避后重试
<date>.feishu.json 已存在默认覆盖;--no-overwrite 时退出
generate_report.py 仅看到微信,没飞书正常出报告,不报错
generate_report.py 仅看到飞书,没微信同上,不报错
飞书群机器人被踢API 403,extract_day 报错并 exit code 非 0;不静默
share_chat 引用的链接已删除解析降级为"[已失效转发]"占位
decode_message 遇到未知 type返回 None 丢弃,stderr warn 一行

10. 测试

bot/wechat-bot/ 当前无测试。本次破例引入轻量单测,因为消息类型解码是飞书侧最易错点:

  • tests/test_decoders.py — 用离线 JSON fixture 测 _decode_post() / _decode_share() / _decode_file()
  • 不测端到端(要打活的飞书 API,集成进 CI 不划算)
  • 跑法:/usr/bin/python3 -m unittest discover bot/feishu-bot/tests,无外部依赖

11. 风险与权衡

  • 机器人拉群 = 信任授权:飞书自建应用拥有 im:message:readonly 等于读群所有消息。社区成员需要被告知。
  • 飞书 API 限流:默认 50 QPS / app,1 个群每天几百条远低于上限。可不做特殊处理。
  • 群名变更:若运营改了飞书群名,下次抽取的 group key 会变,造成 dedupe_highlights 跨日不一致。缓解:用 FEISHU_GROUP_LABELS 显式映射兜底。
  • tenant_access_token 不缓存:每次脚本启动都拿一次,浪费一次 RTT,但避免凭证落盘。当前一天跑一次,可接受。
  • 微信侧 schema 漂移:本设计假定 bot/wechat-bot/data/daily/<date>.json 的 group → messages 形态稳定。若未来微信侧引入 platform 字段,要保证向后兼容。

12. 实施顺序

预期分 3 个 PR:

  1. 飞书自建应用 + _feishu.py + extract_day.py + 测试(不接 generate_report)
  2. generate_report.py 改 4 点 + 端到端在某天数据上人工验证
  3. README / CLAUDE.md / 运营 SOP(拉机器人入群、首跑校验、日常监控)

实现细节由后续 plan 文档展开。