diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a3b7e0d6e..c4af76dab3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai - Telegram: remove last `@ts-nocheck` from `bot-handlers.ts`, use Grammy types directly, deduplicate `StickerMetadata`. Zero `@ts-nocheck` remaining in `src/telegram/`. (#9206) - Telegram: remove `@ts-nocheck` from `bot-message.ts`, type deps via `Omit`, widen `allMedia` to `TelegramMediaRef[]`. (#9180) - Telegram: remove `@ts-nocheck` from `bot.ts`, fix duplicate `bot.catch` error handler (Grammy overrides), remove dead reaction `message_thread_id` routing, harden sticker cache guard. (#9077) +- Feishu: expand channel handling (posts with images, doc links, routing, reactions/typing, replies, native commands). (#8975) Thanks @jiulingyun. - Onboarding: add Cloudflare AI Gateway provider setup and docs. (#7914) Thanks @roerohan. - Onboarding: add Moonshot (.cn) auth choice and keep the China base URL when preserving defaults. (#7180) Thanks @waynelwz. - Docs: clarify tmux send-keys for TUI by splitting text and Enter. (#7737) Thanks @Wangnov. diff --git a/README.md b/README.md index bebf5fcfd7..ba3fce1951 100644 --- a/README.md +++ b/README.md @@ -496,44 +496,46 @@ Special thanks to Adam Doppelt for lobster.bot. Thanks to all clawtributors:

- steipete cpojer plum-dawg bohdanpodvirnyi iHildy jaydenfyi joshp123 joaohlisboa mneves75 MatthieuBizien - MaudeBot Glucksberg rahthakor vrknetha radek-paclt vignesh07 Tobias Bischoff sebslight czekaj mukhtharcm - maxsumrall xadenryan VACInc Mariano Belinky rodrigouroz tyler6204 juanpablodlc conroywhitney hsrvc magimetal - zerone0x meaningfool patelhiren NicholasSpisak jonisjongithub abhisekbasu1 jamesgroat claude JustYannicc Hyaxia - dantelex SocialNerd42069 daveonkels google-labs-jules[bot] lc0rp mousberg adam91holt hougangdev gumadeiras shakkernerd - mteam88 hirefrank joeynyc orlyjamie dbhurley Eng. Juan Combetto TSavo aerolalit julianengel bradleypriest - benithors rohannagpal timolins f-trycua benostein elliotsecops christianklotz nachx639 pvoo sreekaransrinath - gupsammy cristip73 stefangalescu nachoiacovino Vasanth Rao Naik Sabavat petter-b thewilloftheshadow leszekszpunar scald andranik-sahakyan - davidguttman sleontenko denysvitali sircrumpet peschee nonggialiang rafaelreis-r dominicnunez lploc94 ratulsarna - sfo2001 lutr0 kiranjd danielz1z AdeboyeDN Alg0rix Takhoffman papago2355 clawdinator[bot] emanuelst - evanotero KristijanJovanovski jlowin rdev rhuanssauro joshrad-dev obviyus osolmaz adityashaw2 CashWilliams - sheeek ryancontent jasonsschin artuskg onutc pauloportella HirokiKobayashi-R ThanhNguyxn kimitaka yuting0624 - neooriginal manuelhettich minghinmatthewlam baccula manikv12 myfunc travisirby buddyh connorshea kyleok - mcinteerj dependabot[bot] amitbiswal007 John-Rood timkrase uos-status gerardward2007 roshanasingh4 tosh-hamburg azade-c - badlogic dlauer JonUleis shivamraut101 bjesuiter cheeeee robbyczgw-cla YuriNachos Josh Phillips pookNast - Whoaa512 chriseidhof ngutman ysqander Yurii Chukhlib aj47 kennyklee superman32432432 grp06 Hisleren - shatner antons austinm911 blacksmith-sh[bot] damoahdominic dan-dr GHesericsu HeimdallStrategy imfing jalehman - jarvis-medmatic kkarimi mahmoudashraf93 pkrmf RandyVentures robhparker Ryan Lisse dougvk erikpr1994 fal3 - Ghost jonasjancarik Keith the Silly Goose L36 Server Marc mitschabaude-bot mkbehr neist sibbl abhijeet117 - chrisrodz Friederike Seiler gabriel-trigo iamadig itsjling Jonathan D. Rhyne (DJ-D) Joshua Mitchell Kit koala73 manmal + steipete joshp123 cpojer Mariano Belinky plum-dawg bohdanpodvirnyi iHildy jaydenfyi joaohlisboa mneves75 + MatthieuBizien MaudeBot sebslight Glucksberg rahthakor vrknetha tyler6204 vignesh07 radek-paclt Tobias Bischoff + czekaj ethanpalm mukhtharcm maxsumrall xadenryan VACInc rodrigouroz juanpablodlc conroywhitney hsrvc + christianklotz magimetal zerone0x meaningfool Takhoffman patelhiren NicholasSpisak jonisjongithub abhisekbasu1 jamesgroat + BunsDev claude JustYannicc Hyaxia dantelex SocialNerd42069 daveonkels google-labs-jules[bot] lc0rp mousberg + adam91holt hougangdev gumadeiras shakkernerd mteam88 hirefrank joeynyc orlyjamie dbhurley Eng. Juan Combetto + TSavo aerolalit julianengel bradleypriest benithors rohannagpal timolins f-trycua benostein elliotsecops + nachx639 pvoo sreekaransrinath gupsammy cristip73 stefangalescu nachoiacovino Vasanth Rao Naik Sabavat petter-b thewilloftheshadow + leszekszpunar scald andranik-sahakyan davidguttman sleontenko denysvitali clawdinator[bot] sircrumpet peschee davidiach + nonggialiang rafaelreis-r dominicnunez lploc94 ratulsarna sfo2001 lutr0 kiranjd danielz1z Iranb + AdeboyeDN Alg0rix obviyus papago2355 emanuelst evanotero KristijanJovanovski jlowin rdev rhuanssauro + joshrad-dev osolmaz adityashaw2 CashWilliams sheeek ryancontent jasonsschin artuskg onutc pauloportella + HirokiKobayashi-R ThanhNguyxn kimitaka yuting0624 neooriginal manuelhettich minghinmatthewlam baccula manikv12 myfunc + travisirby buddyh connorshea bjesuiter kyleok mcinteerj badlogic dependabot[bot] amitbiswal007 John-Rood + timkrase uos-status gerardward2007 roshanasingh4 tosh-hamburg azade-c dlauer JonUleis shivamraut101 cheeeee + robbyczgw-cla YuriNachos Josh Phillips Wangnov kaizen403 pookNast Whoaa512 chriseidhof ngutman ysqander + Yurii Chukhlib aj47 kennyklee superman32432432 grp06 Hisleren shatner antons austinm911 blacksmith-sh[bot] + damoahdominic dan-dr GHesericsu HeimdallStrategy imfing jalehman jarvis-medmatic kkarimi Lukavyi mahmoudashraf93 + pkrmf RandyVentures robhparker Ryan Lisse Yeom-JinHo dougvk erikpr1994 fal3 Ghost jonasjancarik + Keith the Silly Goose L36 Server Marc mitschabaude-bot mkbehr neist sibbl abhijeet117 chrisrodz Friederike Seiler + gabriel-trigo iamadig itsjling Jonathan D. Rhyne (DJ-D) Joshua Mitchell kelvinCB Kit koala73 manmal mitsuhiko ogulcancelik pasogott petradonka rubyrunsstuff siddhantjain spiceoogway suminhthanh svkozak wes-davis zats 24601 ameno- bonald bravostation Chris Taylor dguido Django Navarro evalexpr henrino3 humanwritten - larlyssa Lukavyi mitsuhiko odysseus0 oswalpalash pcty-nextgen-service-account pi0 rmorse Roopak Nijhara Syhids - Ubuntu xiaose Aaron Konyer aaronveklabs andreabadesso Andrii cash-echo-bot Clawd ClawdFx danballance - EnzeD erik-agens Evizero fcatuhe itsjaydesu ivancasco ivanrvpereira Jarvis jayhickey jeffersonwarrior - jeffersonwarrior jverdi longmaba MarvinCui mjrussell odnxe optimikelabs p6l-richard philipp-spiess Pocket Clawd - robaxelsen Sash Catanzarite Suksham-sharma T5-AndyML tewatia thejhinvirtuoso travisp VAC william arzt zknicker - 0oAstro abhaymundhara aduk059 aldoeliacim alejandro maza Alex-Alaniz alexanderatallah alexstyl andrewting19 anpoirier - araa47 arthyn Asleep123 Ayush Ojha Ayush10 bguidolim bolismauro championswimmer chenyuan99 Chloe-VP - Clawdbot Maintainers conhecendoia dasilva333 David-Marsh-Photo Developer Dimitrios Ploutarchos Drake Thomsen dylanneve1 Felix Krause foeken - frankekn fredheir ganghyun kim grrowl gtsifrikas HassanFleyah HazAT hclsys hrdwdmrbl hugobarauna - iamEvanYT Jamie Openshaw Jane Jarvis Deploy Jefferson Nunn jogi47 kentaro Kevin Lin kira-ariaki kitze - Kiwitwitter levifig Lloyd loganaden longjos loukotal louzhixian martinpucik Matt mini mertcicekci0 - Miles mrdbstn MSch Mustafa Tag Eldeen mylukin nathanbosse ndraiman nexty5870 Noctivoro ozgur-polat - ppamment prathamdby ptn1411 reeltimeapps RLTCmpe Rony Kelner ryancnelson Samrat Jha senoldogann Seredeep - sergical shiv19 shiyuanhai siraht snopoke techboss testingabc321 The Admiral thesash Vibe Kanban - voidserf Vultr-Clawd Admin Wimmie wolfred wstock YangHuang2280 yazinsai yevhen YiWang24 ymat19 - Zach Knickerbocker zackerthescar 0xJonHoldsCrypto aaronn Alphonse-arianee atalovesyou Azade carlulsoe ddyo Erik - latitudeki5223 Manuel Maly Mourad Boustani odrobnik pcty-nextgen-ios-builder Quentin Randy Torres rhjoh Rolf Fredheim ronak-guliani - William Stock roerohan + larlyssa odysseus0 oswalpalash pcty-nextgen-service-account pi0 rmorse Roopak Nijhara Syhids Ubuntu xiaose + Aaron Konyer aaronveklabs aldoeliacim andreabadesso Andrii BinaryMuse bqcfjwhz85-arch cash-echo-bot Clawd ClawdFx + damaozi danballance Elarwei001 EnzeD erik-agens Evizero fcatuhe gildo hclsys itsjaydesu + ivancasco ivanrvpereira Jarvis jayhickey jeffersonwarrior jeffersonwarrior jverdi longmaba Marco Marandiz MarvinCui + mjrussell odnxe optimikelabs p6l-richard philipp-spiess Pocket Clawd robaxelsen Sash Catanzarite Suksham-sharma T5-AndyML + tewatia thejhinvirtuoso travisp VAC william arzt yudshj zknicker 0oAstro abhaymundhara aduk059 + akramcodez alejandro maza Alex-Alaniz alexanderatallah alexstyl AlexZhangji andrewting19 anpoirier araa47 arthyn + Asleep123 Ayush Ojha Ayush10 bguidolim bolismauro championswimmer chenyuan99 Chloe-VP Clawdbot Maintainers conhecendoia + dasilva333 David-Marsh-Photo deepsoumya617 Developer Dimitrios Ploutarchos Drake Thomsen dylanneve1 Felix Krause foeken frankekn + fredheir ganghyun kim grrowl gtsifrikas HassanFleyah HazAT hrdwdmrbl hugobarauna hyf0-agent iamEvanYT + ichbinlucaskim Jamie Openshaw Jane Jarvis Deploy Jefferson Nunn jogi47 kentaro Kevin Lin kira-ariaki kitze + Kiwitwitter lailoo levifig Lloyd loganaden longjos loukotal louzhixian lsh411 M00N7682 + mac mimi martinpucik Matt mini mertcicekci0 Miles mrdbstn MSch mudrii Mustafa Tag Eldeen mylukin + nathanbosse ndraiman nexty5870 Noctivoro ozgur-polat ppamment prathamdby ptn1411 reeltimeapps RLTCmpe + Rony Kelner ryancnelson Samrat Jha senoldogann Seredeep sergical shiv19 shiyuanhai siraht snopoke + stephenchen2025 techboss testingabc321 The Admiral thesash Vibe Kanban voidserf Vultr-Clawd Admin Wimmie wolfred + wstock wytheme YangHuang2280 yazinsai yevhen YiWang24 ymat19 Zach Knickerbocker zackerthescar 0xJonHoldsCrypto + aaronn Alphonse-arianee atalovesyou Azade carlulsoe ddyo Erik jiulingyun latitudeki5223 Manuel Maly + Mourad Boustani odrobnik pcty-nextgen-ios-builder Quentin Randy Torres rhjoh Rolf Fredheim ronak-guliani William Stock

diff --git a/docs/channels/feishu.md b/docs/channels/feishu.md index e378afaba8..2c6ba1e7f4 100644 --- a/docs/channels/feishu.md +++ b/docs/channels/feishu.md @@ -447,7 +447,75 @@ openclaw pairing list feishu ### Streaming -Feishu does not support message editing, so block streaming is enabled by default (`blockStreaming: true`). The bot waits for the full reply before sending. +Feishu supports streaming replies via interactive cards. When enabled, the bot updates a card as it generates text. + +```json5 +{ + channels: { + feishu: { + streaming: true, // enable streaming card output (default true) + blockStreaming: true, // enable block-level streaming (default true) + }, + }, +} +``` + +Set `streaming: false` to wait for the full reply before sending. + +### Multi-agent routing + +Use `bindings` to route Feishu DMs or groups to different agents. + +```json5 +{ + agents: { + list: [ + { id: "main" }, + { + id: "clawd-fan", + workspace: "/home/user/clawd-fan", + agentDir: "/home/user/.openclaw/agents/clawd-fan/agent", + }, + { + id: "clawd-xi", + workspace: "/home/user/clawd-xi", + agentDir: "/home/user/.openclaw/agents/clawd-xi/agent", + }, + ], + }, + bindings: [ + { + agentId: "main", + match: { + channel: "feishu", + peer: { kind: "dm", id: "ou_xxx" }, + }, + }, + { + agentId: "clawd-fan", + match: { + channel: "feishu", + peer: { kind: "dm", id: "ou_yyy" }, + }, + }, + { + agentId: "clawd-xi", + match: { + channel: "feishu", + peer: { kind: "group", id: "oc_zzz" }, + }, + }, + ], +} +``` + +Routing fields: + +- `match.channel`: `"feishu"` +- `match.peer.kind`: `"dm"` or `"group"` +- `match.peer.id`: user Open ID (`ou_xxx`) or group ID (`oc_xxx`) + +See [Get group/user IDs](#get-groupuser-ids) for lookup tips. --- @@ -472,7 +540,8 @@ Key options: | `channels.feishu.groups..enabled` | Enable group | `true` | | `channels.feishu.textChunkLimit` | Message chunk size | `2000` | | `channels.feishu.mediaMaxMb` | Media size limit | `30` | -| `channels.feishu.blockStreaming` | Disable streaming | `true` | +| `channels.feishu.streaming` | Enable streaming card output | `true` | +| `channels.feishu.blockStreaming` | Enable block streaming | `true` | --- @@ -492,6 +561,7 @@ Key options: ### Receive - ✅ Text +- ✅ Rich text (post) - ✅ Images - ✅ Files - ✅ Audio diff --git a/docs/start/hubs.md b/docs/start/hubs.md index 739b53fb97..67467d4568 100644 --- a/docs/start/hubs.md +++ b/docs/start/hubs.md @@ -17,6 +17,7 @@ Use these hubs to discover every page, including deep dives and reference docs t - [Index](/) - [Getting Started](/start/getting-started) +- [Quick start](/start/quickstart) - [Onboarding](/start/onboarding) - [Wizard](/start/wizard) - [Setup](/start/setup) diff --git a/docs/zh-CN/channels/feishu.md b/docs/zh-CN/channels/feishu.md index 76c3d5a41f..ff569c20e2 100644 --- a/docs/zh-CN/channels/feishu.md +++ b/docs/zh-CN/channels/feishu.md @@ -109,17 +109,23 @@ Lark(国际版)请使用 https://open.larksuite.com/app,并在配置中设 "application:application.app_message_stats.overview:readonly", "application:application:self_manage", "application:bot.menu:write", + "cardkit:card:write", "contact:user.employee_id:readonly", "corehr:file:download", + "docs:document.content:read", "event:ip_list", + "im:chat", "im:chat.access_event.bot_p2p_chat:read", "im:chat.members:bot_access", "im:message", "im:message.group_at_msg:readonly", + "im:message.group_msg", "im:message.p2p_msg:readonly", "im:message:readonly", "im:message:send_as_bot", - "im:resource" + "im:resource", + "sheets:spreadsheet", + "wiki:wiki:readonly" ], "user": ["aily:file:read", "aily:file:write", "im:chat.access_event.bot_p2p_chat:read"] } @@ -453,7 +459,116 @@ openclaw pairing list feishu ### 流式输出 -飞书目前不支持消息编辑,因此默认禁用流式输出(`blockStreaming: true`)。机器人会等待完整回复后一次性发送。 +飞书支持通过交互式卡片实现流式输出,机器人会实时更新卡片内容显示生成进度。默认配置: + +```json5 +{ + channels: { + feishu: { + streaming: true, // 启用流式卡片输出(默认 true) + blockStreaming: true, // 启用块级流式(默认 true) + }, + }, +} +``` + +如需禁用流式输出(等待完整回复后一次性发送),可设置 `streaming: false`。 + +### 消息引用 + +在群聊中,机器人的回复可以引用用户发送的原始消息,让对话上下文更加清晰。 + +配置选项: + +```json5 +{ + channels: { + feishu: { + // 账户级别配置(默认 "all") + replyToMode: "all", + groups: { + oc_xxx: { + // 特定群组可以覆盖 + replyToMode: "first", + }, + }, + }, + }, +} +``` + +`replyToMode` 值说明: + +| 值 | 行为 | +| --------- | ---------------------------------- | +| `"off"` | 不引用原消息(私聊默认值) | +| `"first"` | 仅在第一条回复时引用原消息 | +| `"all"` | 所有回复都引用原消息(群聊默认值) | + +> 注意:消息引用功能与流式卡片输出(`streaming: true`)不能同时使用。当启用流式输出时,回复会以卡片形式呈现,不会显示引用。 + +### 多 Agent 路由 + +通过 `bindings` 配置,您可以用一个飞书机器人对接多个不同功能或性格的 Agent。系统会根据用户 ID 或群组 ID 自动将对话分发到对应的 Agent。 + +配置示例: + +```json5 +{ + agents: { + list: [ + { id: "main" }, + { + id: "clawd-fan", + workspace: "/home/user/clawd-fan", + agentDir: "/home/user/.openclaw/agents/clawd-fan/agent", + }, + { + id: "clawd-xi", + workspace: "/home/user/clawd-xi", + agentDir: "/home/user/.openclaw/agents/clawd-xi/agent", + }, + ], + }, + bindings: [ + { + // 用户 A 的私聊 → main agent + agentId: "main", + match: { + channel: "feishu", + peer: { kind: "dm", id: "ou_28b31a88..." }, + }, + }, + { + // 用户 B 的私聊 → clawd-fan agent + agentId: "clawd-fan", + match: { + channel: "feishu", + peer: { kind: "dm", id: "ou_0fe6b1c9..." }, + }, + }, + { + // 某个群组 → clawd-xi agent + agentId: "clawd-xi", + match: { + channel: "feishu", + peer: { kind: "group", id: "oc_xxx..." }, + }, + }, + ], +} +``` + +匹配规则说明: + +| 字段 | 说明 | +| ----------------- | --------------------------------------------- | +| `agentId` | 目标 Agent 的 ID,需要在 `agents.list` 中定义 | +| `match.channel` | 渠道类型,这里固定为 `"feishu"` | +| `match.peer.kind` | 对话类型:`"dm"`(私聊)或 `"group"`(群组) | +| `match.peer.id` | 用户 Open ID(`ou_xxx`)或群组 ID(`oc_xxx`) | + +> 获取 ID 的方法:参见上文 [获取群组/用户 ID](#获取群组用户-id) 章节。 --- @@ -478,7 +593,8 @@ openclaw pairing list feishu | `channels.feishu.groups..enabled` | 是否启用该群组 | `true` | | `channels.feishu.textChunkLimit` | 消息分块大小 | `2000` | | `channels.feishu.mediaMaxMb` | 媒体大小限制 | `30` | -| `channels.feishu.blockStreaming` | 禁用流式输出 | `true` | +| `channels.feishu.streaming` | 启用流式卡片输出 | `true` | +| `channels.feishu.blockStreaming` | 启用块级流式 | `true` | --- diff --git a/extensions/feishu/src/channel.ts b/extensions/feishu/src/channel.ts index e0ef296972..dff6e24fb2 100644 --- a/extensions/feishu/src/channel.ts +++ b/extensions/feishu/src/channel.ts @@ -55,10 +55,10 @@ export const feishuPlugin: ChannelPlugin = { capabilities: { chatTypes: ["direct", "group"], media: true, - reactions: false, + reactions: true, threads: false, polls: false, - nativeCommands: false, + nativeCommands: true, blockStreaming: true, }, reload: { configPrefixes: ["channels.feishu"] }, diff --git a/scripts/clawtributors-map.json b/scripts/clawtributors-map.json index d652938a60..06cbf20dbe 100644 --- a/scripts/clawtributors-map.json +++ b/scripts/clawtributors-map.json @@ -15,7 +15,8 @@ "ysqander", "atalovesyou", "0xJonHoldsCrypto", - "hougangdev" + "hougangdev", + "jiulingyun" ], "seedCommit": "d6863f87", "placeholderAvatar": "assets/avatar-placeholder.svg", diff --git a/src/feishu/docs.test.ts b/src/feishu/docs.test.ts new file mode 100644 index 0000000000..264f58a6e5 --- /dev/null +++ b/src/feishu/docs.test.ts @@ -0,0 +1,135 @@ +import { describe, it, expect } from "vitest"; +import { extractDocRefsFromText, extractDocRefsFromPost } from "./docs.js"; + +describe("extractDocRefsFromText", () => { + it("should extract docx URL", () => { + const text = "Check this document https://example.feishu.cn/docx/B4EPdAYx8oi8HRxgPQQb"; + const refs = extractDocRefsFromText(text); + expect(refs).toHaveLength(1); + expect(refs[0].docToken).toBe("B4EPdAYx8oi8HRxgPQQb"); + expect(refs[0].docType).toBe("docx"); + }); + + it("should extract wiki URL", () => { + const text = "Wiki link: https://company.feishu.cn/wiki/WikiTokenExample123"; + const refs = extractDocRefsFromText(text); + expect(refs).toHaveLength(1); + expect(refs[0].docType).toBe("wiki"); + expect(refs[0].docToken).toBe("WikiTokenExample123"); + }); + + it("should extract sheet URL", () => { + const text = "Sheet URL https://open.larksuite.com/sheets/SheetToken1234567890"; + const refs = extractDocRefsFromText(text); + expect(refs).toHaveLength(1); + expect(refs[0].docType).toBe("sheet"); + }); + + it("should extract bitable/base URL", () => { + const text = "Bitable https://abc.feishu.cn/base/BitableToken1234567890"; + const refs = extractDocRefsFromText(text); + expect(refs).toHaveLength(1); + expect(refs[0].docType).toBe("bitable"); + }); + + it("should extract multiple URLs", () => { + const text = ` + Doc 1: https://example.feishu.cn/docx/Doc1Token12345678901 + Doc 2: https://example.feishu.cn/wiki/Wiki1Token12345678901 + `; + const refs = extractDocRefsFromText(text); + expect(refs).toHaveLength(2); + }); + + it("should deduplicate same token", () => { + const text = ` + https://example.feishu.cn/docx/SameToken123456789012 + https://example.feishu.cn/docx/SameToken123456789012 + `; + const refs = extractDocRefsFromText(text); + expect(refs).toHaveLength(1); + }); + + it("should return empty array for text without URLs", () => { + const text = "This is plain text without any document links"; + const refs = extractDocRefsFromText(text); + expect(refs).toHaveLength(0); + }); +}); + +describe("extractDocRefsFromPost", () => { + it("should extract URL from link element", () => { + const content = { + title: "Test rich text", + content: [ + [ + { + tag: "a", + text: "API Documentation", + href: "https://example.feishu.cn/docx/ApiDocToken123456789", + }, + ], + ], + }; + const refs = extractDocRefsFromPost(content); + expect(refs).toHaveLength(1); + expect(refs[0].title).toBe("API Documentation"); + expect(refs[0].docToken).toBe("ApiDocToken123456789"); + }); + + it("should extract URL from title", () => { + const content = { + title: "See https://example.feishu.cn/docx/TitleDocToken1234567", + content: [], + }; + const refs = extractDocRefsFromPost(content); + expect(refs).toHaveLength(1); + }); + + it("should extract URL from text element", () => { + const content = { + content: [ + [ + { + tag: "text", + text: "Visit https://example.feishu.cn/wiki/TextWikiToken12345678", + }, + ], + ], + }; + const refs = extractDocRefsFromPost(content); + expect(refs).toHaveLength(1); + expect(refs[0].docType).toBe("wiki"); + }); + + it("should handle stringified JSON", () => { + const content = JSON.stringify({ + title: "Document Share", + content: [ + [ + { + tag: "a", + text: "Click to view", + href: "https://example.feishu.cn/docx/JsonDocToken123456789", + }, + ], + ], + }); + const refs = extractDocRefsFromPost(content); + expect(refs).toHaveLength(1); + }); + + it("should return empty array for post without doc links", () => { + const content = { + title: "Normal title", + content: [ + [ + { tag: "text", text: "Normal text" }, + { tag: "a", text: "Normal link", href: "https://example.com" }, + ], + ], + }; + const refs = extractDocRefsFromPost(content); + expect(refs).toHaveLength(0); + }); +}); diff --git a/src/feishu/docs.ts b/src/feishu/docs.ts new file mode 100644 index 0000000000..e01d4fd43c --- /dev/null +++ b/src/feishu/docs.ts @@ -0,0 +1,456 @@ +import type { Client } from "@larksuiteoapi/node-sdk"; +import { getChildLogger } from "../logging.js"; +import { resolveFeishuApiBase } from "./domain.js"; + +const logger = getChildLogger({ module: "feishu-docs" }); + +type FeishuApiResponse = { + code?: number; + msg?: string; + data?: T; +}; + +type FeishuRequestClient = { + request: (params: { + method: string; + url: string; + params?: Record; + data?: Record; + }) => Promise>; +}; + +/** + * Document token info extracted from a Feishu/Lark document URL or message + */ +export type FeishuDocRef = { + docToken: string; + docType: "docx" | "doc" | "sheet" | "bitable" | "wiki" | "mindnote" | "file" | "slide"; + url: string; + title?: string; +}; + +/** + * Regex patterns to extract doc_token from various Feishu/Lark URLs + * + * Supported URL formats: + * - https://xxx.feishu.cn/docx/xxxxx + * - https://xxx.feishu.cn/wiki/xxxxx + * - https://xxx.feishu.cn/sheets/xxxxx + * - https://xxx.feishu.cn/base/xxxxx (bitable) + * - https://xxx.larksuite.com/docx/xxxxx + * etc. + */ +/* eslint-disable no-useless-escape */ +const DOC_URL_PATTERNS = [ + // docx (new version document) - token is typically 22-27 chars + /https?:\/\/[^\/]+\/(docx)\/([A-Za-z0-9_-]{15,35})/, + // doc (legacy document) + /https?:\/\/[^\/]+\/(doc)\/([A-Za-z0-9_-]{15,35})/, + // wiki + /https?:\/\/[^\/]+\/(wiki)\/([A-Za-z0-9_-]{15,35})/, + // sheets + /https?:\/\/[^\/]+\/(sheets?)\/([A-Za-z0-9_-]{15,35})/, + // bitable (base) + /https?:\/\/[^\/]+\/(base|bitable)\/([A-Za-z0-9_-]{15,35})/, + // mindnote + /https?:\/\/[^\/]+\/(mindnote)\/([A-Za-z0-9_-]{15,35})/, + // file + /https?:\/\/[^\/]+\/(file)\/([A-Za-z0-9_-]{15,35})/, + // slide + /https?:\/\/[^\/]+\/(slides?)\/([A-Za-z0-9_-]{15,35})/, +]; +/* eslint-enable no-useless-escape */ + +/** + * Extract document references from text content + * Looks for Feishu/Lark document URLs and extracts doc tokens + */ +export function extractDocRefsFromText(text: string): FeishuDocRef[] { + const refs: FeishuDocRef[] = []; + const seenTokens = new Set(); + + for (const pattern of DOC_URL_PATTERNS) { + const regex = new RegExp(pattern, "g"); + let match; + while ((match = regex.exec(text)) !== null) { + const [url, typeStr, token] = match; + const docType = normalizeDocType(typeStr); + + if (!seenTokens.has(token)) { + seenTokens.add(token); + refs.push({ + docToken: token, + docType, + url, + }); + } + } + } + + return refs; +} + +/** + * Extract document references from a rich text (post) message content + */ +export function extractDocRefsFromPost(content: unknown): FeishuDocRef[] { + const refs: FeishuDocRef[] = []; + const seenTokens = new Set(); + + try { + // Post content structure: { title, content: [[{tag, ...}]] } + const postContent = typeof content === "string" ? JSON.parse(content) : content; + + // Check title for links + if (postContent.title) { + const titleRefs = extractDocRefsFromText(postContent.title); + for (const ref of titleRefs) { + if (!seenTokens.has(ref.docToken)) { + seenTokens.add(ref.docToken); + refs.push(ref); + } + } + } + + // Check content elements + if (Array.isArray(postContent.content)) { + for (const line of postContent.content) { + if (!Array.isArray(line)) { + continue; + } + + for (const element of line) { + // Check hyperlinks + if (element.tag === "a" && element.href) { + const linkRefs = extractDocRefsFromText(element.href); + for (const ref of linkRefs) { + if (!seenTokens.has(ref.docToken)) { + seenTokens.add(ref.docToken); + // Use the link text as title if available + ref.title = element.text || undefined; + refs.push(ref); + } + } + } + + // Check text content for inline URLs + if (element.tag === "text" && element.text) { + const textRefs = extractDocRefsFromText(element.text); + for (const ref of textRefs) { + if (!seenTokens.has(ref.docToken)) { + seenTokens.add(ref.docToken); + refs.push(ref); + } + } + } + } + } + } + } catch (err: unknown) { + logger.debug(`Failed to parse post content: ${String(err)}`); + } + + return refs; +} + +function normalizeDocType( + typeStr: string, +): "docx" | "doc" | "sheet" | "bitable" | "wiki" | "mindnote" | "file" | "slide" { + switch (typeStr.toLowerCase()) { + case "docx": + return "docx"; + case "doc": + return "doc"; + case "sheet": + case "sheets": + return "sheet"; + case "base": + case "bitable": + return "bitable"; + case "wiki": + return "wiki"; + case "mindnote": + return "mindnote"; + case "file": + return "file"; + case "slide": + case "slides": + return "slide"; + default: + return "docx"; + } +} + +/** + * Get wiki node info to resolve the actual document token + * + * Wiki documents have a node_token that needs to be resolved to the actual obj_token + * + * API: GET https://open.feishu.cn/open-apis/wiki/v2/spaces/get_node + * Required permission: wiki:wiki:readonly or wiki:wiki + */ +async function resolveWikiNode( + client: Client, + nodeToken: string, + apiBase: string, +): Promise<{ objToken: string; objType: string; title?: string } | null> { + try { + logger.debug(`Resolving wiki node: ${nodeToken}`); + + const response = await (client as FeishuRequestClient).request<{ + node?: { obj_token?: string; obj_type?: string; title?: string }; + }>({ + method: "GET", + url: `${apiBase}/wiki/v2/spaces/get_node`, + params: { + token: nodeToken, + obj_type: "wiki", + }, + }); + + if (response?.code !== 0) { + const errMsg = response?.msg || "Unknown error"; + logger.warn(`Failed to resolve wiki node: ${errMsg} (code: ${response?.code})`); + return null; + } + + const node = response.data?.node; + if (!node?.obj_token || !node?.obj_type) { + logger.warn(`Wiki node response missing obj_token or obj_type`); + return null; + } + + return { + objToken: node.obj_token, + objType: node.obj_type, + title: node.title, + }; + } catch (err: unknown) { + logger.error(`Error resolving wiki node: ${String(err)}`); + return null; + } +} + +/** + * Fetch the content of a Feishu document + * + * Supports: + * - docx (new version documents) - direct content fetch + * - wiki (knowledge base nodes) - first resolve to actual document, then fetch + * + * Other document types return a placeholder message. + * + * API: GET https://open.feishu.cn/open-apis/docs/v1/content + * Docs: https://open.feishu.cn/document/server-docs/docs/content/get + * + * Required permissions: + * - docs:document.content:read (for docx) + * - wiki:wiki:readonly or wiki:wiki (for wiki) + */ +export async function fetchFeishuDocContent( + client: Client, + docRef: FeishuDocRef, + options: { + maxLength?: number; + lang?: "zh" | "en" | "ja"; + apiBase?: string; + } = {}, +): Promise<{ content: string; truncated: boolean } | null> { + const { maxLength = 50000, lang = "zh", apiBase } = options; + const resolvedApiBase = apiBase ?? resolveFeishuApiBase(); + + // For wiki type, first resolve the node to get the actual document token + let targetToken = docRef.docToken; + let targetType = docRef.docType; + let resolvedTitle = docRef.title; + + if (docRef.docType === "wiki") { + const wikiNode = await resolveWikiNode(client, docRef.docToken, resolvedApiBase); + if (!wikiNode) { + return { + content: `[Feishu Wiki Document: ${docRef.title || docRef.docToken}]\nLink: ${docRef.url}\n\n(Unable to access wiki node info. Please ensure the bot has been added as a wiki space member)`, + truncated: false, + }; + } + + targetToken = wikiNode.objToken; + targetType = wikiNode.objType as FeishuDocRef["docType"]; + resolvedTitle = wikiNode.title || docRef.title; + + logger.debug(`Wiki node resolved: ${docRef.docToken} -> ${targetToken} (${targetType})`); + } + + // Only docx is supported for content fetching + if (targetType !== "docx") { + logger.debug(`Document type ${targetType} is not supported for content fetching`); + return { + content: `[Feishu ${getDocTypeName(targetType)} Document: ${resolvedTitle || targetToken}]\nLink: ${docRef.url}\n\n(This document type does not support content extraction. Please access the link directly)`, + truncated: false, + }; + } + + try { + logger.debug(`Fetching document content: ${targetToken} (${targetType})`); + + // Use native HTTP request since SDK may not have this endpoint + // The API endpoint is: GET /open-apis/docs/v1/content + const response = await (client as FeishuRequestClient).request<{ + content?: string; + }>({ + method: "GET", + url: `${resolvedApiBase}/docs/v1/content`, + params: { + doc_token: targetToken, + doc_type: "docx", + content_type: "markdown", + lang, + }, + }); + + if (response?.code !== 0) { + const errMsg = response?.msg || "Unknown error"; + logger.warn(`Failed to fetch document content: ${errMsg} (code: ${response?.code})`); + + // Check for common errors + if (response?.code === 2889902) { + return { + content: `[Feishu Document: ${resolvedTitle || targetToken}]\nLink: ${docRef.url}\n\n(No permission to access this document. Please ensure the bot has been added as a document collaborator)`, + truncated: false, + }; + } + + return { + content: `[Feishu Document: ${resolvedTitle || targetToken}]\nLink: ${docRef.url}\n\n(Failed to fetch document content: ${errMsg})`, + truncated: false, + }; + } + + let content = response.data?.content || ""; + let truncated = false; + + // Truncate if too long + if (content.length > maxLength) { + content = content.substring(0, maxLength) + "\n\n... (Content truncated due to length)"; + truncated = true; + } + + // Add document header + const header = resolvedTitle + ? `[Feishu Document: ${resolvedTitle}]\nLink: ${docRef.url}\n\n---\n\n` + : `[Feishu Document]\nLink: ${docRef.url}\n\n---\n\n`; + + return { + content: header + content, + truncated, + }; + } catch (err: unknown) { + logger.error(`Error fetching document content: ${String(err)}`); + return { + content: `[Feishu Document: ${resolvedTitle || targetToken}]\nLink: ${docRef.url}\n\n(Error occurred while fetching document content)`, + truncated: false, + }; + } +} + +function getDocTypeName(docType: FeishuDocRef["docType"]): string { + switch (docType) { + case "docx": + case "doc": + return ""; + case "sheet": + return "Sheet"; + case "bitable": + return "Bitable"; + case "wiki": + return "Wiki"; + case "mindnote": + return "Mindnote"; + case "file": + return "File"; + case "slide": + return "Slide"; + default: + return ""; + } +} + +/** + * Resolve document content from a message + * Extracts document links and fetches their content + * + * @returns Combined document content string, or null if no documents found + */ +export async function resolveFeishuDocsFromMessage( + client: Client, + message: { message_type?: string; content?: string }, + options: { + maxDocsPerMessage?: number; + maxTotalLength?: number; + domain?: string; + } = {}, +): Promise { + const { maxDocsPerMessage = 3, maxTotalLength = 100000 } = options; + const apiBase = resolveFeishuApiBase(options.domain); + + const msgType = message.message_type; + let docRefs: FeishuDocRef[] = []; + + try { + const content = JSON.parse(message.content ?? "{}"); + + if (msgType === "text" && content.text) { + // Extract from plain text + docRefs = extractDocRefsFromText(content.text); + } else if (msgType === "post") { + // Extract from rich text - handle locale wrapper + let postData = content; + if (content.post && typeof content.post === "object") { + const localeKey = Object.keys(content.post).find( + (key) => content.post[key]?.content || content.post[key]?.title, + ); + if (localeKey) { + postData = content.post[localeKey]; + } + } + docRefs = extractDocRefsFromPost(postData); + } + // TODO: Handle interactive (card) messages with document links + } catch (err: unknown) { + logger.debug(`Failed to parse message content for document extraction: ${String(err)}`); + return null; + } + + if (docRefs.length === 0) { + return null; + } + + // Limit number of documents to process + const refsToProcess = docRefs.slice(0, maxDocsPerMessage); + + logger.debug(`Found ${docRefs.length} document(s), processing ${refsToProcess.length}`); + + const contents: string[] = []; + let totalLength = 0; + + for (const ref of refsToProcess) { + const result = await fetchFeishuDocContent(client, ref, { + maxLength: Math.min(50000, maxTotalLength - totalLength), + apiBase, + }); + + if (result) { + contents.push(result.content); + totalLength += result.content.length; + + if (totalLength >= maxTotalLength) { + break; + } + } + } + + if (contents.length === 0) { + return null; + } + + return contents.join("\n\n---\n\n"); +} diff --git a/src/feishu/download.ts b/src/feishu/download.ts index 9beccdb67c..c69801b48b 100644 --- a/src/feishu/download.ts +++ b/src/feishu/download.ts @@ -21,13 +21,15 @@ type FeishuMessagePayload = { * Download a resource from a user message using messageResource.get * This is the correct API for downloading resources from messages sent by users. * - * @param type - Resource type: "image", "file", "audio", or "video" + * @param type - Resource type: "image" or "file" only (per Feishu API docs) + * Audio/video must use type="file" despite being different media types. + * @see https://open.feishu.cn/document/server-docs/im-v1/message/get-2 */ export async function downloadFeishuMessageResource( client: Client, messageId: string, fileKey: string, - type: "image" | "file" | "audio" | "video", + type: "image" | "file", maxBytes: number = 30 * 1024 * 1024, ): Promise { logger.debug(`Downloading Feishu ${type}: messageId=${messageId}, fileKey=${fileKey}`); @@ -148,27 +150,41 @@ export async function resolveFeishuMedia( } } else if (msgType === "audio") { // Audio message: content = { file_key: "..." } + // Note: Feishu API only supports type="image" or type="file" for messageResource.get + // Audio must be downloaded using type="file" per official docs: + // https://open.feishu.cn/document/server-docs/im-v1/message/get-2 const content = JSON.parse(rawContent); if (content.file_key) { - return await downloadFeishuMessageResource( + const result = await downloadFeishuMessageResource( client, messageId, content.file_key, - "audio", + "file", // Use "file" type for audio download (API limitation) maxBytes, ); + // Override placeholder to indicate audio content + return { + ...result, + placeholder: "", + }; } } else if (msgType === "media") { // Video message: content = { file_key: "...", image_key: "..." (thumbnail) } + // Note: Video must also be downloaded using type="file" per Feishu API docs const content = JSON.parse(rawContent); if (content.file_key) { - return await downloadFeishuMessageResource( + const result = await downloadFeishuMessageResource( client, messageId, content.file_key, - "video", + "file", // Use "file" type for video download (API limitation) maxBytes, ); + // Override placeholder to indicate video content + return { + ...result, + placeholder: "", + }; } } else if (msgType === "sticker") { // Sticker - not supported for download via messageResource API @@ -181,3 +197,81 @@ export async function resolveFeishuMedia( return null; } + +/** + * Extract image keys from post (rich text) message content + * Post content structure: { post: { locale: { content: [[{ tag: "img", image_key: "..." }]] } } } + */ +export function extractPostImageKeys(content: unknown): string[] { + const imageKeys: string[] = []; + + if (!content || typeof content !== "object") { + return imageKeys; + } + + const obj = content as Record; + + // Handle locale-wrapped format: { post: { zh_cn: { content: [...] } } } + let postData = obj; + if (obj.post && typeof obj.post === "object") { + const post = obj.post as Record; + const localeKey = Object.keys(post).find((key) => post[key] && typeof post[key] === "object"); + if (localeKey) { + postData = post[localeKey] as Record; + } + } + + // Extract image_key from content elements + const contentArray = postData.content; + if (!Array.isArray(contentArray)) { + return imageKeys; + } + + for (const line of contentArray) { + if (!Array.isArray(line)) { + continue; + } + for (const element of line) { + if ( + element && + typeof element === "object" && + (element as Record).tag === "img" && + typeof (element as Record).image_key === "string" + ) { + imageKeys.push((element as Record).image_key as string); + } + } + } + + return imageKeys; +} + +/** + * Download embedded images from a post (rich text) message + */ +export async function downloadPostImages( + client: Client, + messageId: string, + imageKeys: string[], + maxBytes: number = 30 * 1024 * 1024, + maxImages: number = 5, +): Promise { + const results: FeishuMediaRef[] = []; + + for (const imageKey of imageKeys.slice(0, maxImages)) { + try { + const media = await downloadFeishuMessageResource( + client, + messageId, + imageKey, + "image", + maxBytes, + ); + results.push(media); + } catch (err) { + logger.warn(`Failed to download post image ${imageKey}: ${formatErrorMessage(err)}`); + } + } + + return results; +} diff --git a/src/feishu/message.ts b/src/feishu/message.ts index a8814ddf72..195fe6dd0d 100644 --- a/src/feishu/message.ts +++ b/src/feishu/message.ts @@ -7,6 +7,7 @@ import { loadConfig } from "../config/config.js"; import { logVerbose } from "../globals.js"; import { formatErrorMessage } from "../infra/errors.js"; import { getChildLogger } from "../logging.js"; +import { resolveAgentRoute } from "../routing/resolve-route.js"; import { isSenderAllowed, normalizeAllowFromWithStore, resolveSenderAllowMatch } from "./access.js"; import { resolveFeishuConfig, @@ -14,10 +15,18 @@ import { resolveFeishuGroupEnabled, type ResolvedFeishuConfig, } from "./config.js"; -import { resolveFeishuMedia, type FeishuMediaRef } from "./download.js"; +import { resolveFeishuDocsFromMessage } from "./docs.js"; +import { + downloadPostImages, + extractPostImageKeys, + resolveFeishuMedia, + type FeishuMediaRef, +} from "./download.js"; import { readFeishuAllowFromStore, upsertFeishuPairingRequest } from "./pairing-store.js"; import { sendMessageFeishu } from "./send.js"; import { FeishuStreamingSession } from "./streaming-card.js"; +import { createTypingIndicatorCallbacks } from "./typing.js"; +import { getFeishuUserDisplayName } from "./user.js"; const logger = getChildLogger({ module: "feishu-message" }); @@ -31,6 +40,12 @@ type FeishuSender = { type FeishuMention = { key?: string; + id?: { + open_id?: string; + user_id?: string; + union_id?: string; + }; + name?: string; }; type FeishuMessage = { @@ -41,6 +56,8 @@ type FeishuMessage = { mentions?: FeishuMention[]; create_time?: string | number; message_id?: string; + parent_id?: string; + root_id?: string; }; type FeishuEventPayload = { @@ -54,7 +71,7 @@ type FeishuEventPayload = { }; // Supported message types for processing -const SUPPORTED_MSG_TYPES = new Set(["text", "image", "file", "audio", "media", "sticker"]); +const SUPPORTED_MSG_TYPES = new Set(["text", "post", "image", "file", "audio", "media", "sticker"]); export type ProcessFeishuMessageOptions = { cfg?: OpenClawConfig; @@ -64,6 +81,8 @@ export type ProcessFeishuMessageOptions = { credentials?: { appId: string; appSecret: string; domain?: string }; /** Bot name for streaming card title (optional, defaults to no title) */ botName?: string; + /** Bot's open_id for detecting bot mentions in groups */ + botOpenId?: string; }; export async function processFeishuMessage( @@ -98,6 +117,17 @@ export async function processFeishuMessage( const senderUnionId = sender?.sender_id?.union_id; const maxMediaBytes = feishuCfg.mediaMaxMb * 1024 * 1024; + // Resolve agent route for multi-agent support + const route = resolveAgentRoute({ + cfg, + channel: "feishu", + accountId, + peer: { + kind: isGroup ? "group" : "dm", + id: isGroup ? chatId : senderId, + }, + }); + // Check if this is a supported message type if (!msgType || !SUPPORTED_MSG_TYPES.has(msgType)) { logger.debug(`Skipping unsupported message type: ${msgType ?? "unknown"}`); @@ -216,7 +246,11 @@ export async function processFeishuMessage( // Handle @mentions for group chats const mentions = message.mentions ?? payload.mentions ?? []; - const wasMentioned = mentions.length > 0; + // Check if the bot itself was mentioned, not just any user + const botOpenId = options.botOpenId?.trim(); + const wasMentioned = botOpenId + ? mentions.some((m) => m.id?.open_id === botOpenId || m.id?.user_id === botOpenId) + : mentions.length > 0; // In group chat, check requireMention setting if (isGroup) { @@ -239,6 +273,58 @@ export async function processFeishuMessage( } catch (err) { logger.error(`Failed to parse text message content: ${formatErrorMessage(err)}`); } + } else if (msgType === "post") { + // Post (rich text) message parsing + // Feishu post content can have two formats: + // Format 1: { post: { zh_cn: { title, content } } } (locale-wrapped) + // Format 2: { title, content } (direct) + try { + const content = JSON.parse(message.content ?? "{}"); + const parts: string[] = []; + + // Try to find the actual post content + let postData = content; + if (content.post && typeof content.post === "object") { + // Find the first locale key (zh_cn, en_us, etc.) + const localeKey = Object.keys(content.post).find( + (key) => content.post[key]?.content || content.post[key]?.title, + ); + if (localeKey) { + postData = content.post[localeKey]; + } + } + + // Include title if present + if (postData.title) { + parts.push(postData.title); + } + + // Extract text from content elements + if (Array.isArray(postData.content)) { + for (const line of postData.content) { + if (!Array.isArray(line)) { + continue; + } + const lineParts: string[] = []; + for (const element of line) { + if (element.tag === "text" && element.text) { + lineParts.push(element.text); + } else if (element.tag === "a" && element.text) { + lineParts.push(element.text); + } else if (element.tag === "at" && element.user_name) { + lineParts.push(`@${element.user_name}`); + } + } + if (lineParts.length > 0) { + parts.push(lineParts.join("")); + } + } + } + + text = parts.join("\n"); + } catch (err) { + logger.error(`Failed to parse post message content: ${formatErrorMessage(err)}`); + } } // Remove @mention placeholders from text @@ -250,7 +336,29 @@ export async function processFeishuMessage( // Resolve media if present let media: FeishuMediaRef | null = null; - if (msgType !== "text") { + let postImages: FeishuMediaRef[] = []; + + if (msgType === "post") { + // Extract and download embedded images from post message + try { + const content = JSON.parse(message.content ?? "{}"); + const imageKeys = extractPostImageKeys(content); + if (imageKeys.length > 0 && message.message_id) { + postImages = await downloadPostImages( + client, + message.message_id, + imageKeys, + maxMediaBytes, + 5, // max 5 images per post + ); + logger.debug( + `Downloaded ${postImages.length}/${imageKeys.length} images from post message`, + ); + } + } catch (err) { + logger.error(`Failed to download post images: ${formatErrorMessage(err)}`); + } + } else if (msgType !== "text") { try { media = await resolveFeishuMedia(client, message, maxMediaBytes); } catch (err) { @@ -258,19 +366,43 @@ export async function processFeishuMessage( } } + // Resolve document content if message contains Feishu doc links + let docContent: string | null = null; + if (msgType === "text" || msgType === "post") { + try { + docContent = await resolveFeishuDocsFromMessage(client, message, { + maxDocsPerMessage: 3, + maxTotalLength: 100000, + domain: options.credentials?.domain, + }); + if (docContent) { + logger.debug(`Resolved ${docContent.length} chars of document content`); + } + } catch (err) { + logger.error(`Failed to resolve document content: ${formatErrorMessage(err)}`); + } + } + // Build body text let bodyText = text; if (!bodyText && media) { bodyText = media.placeholder; } + // Append document content if available + if (docContent) { + bodyText = bodyText ? `${bodyText}\n\n${docContent}` : docContent; + } + // Skip if no content - if (!bodyText && !media) { + if (!bodyText && !media && postImages.length === 0) { logger.debug(`Empty message after processing, skipping`); return; } - const senderName = sender?.sender_id?.user_id || "unknown"; + // Get sender display name (try to fetch from contact API, fallback to user_id) + const fallbackName = sender?.sender_id?.user_id || "unknown"; + const senderName = await getFeishuUserDisplayName(client, senderId, fallbackName); // Streaming mode support const streamingEnabled = (feishuCfg.streaming ?? true) && Boolean(options.credentials); @@ -281,12 +413,24 @@ export async function processFeishuMessage( let streamingStarted = false; let lastPartialText = ""; + // Typing indicator callbacks (for non-streaming mode) + const typingCallbacks = createTypingIndicatorCallbacks(client, message.message_id); + + // Use first post image as primary media if no other media + const primaryMedia = media ?? (postImages.length > 0 ? postImages[0] : null); + const additionalMediaPaths = postImages.length > 1 ? postImages.slice(1).map((m) => m.path) : []; + + // Reply/Thread metadata for inbound messages + const replyToId = message.parent_id ?? message.root_id; + const messageThreadId = message.root_id ?? undefined; + // Context construction const ctx = { Body: bodyText, - RawBody: text || media?.placeholder || "", + RawBody: text || primaryMedia?.placeholder || "", From: senderId, To: chatId, + SessionKey: route.sessionKey, SenderId: senderId, SenderName: senderName, ChatType: isGroup ? "group" : "dm", @@ -294,14 +438,21 @@ export async function processFeishuMessage( Surface: "feishu", Timestamp: Number(message.create_time), MessageSid: message.message_id, - AccountId: accountId, + AccountId: route.accountId, OriginatingChannel: "feishu", OriginatingTo: chatId, // Media fields (similar to Telegram) - MediaPath: media?.path, - MediaType: media?.contentType, - MediaUrl: media?.path, + MediaPath: primaryMedia?.path, + MediaType: primaryMedia?.contentType, + MediaUrl: primaryMedia?.path, + // Additional images from post messages + MediaUrls: additionalMediaPaths.length > 0 ? additionalMediaPaths : undefined, WasMentioned: isGroup ? wasMentioned : undefined, + // Reply/thread metadata when the inbound message is a reply + MessageThreadId: messageThreadId, + ReplyToId: replyToId, + // Command authorization - if message reached here, sender passed access control + CommandAuthorized: true, }; const agentId = resolveSessionAgentId({ config: cfg }); @@ -361,6 +512,8 @@ export async function processFeishuMessage( { mediaUrl, receiveIdType: "chat_id", + // Only reply to the first media item to avoid spamming quote replies + replyToMessageId: i === 0 ? payload.replyToId : undefined, }, ); } @@ -374,19 +527,37 @@ export async function processFeishuMessage( { msgType: "text", receiveIdType: "chat_id", + replyToMessageId: payload.replyToId, }, ); } } }, onError: (err) => { - logger.error(`Reply error: ${formatErrorMessage(err)}`); + const msg = formatErrorMessage(err); + if ( + msg.includes("permission") || + msg.includes("forbidden") || + msg.includes("code: 99991660") + ) { + logger.error( + `Reply error: ${msg} (Check if "im:message" or "im:resource" permissions are enabled in Feishu Console)`, + ); + } else { + logger.error(`Reply error: ${msg}`); + } // Clean up streaming session on error if (streamingSession?.isActive()) { streamingSession.close().catch(() => {}); } + // Clean up typing indicator on error + typingCallbacks.onIdle().catch(() => {}); }, onReplyStart: async () => { + // Add typing indicator reaction (for non-streaming fallback) + if (!streamingSession) { + await typingCallbacks.onReplyStart(); + } // Start streaming card when reply generation begins if (streamingSession && !streamingStarted) { try { @@ -394,7 +565,14 @@ export async function processFeishuMessage( streamingStarted = true; logger.debug(`Started streaming card for chat ${chatId}`); } catch (err) { - logger.warn(`Failed to start streaming card: ${formatErrorMessage(err)}`); + const msg = formatErrorMessage(err); + if (msg.includes("permission") || msg.includes("forbidden")) { + logger.warn( + `Failed to start streaming card: ${msg} (Check if "im:resource:msg:send" or card permissions are enabled)`, + ); + } else { + logger.warn(`Failed to start streaming card: ${msg}`); + } // Continue without streaming } } @@ -435,4 +613,7 @@ export async function processFeishuMessage( if (streamingSession?.isActive()) { await streamingSession.close(); } + + // Clean up typing indicator + await typingCallbacks.onIdle(); } diff --git a/src/feishu/monitor.ts b/src/feishu/monitor.ts index 2b36ca95a5..f17a88a4d3 100644 --- a/src/feishu/monitor.ts +++ b/src/feishu/monitor.ts @@ -7,6 +7,7 @@ import { resolveFeishuAccount } from "./accounts.js"; import { resolveFeishuConfig } from "./config.js"; import { normalizeFeishuDomain } from "./domain.js"; import { processFeishuMessage } from "./message.js"; +import { probeFeishu } from "./probe.js"; const logger = getChildLogger({ module: "feishu-monitor" }); @@ -70,6 +71,13 @@ export async function monitorFeishuProvider(opts: MonitorFeishuOpts = {}): Promi }, }); + // Get bot's open_id for detecting mentions in group chats + const probeResult = await probeFeishu(appId, appSecret, 5000, domain); + const botOpenId = probeResult.bot?.openId ?? undefined; + if (!botOpenId) { + logger.warn(`Could not get bot open_id, group mention detection may not work correctly`); + } + // Create event dispatcher const eventDispatcher = new Lark.EventDispatcher({}).register({ "im.message.receive_v1": async (data) => { @@ -81,6 +89,7 @@ export async function monitorFeishuProvider(opts: MonitorFeishuOpts = {}): Promi resolvedConfig: feishuCfg, credentials: { appId, appSecret, domain }, botName: account.name, + botOpenId, }); } catch (err) { logger.error(`Error processing Feishu message: ${String(err)}`); diff --git a/src/feishu/probe.ts b/src/feishu/probe.ts index bfe33eab22..bc2c600a29 100644 --- a/src/feishu/probe.ts +++ b/src/feishu/probe.ts @@ -12,6 +12,7 @@ export type FeishuProbe = { appId?: string | null; appName?: string | null; avatarUrl?: string | null; + openId?: string | null; }; }; @@ -107,6 +108,7 @@ export async function probeFeishu( appId: appId, appName: botJson.bot?.app_name ?? null, avatarUrl: botJson.bot?.avatar_url ?? null, + openId: botJson.bot?.open_id ?? null, }; result.elapsedMs = Date.now() - started; return result; diff --git a/src/feishu/reactions.ts b/src/feishu/reactions.ts new file mode 100644 index 0000000000..05b48ec77d --- /dev/null +++ b/src/feishu/reactions.ts @@ -0,0 +1,136 @@ +import type { Client } from "@larksuiteoapi/node-sdk"; + +/** + * Reaction info returned from Feishu API + */ +export type FeishuReaction = { + reactionId: string; + emojiType: string; + operatorType: "app" | "user"; + operatorId: string; +}; + +/** + * Add a reaction (emoji) to a message. + * @param emojiType - Feishu emoji type, e.g., "SMILE", "THUMBSUP", "HEART", "Typing" + * @see https://open.feishu.cn/document/server-docs/im-v1/message-reaction/emojis-introduce + */ +export async function addReactionFeishu( + client: Client, + messageId: string, + emojiType: string, +): Promise<{ reactionId: string }> { + const response = (await client.im.messageReaction.create({ + path: { message_id: messageId }, + data: { + reaction_type: { + emoji_type: emojiType, + }, + }, + })) as { + code?: number; + msg?: string; + data?: { reaction_id?: string }; + }; + + if (response.code !== 0) { + throw new Error(`Feishu add reaction failed: ${response.msg || `code ${response.code}`}`); + } + + const reactionId = response.data?.reaction_id; + if (!reactionId) { + throw new Error("Feishu add reaction failed: no reaction_id returned"); + } + + return { reactionId }; +} + +/** + * Remove a reaction from a message. + */ +export async function removeReactionFeishu( + client: Client, + messageId: string, + reactionId: string, +): Promise { + const response = (await client.im.messageReaction.delete({ + path: { + message_id: messageId, + reaction_id: reactionId, + }, + })) as { code?: number; msg?: string }; + + if (response.code !== 0) { + throw new Error(`Feishu remove reaction failed: ${response.msg || `code ${response.code}`}`); + } +} + +/** + * List all reactions for a message. + */ +export async function listReactionsFeishu( + client: Client, + messageId: string, + emojiType?: string, +): Promise { + const response = (await client.im.messageReaction.list({ + path: { message_id: messageId }, + params: emojiType ? { reaction_type: emojiType } : undefined, + })) as { + code?: number; + msg?: string; + data?: { + items?: Array<{ + reaction_id?: string; + reaction_type?: { emoji_type?: string }; + operator_type?: string; + operator_id?: { open_id?: string; user_id?: string; union_id?: string }; + }>; + }; + }; + + if (response.code !== 0) { + throw new Error(`Feishu list reactions failed: ${response.msg || `code ${response.code}`}`); + } + + const items = response.data?.items ?? []; + return items.map((item) => ({ + reactionId: item.reaction_id ?? "", + emojiType: item.reaction_type?.emoji_type ?? "", + operatorType: item.operator_type === "app" ? "app" : "user", + operatorId: + item.operator_id?.open_id ?? item.operator_id?.user_id ?? item.operator_id?.union_id ?? "", + })); +} + +/** + * Common Feishu emoji types for convenience. + * @see https://open.feishu.cn/document/server-docs/im-v1/message-reaction/emojis-introduce + */ +export const FeishuEmoji = { + // Common reactions + THUMBSUP: "THUMBSUP", + THUMBSDOWN: "THUMBSDOWN", + HEART: "HEART", + SMILE: "SMILE", + GRINNING: "GRINNING", + LAUGHING: "LAUGHING", + CRY: "CRY", + ANGRY: "ANGRY", + SURPRISED: "SURPRISED", + THINKING: "THINKING", + CLAP: "CLAP", + OK: "OK", + FIST: "FIST", + PRAY: "PRAY", + FIRE: "FIRE", + PARTY: "PARTY", + CHECK: "CHECK", + CROSS: "CROSS", + QUESTION: "QUESTION", + EXCLAMATION: "EXCLAMATION", + // Special typing indicator + TYPING: "Typing", +} as const; + +export type FeishuEmojiType = (typeof FeishuEmoji)[keyof typeof FeishuEmoji]; diff --git a/src/feishu/send.ts b/src/feishu/send.ts index 977e2a107c..0bb8ebaac7 100644 --- a/src/feishu/send.ts +++ b/src/feishu/send.ts @@ -18,6 +18,10 @@ export type FeishuSendOpts = { maxBytes?: number; /** Whether to auto-convert Markdown to rich text (post). Default: true */ autoRichText?: boolean; + /** Message ID to reply to (uses reply API instead of create) */ + replyToMessageId?: string; + /** Whether to reply in thread mode. Default: false */ + replyInThread?: boolean; }; export type FeishuSendResult = { @@ -230,18 +234,25 @@ export async function sendMessageFeishu( // First send the media, then send text as a follow-up if (typeof contentText === "string" && contentText.trim()) { // Send media first - const mediaRes = await client.im.message.create({ - params: { receive_id_type: receiveIdType }, - data: { - receive_id: receiveId, - msg_type: msgType, - content: JSON.stringify(finalContent), - }, - }); + const mediaContent = JSON.stringify(finalContent); + if (opts.replyToMessageId) { + await replyMessageFeishu(client, opts.replyToMessageId, mediaContent, msgType, { + replyInThread: opts.replyInThread, + }); + } else { + const mediaRes = await client.im.message.create({ + params: { receive_id_type: receiveIdType }, + data: { + receive_id: receiveId, + msg_type: msgType, + content: mediaContent, + }, + }); - if (mediaRes.code !== 0) { - logger.error(`Feishu media send failed: ${mediaRes.code} - ${mediaRes.msg}`); - throw new Error(`Feishu API Error: ${mediaRes.msg}`); + if (mediaRes.code !== 0) { + logger.error(`Feishu media send failed: ${mediaRes.code} - ${mediaRes.msg}`); + throw new Error(`Feishu API Error: ${mediaRes.msg}`); + } } // Then send text @@ -297,6 +308,13 @@ export async function sendMessageFeishu( const contentStr = typeof finalContent === "string" ? finalContent : JSON.stringify(finalContent); + // Use reply API if replyToMessageId is provided + if (opts.replyToMessageId) { + return replyMessageFeishu(client, opts.replyToMessageId, contentStr, msgType, { + replyInThread: opts.replyInThread, + }); + } + try { const res = await client.im.message.create({ params: { receive_id_type: receiveIdType }, @@ -317,3 +335,40 @@ export async function sendMessageFeishu( throw err; } } + +export type FeishuReplyOpts = { + /** Whether to reply in thread mode. Default: false */ + replyInThread?: boolean; +}; + +/** + * Reply to a specific message in Feishu + * Uses the Feishu reply API: POST /open-apis/im/v1/messages/:message_id/reply + */ +export async function replyMessageFeishu( + client: Client, + messageId: string, + content: string, + msgType: FeishuMsgType, + opts: FeishuReplyOpts = {}, +): Promise { + try { + const res = await client.im.message.reply({ + path: { message_id: messageId }, + data: { + msg_type: msgType, + content: content, + reply_in_thread: opts.replyInThread ?? false, + }, + }); + + if (res.code !== 0) { + logger.error(`Feishu reply failed: ${res.code} - ${res.msg}`); + throw new Error(`Feishu API Error: ${res.msg}`); + } + return res.data ?? null; + } catch (err) { + logger.error(`Feishu reply error: ${formatErrorMessage(err)}`); + throw err; + } +} diff --git a/src/feishu/typing.ts b/src/feishu/typing.ts new file mode 100644 index 0000000000..85dd6001ae --- /dev/null +++ b/src/feishu/typing.ts @@ -0,0 +1,89 @@ +import type { Client } from "@larksuiteoapi/node-sdk"; +import { formatErrorMessage } from "../infra/errors.js"; +import { getChildLogger } from "../logging.js"; +import { addReactionFeishu, removeReactionFeishu, FeishuEmoji } from "./reactions.js"; + +const logger = getChildLogger({ module: "feishu-typing" }); + +/** + * Typing indicator state + */ +export type TypingIndicatorState = { + messageId: string; + reactionId: string | null; +}; + +/** + * Add a typing indicator (reaction) to a message. + * + * Feishu doesn't have a native typing indicator API, so we use emoji reactions + * as a visual substitute. The "Typing" emoji provides immediate feedback to users. + * + * Requires permission: im:message.reaction:read_write + */ +export async function addTypingIndicator( + client: Client, + messageId: string, +): Promise { + try { + const { reactionId } = await addReactionFeishu(client, messageId, FeishuEmoji.TYPING); + logger.debug(`Added typing indicator reaction: ${reactionId}`); + return { messageId, reactionId }; + } catch (err) { + // Silently fail - typing indicator is not critical + logger.debug(`Failed to add typing indicator: ${formatErrorMessage(err)}`); + return { messageId, reactionId: null }; + } +} + +/** + * Remove a typing indicator (reaction) from a message. + */ +export async function removeTypingIndicator( + client: Client, + state: TypingIndicatorState, +): Promise { + if (!state.reactionId) { + return; + } + + try { + await removeReactionFeishu(client, state.messageId, state.reactionId); + logger.debug(`Removed typing indicator reaction: ${state.reactionId}`); + } catch (err) { + // Silently fail - cleanup is not critical + logger.debug(`Failed to remove typing indicator: ${formatErrorMessage(err)}`); + } +} + +/** + * Create typing indicator callbacks for use with reply dispatchers. + * These callbacks automatically manage the typing indicator lifecycle. + */ +export function createTypingIndicatorCallbacks( + client: Client, + messageId: string | undefined, +): { + state: { current: TypingIndicatorState | null }; + onReplyStart: () => Promise; + onIdle: () => Promise; +} { + const state: { current: TypingIndicatorState | null } = { current: null }; + + return { + state, + onReplyStart: async () => { + if (!messageId) { + return; + } + state.current = await addTypingIndicator(client, messageId); + }, + onIdle: async () => { + if (!state.current) { + return; + } + await removeTypingIndicator(client, state.current); + state.current = null; + }, + }; +} diff --git a/src/feishu/user.ts b/src/feishu/user.ts new file mode 100644 index 0000000000..1598c94431 --- /dev/null +++ b/src/feishu/user.ts @@ -0,0 +1,93 @@ +import type { Client } from "@larksuiteoapi/node-sdk"; +import { formatErrorMessage } from "../infra/errors.js"; +import { getChildLogger } from "../logging.js"; + +const logger = getChildLogger({ module: "feishu-user" }); + +export type FeishuUserInfo = { + openId: string; + name?: string; + enName?: string; + avatar?: string; +}; + +// Simple in-memory cache for user info (expires after 1 hour) +const userCache = new Map(); +const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour + +/** + * Get user information from Feishu + * Uses the contact API: GET /open-apis/contact/v3/users/:user_id + * Requires permission: contact:user.base:readonly or contact:contact:readonly_as_app + */ +export async function getFeishuUserInfo( + client: Client, + openId: string, +): Promise { + // Check cache first + const cached = userCache.get(openId); + if (cached && cached.expiresAt > Date.now()) { + return cached.info; + } + + try { + const res = await client.contact.user.get({ + path: { user_id: openId }, + params: { user_id_type: "open_id" }, + }); + + if (res.code !== 0) { + logger.debug(`Failed to get user info for ${openId}: ${res.code} - ${res.msg}`); + return null; + } + + const user = res.data?.user; + if (!user) { + return null; + } + + const info: FeishuUserInfo = { + openId, + name: user.name, + enName: user.en_name, + avatar: user.avatar?.avatar_240, + }; + + // Cache the result + userCache.set(openId, { + info, + expiresAt: Date.now() + CACHE_TTL_MS, + }); + + return info; + } catch (err) { + // Gracefully handle permission errors - just log and return null + logger.debug(`Error getting user info for ${openId}: ${formatErrorMessage(err)}`); + return null; + } +} + +/** + * Get display name for a user + * Falls back to openId if name is not available + */ +export async function getFeishuUserDisplayName( + client: Client, + openId: string, + fallback?: string, +): Promise { + const info = await getFeishuUserInfo(client, openId); + return info?.name || info?.enName || fallback || openId; +} + +/** + * Clear expired entries from the cache + */ +export function cleanupUserCache(): void { + const now = Date.now(); + for (const [key, value] of userCache) { + if (value.expiresAt < now) { + userCache.delete(key); + } + } +}