From 2267d58afcc70fe19408b8f0dce108c340f3426d Mon Sep 17 00:00:00 2001 From: Yifeng Wang Date: Thu, 5 Feb 2026 18:26:05 +0800 Subject: [PATCH] feat(feishu): replace built-in SDK with community plugin Replace the built-in Feishu SDK with the community-maintained clawdbot-feishu plugin by @m1heng. Changes: - Remove src/feishu/ directory (19 files) - Remove src/channels/plugins/outbound/feishu.ts - Remove src/channels/plugins/normalize/feishu.ts - Remove src/config/types.feishu.ts - Remove feishu exports from plugin-sdk/index.ts - Remove FeishuConfig from types.channels.ts New features in community plugin: - Document tools (read/create/edit Feishu docs) - Wiki tools (navigate/manage knowledge base) - Drive tools (folder/file management) - Bitable tools (read/write table records) - Permission tools (collaborator management) - Emoji reactions support - Typing indicators - Rich media support (bidirectional image/file transfer) - @mention handling - Skills for feishu-doc, feishu-wiki, feishu-drive, feishu-perm Co-Authored-By: Claude Opus 4.5 --- extensions/feishu/README.md | 47 - extensions/feishu/index.ts | 50 +- extensions/feishu/openclaw.plugin.json | 1 + extensions/feishu/package.json | 12 +- extensions/feishu/skills/feishu-doc/SKILL.md | 105 +++ .../feishu-doc/references/block-types.md | 103 +++ .../feishu/skills/feishu-drive/SKILL.md | 97 +++ extensions/feishu/skills/feishu-perm/SKILL.md | 119 +++ extensions/feishu/skills/feishu-wiki/SKILL.md | 111 +++ extensions/feishu/src/accounts.ts | 53 ++ extensions/feishu/src/bitable.ts | 443 ++++++++++ extensions/feishu/src/bot.ts | 823 ++++++++++++++++++ extensions/feishu/src/channel.ts | 374 ++++---- extensions/feishu/src/client.ts | 68 ++ extensions/feishu/src/config-schema.ts | 144 ++- extensions/feishu/src/directory.ts | 159 ++++ extensions/feishu/src/doc-schema.ts | 47 + extensions/feishu/src/docx.ts | 470 ++++++++++ extensions/feishu/src/drive-schema.ts | 46 + extensions/feishu/src/drive.ts | 204 +++++ extensions/feishu/src/media.ts | 513 +++++++++++ extensions/feishu/src/mention.ts | 118 +++ extensions/feishu/src/monitor.ts | 156 ++++ extensions/feishu/src/onboarding.ts | 488 ++++++----- extensions/feishu/src/outbound.ts | 40 + extensions/feishu/src/perm-schema.ts | 52 ++ extensions/feishu/src/perm.ts | 160 ++++ extensions/feishu/src/policy.ts | 92 ++ extensions/feishu/src/probe.ts | 46 + .../feishu/src}/reactions.ts | 65 +- extensions/feishu/src/reply-dispatcher.ts | 161 ++++ extensions/feishu/src/runtime.ts | 14 + extensions/feishu/src/send.ts | 356 ++++++++ extensions/feishu/src/targets.ts | 58 ++ extensions/feishu/src/tools-config.ts | 21 + extensions/feishu/src/types.ts | 63 ++ extensions/feishu/src/typing.ts | 73 ++ extensions/feishu/src/wiki-schema.ts | 55 ++ extensions/feishu/src/wiki.ts | 213 +++++ src/channels/plugins/normalize/feishu.ts | 5 - src/channels/plugins/outbound/feishu.ts | 52 -- src/config/types.channels.ts | 2 - src/config/types.feishu.ts | 100 --- src/config/types.ts | 1 - src/feishu/access.ts | 91 -- src/feishu/accounts.ts | 142 --- src/feishu/bot.ts | 58 -- src/feishu/client.ts | 134 --- src/feishu/config.ts | 91 -- src/feishu/docs.test.ts | 135 --- src/feishu/docs.ts | 456 ---------- src/feishu/domain.ts | 31 - src/feishu/download.ts | 277 ------ src/feishu/format.test.ts | 94 -- src/feishu/format.ts | 267 ------ src/feishu/index.ts | 8 - src/feishu/message.ts | 619 ------------- src/feishu/monitor.ts | 161 ---- src/feishu/pairing-store.ts | 129 --- src/feishu/probe.ts | 124 --- src/feishu/send.ts | 374 -------- src/feishu/streaming-card.ts | 404 --------- src/feishu/types.ts | 14 - src/feishu/typing.ts | 89 -- src/feishu/user.ts | 93 -- src/plugin-sdk/index.ts | 17 - 66 files changed, 5702 insertions(+), 4486 deletions(-) delete mode 100644 extensions/feishu/README.md create mode 100644 extensions/feishu/skills/feishu-doc/SKILL.md create mode 100644 extensions/feishu/skills/feishu-doc/references/block-types.md create mode 100644 extensions/feishu/skills/feishu-drive/SKILL.md create mode 100644 extensions/feishu/skills/feishu-perm/SKILL.md create mode 100644 extensions/feishu/skills/feishu-wiki/SKILL.md create mode 100644 extensions/feishu/src/accounts.ts create mode 100644 extensions/feishu/src/bitable.ts create mode 100644 extensions/feishu/src/bot.ts create mode 100644 extensions/feishu/src/client.ts create mode 100644 extensions/feishu/src/directory.ts create mode 100644 extensions/feishu/src/doc-schema.ts create mode 100644 extensions/feishu/src/docx.ts create mode 100644 extensions/feishu/src/drive-schema.ts create mode 100644 extensions/feishu/src/drive.ts create mode 100644 extensions/feishu/src/media.ts create mode 100644 extensions/feishu/src/mention.ts create mode 100644 extensions/feishu/src/monitor.ts create mode 100644 extensions/feishu/src/outbound.ts create mode 100644 extensions/feishu/src/perm-schema.ts create mode 100644 extensions/feishu/src/perm.ts create mode 100644 extensions/feishu/src/policy.ts create mode 100644 extensions/feishu/src/probe.ts rename {src/feishu => extensions/feishu/src}/reactions.ts (68%) create mode 100644 extensions/feishu/src/reply-dispatcher.ts create mode 100644 extensions/feishu/src/runtime.ts create mode 100644 extensions/feishu/src/send.ts create mode 100644 extensions/feishu/src/targets.ts create mode 100644 extensions/feishu/src/tools-config.ts create mode 100644 extensions/feishu/src/types.ts create mode 100644 extensions/feishu/src/typing.ts create mode 100644 extensions/feishu/src/wiki-schema.ts create mode 100644 extensions/feishu/src/wiki.ts delete mode 100644 src/channels/plugins/normalize/feishu.ts delete mode 100644 src/channels/plugins/outbound/feishu.ts delete mode 100644 src/config/types.feishu.ts delete mode 100644 src/feishu/access.ts delete mode 100644 src/feishu/accounts.ts delete mode 100644 src/feishu/bot.ts delete mode 100644 src/feishu/client.ts delete mode 100644 src/feishu/config.ts delete mode 100644 src/feishu/docs.test.ts delete mode 100644 src/feishu/docs.ts delete mode 100644 src/feishu/domain.ts delete mode 100644 src/feishu/download.ts delete mode 100644 src/feishu/format.test.ts delete mode 100644 src/feishu/format.ts delete mode 100644 src/feishu/index.ts delete mode 100644 src/feishu/message.ts delete mode 100644 src/feishu/monitor.ts delete mode 100644 src/feishu/pairing-store.ts delete mode 100644 src/feishu/probe.ts delete mode 100644 src/feishu/send.ts delete mode 100644 src/feishu/streaming-card.ts delete mode 100644 src/feishu/types.ts delete mode 100644 src/feishu/typing.ts delete mode 100644 src/feishu/user.ts diff --git a/extensions/feishu/README.md b/extensions/feishu/README.md deleted file mode 100644 index 9bd0e5ce09..0000000000 --- a/extensions/feishu/README.md +++ /dev/null @@ -1,47 +0,0 @@ -# @openclaw/feishu - -Feishu/Lark channel plugin for OpenClaw (WebSocket bot events). - -## Install (local checkout) - -```bash -openclaw plugins install ./extensions/feishu -``` - -## Install (npm) - -```bash -openclaw plugins install @openclaw/feishu -``` - -Onboarding: select Feishu/Lark and confirm the install prompt to fetch the plugin automatically. - -## Config - -```json5 -{ - channels: { - feishu: { - accounts: { - default: { - appId: "cli_xxx", - appSecret: "xxx", - domain: "feishu", - enabled: true, - }, - }, - dmPolicy: "pairing", - groupPolicy: "open", - blockStreaming: true, - }, - }, -} -``` - -Lark (global) tenants should set `domain: "lark"` (or a full https:// domain). - -Restart the gateway after config changes. - -## Docs - -https://docs.openclaw.ai/channels/feishu diff --git a/extensions/feishu/index.ts b/extensions/feishu/index.ts index adeeba5f6c..7b2375acf5 100644 --- a/extensions/feishu/index.ts +++ b/extensions/feishu/index.ts @@ -1,14 +1,62 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import { emptyPluginConfigSchema } from "openclaw/plugin-sdk"; +import { registerFeishuBitableTools } from "./src/bitable.js"; import { feishuPlugin } from "./src/channel.js"; +import { registerFeishuDocTools } from "./src/docx.js"; +import { registerFeishuDriveTools } from "./src/drive.js"; +import { registerFeishuPermTools } from "./src/perm.js"; +import { setFeishuRuntime } from "./src/runtime.js"; +import { registerFeishuWikiTools } from "./src/wiki.js"; + +export { monitorFeishuProvider } from "./src/monitor.js"; +export { + sendMessageFeishu, + sendCardFeishu, + updateCardFeishu, + editMessageFeishu, + getMessageFeishu, +} from "./src/send.js"; +export { + uploadImageFeishu, + uploadFileFeishu, + sendImageFeishu, + sendFileFeishu, + sendMediaFeishu, +} from "./src/media.js"; +export { probeFeishu } from "./src/probe.js"; +export { + addReactionFeishu, + removeReactionFeishu, + listReactionsFeishu, + FeishuEmoji, +} from "./src/reactions.js"; +export { + extractMentionTargets, + extractMessageBody, + isMentionForwardRequest, + formatMentionForText, + formatMentionForCard, + formatMentionAllForText, + formatMentionAllForCard, + buildMentionedMessage, + buildMentionedCardContent, + type MentionTarget, +} from "./src/mention.js"; +export { feishuPlugin } from "./src/channel.js"; const plugin = { id: "feishu", name: "Feishu", - description: "Feishu (Lark) channel plugin", + description: "Feishu/Lark channel plugin", configSchema: emptyPluginConfigSchema(), register(api: OpenClawPluginApi) { + setFeishuRuntime(api.runtime); api.registerChannel({ plugin: feishuPlugin }); + registerFeishuDocTools(api); + registerFeishuWikiTools(api); + registerFeishuDriveTools(api); + registerFeishuPermTools(api); + registerFeishuBitableTools(api); }, }; diff --git a/extensions/feishu/openclaw.plugin.json b/extensions/feishu/openclaw.plugin.json index 93fb800f4d..90358d7ec5 100644 --- a/extensions/feishu/openclaw.plugin.json +++ b/extensions/feishu/openclaw.plugin.json @@ -1,6 +1,7 @@ { "id": "feishu", "channels": ["feishu"], + "skills": ["./skills"], "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/feishu/package.json b/extensions/feishu/package.json index f6659bf220..cfa098ad14 100644 --- a/extensions/feishu/package.json +++ b/extensions/feishu/package.json @@ -1,8 +1,13 @@ { "name": "@openclaw/feishu", "version": "2026.2.4", - "description": "OpenClaw Feishu channel plugin", + "description": "OpenClaw Feishu/Lark channel plugin (community maintained by @m1heng)", "type": "module", + "dependencies": { + "@larksuiteoapi/node-sdk": "^1.56.1", + "@sinclair/typebox": "^0.34.48", + "zod": "^4.3.6" + }, "devDependencies": { "openclaw": "workspace:*" }, @@ -13,11 +18,10 @@ "channel": { "id": "feishu", "label": "Feishu", - "selectionLabel": "Feishu (Lark Open Platform)", - "detailLabel": "Feishu Bot", + "selectionLabel": "Feishu/Lark (飞书)", "docsPath": "/channels/feishu", "docsLabel": "feishu", - "blurb": "Feishu/Lark bot via WebSocket.", + "blurb": "飞书/Lark enterprise messaging with doc/wiki/drive tools.", "aliases": [ "lark" ], diff --git a/extensions/feishu/skills/feishu-doc/SKILL.md b/extensions/feishu/skills/feishu-doc/SKILL.md new file mode 100644 index 0000000000..13a790228a --- /dev/null +++ b/extensions/feishu/skills/feishu-doc/SKILL.md @@ -0,0 +1,105 @@ +--- +name: feishu-doc +description: | + Feishu document read/write operations. Activate when user mentions Feishu docs, cloud docs, or docx links. +--- + +# Feishu Document Tool + +Single tool `feishu_doc` with action parameter for all document operations. + +## Token Extraction + +From URL `https://xxx.feishu.cn/docx/ABC123def` → `doc_token` = `ABC123def` + +## Actions + +### Read Document + +```json +{ "action": "read", "doc_token": "ABC123def" } +``` + +Returns: title, plain text content, block statistics. Check `hint` field - if present, structured content (tables, images) exists that requires `list_blocks`. + +### Write Document (Replace All) + +```json +{ "action": "write", "doc_token": "ABC123def", "content": "# Title\n\nMarkdown content..." } +``` + +Replaces entire document with markdown content. Supports: headings, lists, code blocks, quotes, links, images (`![](url)` auto-uploaded), bold/italic/strikethrough. + +**Limitation:** Markdown tables are NOT supported. + +### Append Content + +```json +{ "action": "append", "doc_token": "ABC123def", "content": "Additional content" } +``` + +Appends markdown to end of document. + +### Create Document + +```json +{ "action": "create", "title": "New Document" } +``` + +With folder: + +```json +{ "action": "create", "title": "New Document", "folder_token": "fldcnXXX" } +``` + +### List Blocks + +```json +{ "action": "list_blocks", "doc_token": "ABC123def" } +``` + +Returns full block data including tables, images. Use this to read structured content. + +### Get Single Block + +```json +{ "action": "get_block", "doc_token": "ABC123def", "block_id": "doxcnXXX" } +``` + +### Update Block Text + +```json +{ + "action": "update_block", + "doc_token": "ABC123def", + "block_id": "doxcnXXX", + "content": "New text" +} +``` + +### Delete Block + +```json +{ "action": "delete_block", "doc_token": "ABC123def", "block_id": "doxcnXXX" } +``` + +## Reading Workflow + +1. Start with `action: "read"` - get plain text + statistics +2. Check `block_types` in response for Table, Image, Code, etc. +3. If structured content exists, use `action: "list_blocks"` for full data + +## Configuration + +```yaml +channels: + feishu: + tools: + doc: true # default: true +``` + +**Note:** `feishu_wiki` depends on this tool - wiki page content is read/written via `feishu_doc`. + +## Permissions + +Required: `docx:document`, `docx:document:readonly`, `docx:document.block:convert`, `drive:drive` diff --git a/extensions/feishu/skills/feishu-doc/references/block-types.md b/extensions/feishu/skills/feishu-doc/references/block-types.md new file mode 100644 index 0000000000..8ce599fe86 --- /dev/null +++ b/extensions/feishu/skills/feishu-doc/references/block-types.md @@ -0,0 +1,103 @@ +# Feishu Block Types Reference + +Complete reference for Feishu document block types. Use with `feishu_doc_list_blocks`, `feishu_doc_update_block`, and `feishu_doc_delete_block`. + +## Block Type Table + +| block_type | Name | Description | Editable | +| ---------- | --------------- | ------------------------------ | -------- | +| 1 | Page | Document root (contains title) | No | +| 2 | Text | Plain text paragraph | Yes | +| 3 | Heading1 | H1 heading | Yes | +| 4 | Heading2 | H2 heading | Yes | +| 5 | Heading3 | H3 heading | Yes | +| 6 | Heading4 | H4 heading | Yes | +| 7 | Heading5 | H5 heading | Yes | +| 8 | Heading6 | H6 heading | Yes | +| 9 | Heading7 | H7 heading | Yes | +| 10 | Heading8 | H8 heading | Yes | +| 11 | Heading9 | H9 heading | Yes | +| 12 | Bullet | Unordered list item | Yes | +| 13 | Ordered | Ordered list item | Yes | +| 14 | Code | Code block | Yes | +| 15 | Quote | Blockquote | Yes | +| 16 | Equation | LaTeX equation | Partial | +| 17 | Todo | Checkbox / task item | Yes | +| 18 | Bitable | Multi-dimensional table | No | +| 19 | Callout | Highlight block | Yes | +| 20 | ChatCard | Chat card embed | No | +| 21 | Diagram | Diagram embed | No | +| 22 | Divider | Horizontal rule | No | +| 23 | File | File attachment | No | +| 24 | Grid | Grid layout container | No | +| 25 | GridColumn | Grid column | No | +| 26 | Iframe | Embedded iframe | No | +| 27 | Image | Image | Partial | +| 28 | ISV | Third-party widget | No | +| 29 | MindnoteBlock | Mindmap embed | No | +| 30 | Sheet | Spreadsheet embed | No | +| 31 | Table | Table | Partial | +| 32 | TableCell | Table cell | Yes | +| 33 | View | View embed | No | +| 34 | Undefined | Unknown type | No | +| 35 | QuoteContainer | Quote container | No | +| 36 | Task | Lark Tasks integration | No | +| 37 | OKR | OKR integration | No | +| 38 | OKRObjective | OKR objective | No | +| 39 | OKRKeyResult | OKR key result | No | +| 40 | OKRProgress | OKR progress | No | +| 41 | AddOns | Add-ons block | No | +| 42 | JiraIssue | Jira issue embed | No | +| 43 | WikiCatalog | Wiki catalog | No | +| 44 | Board | Board embed | No | +| 45 | Agenda | Agenda block | No | +| 46 | AgendaItem | Agenda item | No | +| 47 | AgendaItemTitle | Agenda item title | No | +| 48 | SyncedBlock | Synced block reference | No | + +## Editing Guidelines + +### Text-based blocks (2-17, 19) + +Update text content using `feishu_doc_update_block`: + +```json +{ + "doc_token": "ABC123", + "block_id": "block_xxx", + "content": "New text content" +} +``` + +### Image blocks (27) + +Images cannot be updated directly via `update_block`. Use `feishu_doc_write` or `feishu_doc_append` with markdown to add new images. + +### Table blocks (31) + +**Important:** Table blocks CANNOT be created via the `documentBlockChildren.create` API (error 1770029). This affects `feishu_doc_write` and `feishu_doc_append` - markdown tables will be skipped with a warning. + +Tables can only be read (via `list_blocks`) and individual cells (type 32) can be updated, but new tables cannot be inserted programmatically via markdown. + +### Container blocks (24, 25, 35) + +Grid and QuoteContainer are layout containers. Edit their child blocks instead. + +## Common Patterns + +### Replace specific paragraph + +1. `feishu_doc_list_blocks` - find the block_id +2. `feishu_doc_update_block` - update its content + +### Insert content at specific location + +Currently, the API only supports appending to document end. For insertion at specific positions, consider: + +1. Read existing content +2. Delete affected blocks +3. Rewrite with new content in desired order + +### Delete multiple blocks + +Blocks must be deleted one at a time. Delete child blocks before parent containers. diff --git a/extensions/feishu/skills/feishu-drive/SKILL.md b/extensions/feishu/skills/feishu-drive/SKILL.md new file mode 100644 index 0000000000..6b46eec7c8 --- /dev/null +++ b/extensions/feishu/skills/feishu-drive/SKILL.md @@ -0,0 +1,97 @@ +--- +name: feishu-drive +description: | + Feishu cloud storage file management. Activate when user mentions cloud space, folders, drive. +--- + +# Feishu Drive Tool + +Single tool `feishu_drive` for cloud storage operations. + +## Token Extraction + +From URL `https://xxx.feishu.cn/drive/folder/ABC123` → `folder_token` = `ABC123` + +## Actions + +### List Folder Contents + +```json +{ "action": "list" } +``` + +Root directory (no folder_token). + +```json +{ "action": "list", "folder_token": "fldcnXXX" } +``` + +Returns: files with token, name, type, url, timestamps. + +### Get File Info + +```json +{ "action": "info", "file_token": "ABC123", "type": "docx" } +``` + +Searches for the file in the root directory. Note: file must be in root or use `list` to browse folders first. + +`type`: `doc`, `docx`, `sheet`, `bitable`, `folder`, `file`, `mindnote`, `shortcut` + +### Create Folder + +```json +{ "action": "create_folder", "name": "New Folder" } +``` + +In parent folder: + +```json +{ "action": "create_folder", "name": "New Folder", "folder_token": "fldcnXXX" } +``` + +### Move File + +```json +{ "action": "move", "file_token": "ABC123", "type": "docx", "folder_token": "fldcnXXX" } +``` + +### Delete File + +```json +{ "action": "delete", "file_token": "ABC123", "type": "docx" } +``` + +## File Types + +| Type | Description | +| ---------- | ----------------------- | +| `doc` | Old format document | +| `docx` | New format document | +| `sheet` | Spreadsheet | +| `bitable` | Multi-dimensional table | +| `folder` | Folder | +| `file` | Uploaded file | +| `mindnote` | Mind map | +| `shortcut` | Shortcut | + +## Configuration + +```yaml +channels: + feishu: + tools: + drive: true # default: true +``` + +## Permissions + +- `drive:drive` - Full access (create, move, delete) +- `drive:drive:readonly` - Read only (list, info) + +## Known Limitations + +- **Bots have no root folder**: Feishu bots use `tenant_access_token` and don't have their own "My Space". The root folder concept only exists for user accounts. This means: + - `create_folder` without `folder_token` will fail (400 error) + - Bot can only access files/folders that have been **shared with it** + - **Workaround**: User must first create a folder manually and share it with the bot, then bot can create subfolders inside it diff --git a/extensions/feishu/skills/feishu-perm/SKILL.md b/extensions/feishu/skills/feishu-perm/SKILL.md new file mode 100644 index 0000000000..1ce5db8b86 --- /dev/null +++ b/extensions/feishu/skills/feishu-perm/SKILL.md @@ -0,0 +1,119 @@ +--- +name: feishu-perm +description: | + Feishu permission management for documents and files. Activate when user mentions sharing, permissions, collaborators. +--- + +# Feishu Permission Tool + +Single tool `feishu_perm` for managing file/document permissions. + +## Actions + +### List Collaborators + +```json +{ "action": "list", "token": "ABC123", "type": "docx" } +``` + +Returns: members with member_type, member_id, perm, name. + +### Add Collaborator + +```json +{ + "action": "add", + "token": "ABC123", + "type": "docx", + "member_type": "email", + "member_id": "user@example.com", + "perm": "edit" +} +``` + +### Remove Collaborator + +```json +{ + "action": "remove", + "token": "ABC123", + "type": "docx", + "member_type": "email", + "member_id": "user@example.com" +} +``` + +## Token Types + +| Type | Description | +| ---------- | ----------------------- | +| `doc` | Old format document | +| `docx` | New format document | +| `sheet` | Spreadsheet | +| `bitable` | Multi-dimensional table | +| `folder` | Folder | +| `file` | Uploaded file | +| `wiki` | Wiki node | +| `mindnote` | Mind map | + +## Member Types + +| Type | Description | +| ------------------ | ------------------ | +| `email` | Email address | +| `openid` | User open_id | +| `userid` | User user_id | +| `unionid` | User union_id | +| `openchat` | Group chat open_id | +| `opendepartmentid` | Department open_id | + +## Permission Levels + +| Perm | Description | +| ------------- | ------------------------------------ | +| `view` | View only | +| `edit` | Can edit | +| `full_access` | Full access (can manage permissions) | + +## Examples + +Share document with email: + +```json +{ + "action": "add", + "token": "doxcnXXX", + "type": "docx", + "member_type": "email", + "member_id": "alice@company.com", + "perm": "edit" +} +``` + +Share folder with group: + +```json +{ + "action": "add", + "token": "fldcnXXX", + "type": "folder", + "member_type": "openchat", + "member_id": "oc_xxx", + "perm": "view" +} +``` + +## Configuration + +```yaml +channels: + feishu: + tools: + perm: true # default: false (disabled) +``` + +**Note:** This tool is disabled by default because permission management is a sensitive operation. Enable explicitly if needed. + +## Permissions + +Required: `drive:permission` diff --git a/extensions/feishu/skills/feishu-wiki/SKILL.md b/extensions/feishu/skills/feishu-wiki/SKILL.md new file mode 100644 index 0000000000..6ffb8a561a --- /dev/null +++ b/extensions/feishu/skills/feishu-wiki/SKILL.md @@ -0,0 +1,111 @@ +--- +name: feishu-wiki +description: | + Feishu knowledge base navigation. Activate when user mentions knowledge base, wiki, or wiki links. +--- + +# Feishu Wiki Tool + +Single tool `feishu_wiki` for knowledge base operations. + +## Token Extraction + +From URL `https://xxx.feishu.cn/wiki/ABC123def` → `token` = `ABC123def` + +## Actions + +### List Knowledge Spaces + +```json +{ "action": "spaces" } +``` + +Returns all accessible wiki spaces. + +### List Nodes + +```json +{ "action": "nodes", "space_id": "7xxx" } +``` + +With parent: + +```json +{ "action": "nodes", "space_id": "7xxx", "parent_node_token": "wikcnXXX" } +``` + +### Get Node Details + +```json +{ "action": "get", "token": "ABC123def" } +``` + +Returns: `node_token`, `obj_token`, `obj_type`, etc. Use `obj_token` with `feishu_doc` to read/write the document. + +### Create Node + +```json +{ "action": "create", "space_id": "7xxx", "title": "New Page" } +``` + +With type and parent: + +```json +{ + "action": "create", + "space_id": "7xxx", + "title": "Sheet", + "obj_type": "sheet", + "parent_node_token": "wikcnXXX" +} +``` + +`obj_type`: `docx` (default), `sheet`, `bitable`, `mindnote`, `file`, `doc`, `slides` + +### Move Node + +```json +{ "action": "move", "space_id": "7xxx", "node_token": "wikcnXXX" } +``` + +To different location: + +```json +{ + "action": "move", + "space_id": "7xxx", + "node_token": "wikcnXXX", + "target_space_id": "7yyy", + "target_parent_token": "wikcnYYY" +} +``` + +### Rename Node + +```json +{ "action": "rename", "space_id": "7xxx", "node_token": "wikcnXXX", "title": "New Title" } +``` + +## Wiki-Doc Workflow + +To edit a wiki page: + +1. Get node: `{ "action": "get", "token": "wiki_token" }` → returns `obj_token` +2. Read doc: `feishu_doc { "action": "read", "doc_token": "obj_token" }` +3. Write doc: `feishu_doc { "action": "write", "doc_token": "obj_token", "content": "..." }` + +## Configuration + +```yaml +channels: + feishu: + tools: + wiki: true # default: true + doc: true # required - wiki content uses feishu_doc +``` + +**Dependency:** This tool requires `feishu_doc` to be enabled. Wiki pages are documents - use `feishu_wiki` to navigate, then `feishu_doc` to read/edit content. + +## Permissions + +Required: `wiki:wiki` or `wiki:wiki:readonly` diff --git a/extensions/feishu/src/accounts.ts b/extensions/feishu/src/accounts.ts new file mode 100644 index 0000000000..2fbf8a285c --- /dev/null +++ b/extensions/feishu/src/accounts.ts @@ -0,0 +1,53 @@ +import type { ClawdbotConfig } from "openclaw/plugin-sdk"; +import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk"; +import type { FeishuConfig, FeishuDomain, ResolvedFeishuAccount } from "./types.js"; + +export function resolveFeishuCredentials(cfg?: FeishuConfig): { + appId: string; + appSecret: string; + encryptKey?: string; + verificationToken?: string; + domain: FeishuDomain; +} | null { + const appId = cfg?.appId?.trim(); + const appSecret = cfg?.appSecret?.trim(); + if (!appId || !appSecret) return null; + return { + appId, + appSecret, + encryptKey: cfg?.encryptKey?.trim() || undefined, + verificationToken: cfg?.verificationToken?.trim() || undefined, + domain: cfg?.domain ?? "feishu", + }; +} + +export function resolveFeishuAccount(params: { + cfg: ClawdbotConfig; + accountId?: string | null; +}): ResolvedFeishuAccount { + const feishuCfg = params.cfg.channels?.feishu as FeishuConfig | undefined; + const enabled = feishuCfg?.enabled !== false; + const creds = resolveFeishuCredentials(feishuCfg); + + return { + accountId: params.accountId?.trim() || DEFAULT_ACCOUNT_ID, + enabled, + configured: Boolean(creds), + appId: creds?.appId, + domain: creds?.domain ?? "feishu", + }; +} + +export function listFeishuAccountIds(_cfg: ClawdbotConfig): string[] { + return [DEFAULT_ACCOUNT_ID]; +} + +export function resolveDefaultFeishuAccountId(_cfg: ClawdbotConfig): string { + return DEFAULT_ACCOUNT_ID; +} + +export function listEnabledFeishuAccounts(cfg: ClawdbotConfig): ResolvedFeishuAccount[] { + return listFeishuAccountIds(cfg) + .map((accountId) => resolveFeishuAccount({ cfg, accountId })) + .filter((account) => account.enabled && account.configured); +} diff --git a/extensions/feishu/src/bitable.ts b/extensions/feishu/src/bitable.ts new file mode 100644 index 0000000000..696abac979 --- /dev/null +++ b/extensions/feishu/src/bitable.ts @@ -0,0 +1,443 @@ +import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; +import { Type } from "@sinclair/typebox"; +import type { FeishuConfig } from "./types.js"; +import { createFeishuClient } from "./client.js"; + +// ============ Helpers ============ + +function json(data: unknown) { + return { + content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }], + details: data, + }; +} + +/** Field type ID to human-readable name */ +const FIELD_TYPE_NAMES: Record = { + 1: "Text", + 2: "Number", + 3: "SingleSelect", + 4: "MultiSelect", + 5: "DateTime", + 7: "Checkbox", + 11: "User", + 13: "Phone", + 15: "URL", + 17: "Attachment", + 18: "SingleLink", + 19: "Lookup", + 20: "Formula", + 21: "DuplexLink", + 22: "Location", + 23: "GroupChat", + 1001: "CreatedTime", + 1002: "ModifiedTime", + 1003: "CreatedUser", + 1004: "ModifiedUser", + 1005: "AutoNumber", +}; + +// ============ Core Functions ============ + +/** Parse bitable URL and extract tokens */ +function parseBitableUrl(url: string): { token: string; tableId?: string; isWiki: boolean } | null { + try { + const u = new URL(url); + const tableId = u.searchParams.get("table") ?? undefined; + + // Wiki format: /wiki/XXXXX?table=YYY + const wikiMatch = u.pathname.match(/\/wiki\/([A-Za-z0-9]+)/); + if (wikiMatch) { + return { token: wikiMatch[1], tableId, isWiki: true }; + } + + // Base format: /base/XXXXX?table=YYY + const baseMatch = u.pathname.match(/\/base\/([A-Za-z0-9]+)/); + if (baseMatch) { + return { token: baseMatch[1], tableId, isWiki: false }; + } + + return null; + } catch { + return null; + } +} + +/** Get app_token from wiki node_token */ +async function getAppTokenFromWiki( + client: ReturnType, + nodeToken: string, +): Promise { + const res = await client.wiki.space.getNode({ + params: { token: nodeToken }, + }); + if (res.code !== 0) throw new Error(res.msg); + + const node = res.data?.node; + if (!node) throw new Error("Node not found"); + if (node.obj_type !== "bitable") { + throw new Error(`Node is not a bitable (type: ${node.obj_type})`); + } + + return node.obj_token!; +} + +/** Get bitable metadata from URL (handles both /base/ and /wiki/ URLs) */ +async function getBitableMeta(client: ReturnType, url: string) { + const parsed = parseBitableUrl(url); + if (!parsed) { + throw new Error("Invalid URL format. Expected /base/XXX or /wiki/XXX URL"); + } + + let appToken: string; + if (parsed.isWiki) { + appToken = await getAppTokenFromWiki(client, parsed.token); + } else { + appToken = parsed.token; + } + + // Get bitable app info + const res = await client.bitable.app.get({ + path: { app_token: appToken }, + }); + if (res.code !== 0) throw new Error(res.msg); + + // List tables if no table_id specified + let tables: { table_id: string; name: string }[] = []; + if (!parsed.tableId) { + const tablesRes = await client.bitable.appTable.list({ + path: { app_token: appToken }, + }); + if (tablesRes.code === 0) { + tables = (tablesRes.data?.items ?? []).map((t) => ({ + table_id: t.table_id!, + name: t.name!, + })); + } + } + + return { + app_token: appToken, + table_id: parsed.tableId, + name: res.data?.app?.name, + url_type: parsed.isWiki ? "wiki" : "base", + ...(tables.length > 0 && { tables }), + hint: parsed.tableId + ? `Use app_token="${appToken}" and table_id="${parsed.tableId}" for other bitable tools` + : `Use app_token="${appToken}" for other bitable tools. Select a table_id from the tables list.`, + }; +} + +async function listFields( + client: ReturnType, + appToken: string, + tableId: string, +) { + const res = await client.bitable.appTableField.list({ + path: { app_token: appToken, table_id: tableId }, + }); + if (res.code !== 0) throw new Error(res.msg); + + const fields = res.data?.items ?? []; + return { + fields: fields.map((f) => ({ + field_id: f.field_id, + field_name: f.field_name, + type: f.type, + type_name: FIELD_TYPE_NAMES[f.type ?? 0] || `type_${f.type}`, + is_primary: f.is_primary, + ...(f.property && { property: f.property }), + })), + total: fields.length, + }; +} + +async function listRecords( + client: ReturnType, + appToken: string, + tableId: string, + pageSize?: number, + pageToken?: string, +) { + const res = await client.bitable.appTableRecord.list({ + path: { app_token: appToken, table_id: tableId }, + params: { + page_size: pageSize ?? 100, + ...(pageToken && { page_token: pageToken }), + }, + }); + if (res.code !== 0) throw new Error(res.msg); + + return { + records: res.data?.items ?? [], + has_more: res.data?.has_more ?? false, + page_token: res.data?.page_token, + total: res.data?.total, + }; +} + +async function getRecord( + client: ReturnType, + appToken: string, + tableId: string, + recordId: string, +) { + const res = await client.bitable.appTableRecord.get({ + path: { app_token: appToken, table_id: tableId, record_id: recordId }, + }); + if (res.code !== 0) throw new Error(res.msg); + + return { + record: res.data?.record, + }; +} + +async function createRecord( + client: ReturnType, + appToken: string, + tableId: string, + fields: Record, +) { + const res = await client.bitable.appTableRecord.create({ + path: { app_token: appToken, table_id: tableId }, + data: { fields }, + }); + if (res.code !== 0) throw new Error(res.msg); + + return { + record: res.data?.record, + }; +} + +async function updateRecord( + client: ReturnType, + appToken: string, + tableId: string, + recordId: string, + fields: Record, +) { + const res = await client.bitable.appTableRecord.update({ + path: { app_token: appToken, table_id: tableId, record_id: recordId }, + data: { fields }, + }); + if (res.code !== 0) throw new Error(res.msg); + + return { + record: res.data?.record, + }; +} + +// ============ Schemas ============ + +const GetMetaSchema = Type.Object({ + url: Type.String({ + description: "Bitable URL. Supports both formats: /base/XXX?table=YYY or /wiki/XXX?table=YYY", + }), +}); + +const ListFieldsSchema = Type.Object({ + app_token: Type.String({ + description: "Bitable app token (use feishu_bitable_get_meta to get from URL)", + }), + table_id: Type.String({ description: "Table ID (from URL: ?table=YYY)" }), +}); + +const ListRecordsSchema = Type.Object({ + app_token: Type.String({ + description: "Bitable app token (use feishu_bitable_get_meta to get from URL)", + }), + table_id: Type.String({ description: "Table ID (from URL: ?table=YYY)" }), + page_size: Type.Optional( + Type.Number({ + description: "Number of records per page (1-500, default 100)", + minimum: 1, + maximum: 500, + }), + ), + page_token: Type.Optional( + Type.String({ description: "Pagination token from previous response" }), + ), +}); + +const GetRecordSchema = Type.Object({ + app_token: Type.String({ + description: "Bitable app token (use feishu_bitable_get_meta to get from URL)", + }), + table_id: Type.String({ description: "Table ID (from URL: ?table=YYY)" }), + record_id: Type.String({ description: "Record ID to retrieve" }), +}); + +const CreateRecordSchema = Type.Object({ + app_token: Type.String({ + description: "Bitable app token (use feishu_bitable_get_meta to get from URL)", + }), + table_id: Type.String({ description: "Table ID (from URL: ?table=YYY)" }), + fields: Type.Record(Type.String(), Type.Any(), { + description: + "Field values keyed by field name. Format by type: Text='string', Number=123, SingleSelect='Option', MultiSelect=['A','B'], DateTime=timestamp_ms, User=[{id:'ou_xxx'}], URL={text:'Display',link:'https://...'}", + }), +}); + +const UpdateRecordSchema = Type.Object({ + app_token: Type.String({ + description: "Bitable app token (use feishu_bitable_get_meta to get from URL)", + }), + table_id: Type.String({ description: "Table ID (from URL: ?table=YYY)" }), + record_id: Type.String({ description: "Record ID to update" }), + fields: Type.Record(Type.String(), Type.Any(), { + description: "Field values to update (same format as create_record)", + }), +}); + +// ============ Tool Registration ============ + +export function registerFeishuBitableTools(api: OpenClawPluginApi) { + const feishuCfg = api.config?.channels?.feishu as FeishuConfig | undefined; + if (!feishuCfg?.appId || !feishuCfg?.appSecret) { + api.logger.debug?.("feishu_bitable: Feishu credentials not configured, skipping bitable tools"); + return; + } + + const getClient = () => createFeishuClient(feishuCfg); + + // Tool 0: feishu_bitable_get_meta (helper to parse URLs) + api.registerTool( + { + name: "feishu_bitable_get_meta", + label: "Feishu Bitable Get Meta", + description: + "Parse a Bitable URL and get app_token, table_id, and table list. Use this first when given a /wiki/ or /base/ URL.", + parameters: GetMetaSchema, + async execute(_toolCallId, params) { + const { url } = params as { url: string }; + try { + const result = await getBitableMeta(getClient(), url); + return json(result); + } catch (err) { + return json({ error: err instanceof Error ? err.message : String(err) }); + } + }, + }, + { name: "feishu_bitable_get_meta" }, + ); + + // Tool 1: feishu_bitable_list_fields + api.registerTool( + { + name: "feishu_bitable_list_fields", + label: "Feishu Bitable List Fields", + description: "List all fields (columns) in a Bitable table with their types and properties", + parameters: ListFieldsSchema, + async execute(_toolCallId, params) { + const { app_token, table_id } = params as { app_token: string; table_id: string }; + try { + const result = await listFields(getClient(), app_token, table_id); + return json(result); + } catch (err) { + return json({ error: err instanceof Error ? err.message : String(err) }); + } + }, + }, + { name: "feishu_bitable_list_fields" }, + ); + + // Tool 2: feishu_bitable_list_records + api.registerTool( + { + name: "feishu_bitable_list_records", + label: "Feishu Bitable List Records", + description: "List records (rows) from a Bitable table with pagination support", + parameters: ListRecordsSchema, + async execute(_toolCallId, params) { + const { app_token, table_id, page_size, page_token } = params as { + app_token: string; + table_id: string; + page_size?: number; + page_token?: string; + }; + try { + const result = await listRecords(getClient(), app_token, table_id, page_size, page_token); + return json(result); + } catch (err) { + return json({ error: err instanceof Error ? err.message : String(err) }); + } + }, + }, + { name: "feishu_bitable_list_records" }, + ); + + // Tool 3: feishu_bitable_get_record + api.registerTool( + { + name: "feishu_bitable_get_record", + label: "Feishu Bitable Get Record", + description: "Get a single record by ID from a Bitable table", + parameters: GetRecordSchema, + async execute(_toolCallId, params) { + const { app_token, table_id, record_id } = params as { + app_token: string; + table_id: string; + record_id: string; + }; + try { + const result = await getRecord(getClient(), app_token, table_id, record_id); + return json(result); + } catch (err) { + return json({ error: err instanceof Error ? err.message : String(err) }); + } + }, + }, + { name: "feishu_bitable_get_record" }, + ); + + // Tool 4: feishu_bitable_create_record + api.registerTool( + { + name: "feishu_bitable_create_record", + label: "Feishu Bitable Create Record", + description: "Create a new record (row) in a Bitable table", + parameters: CreateRecordSchema, + async execute(_toolCallId, params) { + const { app_token, table_id, fields } = params as { + app_token: string; + table_id: string; + fields: Record; + }; + try { + const result = await createRecord(getClient(), app_token, table_id, fields); + return json(result); + } catch (err) { + return json({ error: err instanceof Error ? err.message : String(err) }); + } + }, + }, + { name: "feishu_bitable_create_record" }, + ); + + // Tool 5: feishu_bitable_update_record + api.registerTool( + { + name: "feishu_bitable_update_record", + label: "Feishu Bitable Update Record", + description: "Update an existing record (row) in a Bitable table", + parameters: UpdateRecordSchema, + async execute(_toolCallId, params) { + const { app_token, table_id, record_id, fields } = params as { + app_token: string; + table_id: string; + record_id: string; + fields: Record; + }; + try { + const result = await updateRecord(getClient(), app_token, table_id, record_id, fields); + return json(result); + } catch (err) { + return json({ error: err instanceof Error ? err.message : String(err) }); + } + }, + }, + { name: "feishu_bitable_update_record" }, + ); + + api.logger.info?.(`feishu_bitable: Registered 6 bitable tools`); +} diff --git a/extensions/feishu/src/bot.ts b/extensions/feishu/src/bot.ts new file mode 100644 index 0000000000..59e7cc99a4 --- /dev/null +++ b/extensions/feishu/src/bot.ts @@ -0,0 +1,823 @@ +import type { ClawdbotConfig, RuntimeEnv } from "openclaw/plugin-sdk"; +import { + buildPendingHistoryContextFromMap, + recordPendingHistoryEntryIfEnabled, + clearHistoryEntriesIfEnabled, + DEFAULT_GROUP_HISTORY_LIMIT, + type HistoryEntry, +} from "openclaw/plugin-sdk"; +import type { FeishuConfig, FeishuMessageContext, FeishuMediaInfo } from "./types.js"; +import { createFeishuClient } from "./client.js"; +import { downloadImageFeishu, downloadMessageResourceFeishu } from "./media.js"; +import { extractMentionTargets, extractMessageBody, isMentionForwardRequest } from "./mention.js"; +import { + resolveFeishuGroupConfig, + resolveFeishuReplyPolicy, + resolveFeishuAllowlistMatch, + isFeishuGroupAllowed, +} from "./policy.js"; +import { createFeishuReplyDispatcher } from "./reply-dispatcher.js"; +import { getFeishuRuntime } from "./runtime.js"; +import { getMessageFeishu } from "./send.js"; + +// --- Permission error extraction --- +// Extract permission grant URL from Feishu API error response. +type PermissionError = { + code: number; + message: string; + grantUrl?: string; +}; + +function extractPermissionError(err: unknown): PermissionError | null { + if (!err || typeof err !== "object") return null; + + // Axios error structure: err.response.data contains the Feishu error + const axiosErr = err as { response?: { data?: unknown } }; + const data = axiosErr.response?.data; + if (!data || typeof data !== "object") return null; + + const feishuErr = data as { + code?: number; + msg?: string; + error?: { permission_violations?: Array<{ uri?: string }> }; + }; + + // Feishu permission error code: 99991672 + if (feishuErr.code !== 99991672) return null; + + // Extract the grant URL from the error message (contains the direct link) + const msg = feishuErr.msg ?? ""; + const urlMatch = msg.match(/https:\/\/[^\s,]+\/app\/[^\s,]+/); + const grantUrl = urlMatch?.[0]; + + return { + code: feishuErr.code, + message: msg, + grantUrl, + }; +} + +// --- Sender name resolution (so the agent can distinguish who is speaking in group chats) --- +// Cache display names by open_id to avoid an API call on every message. +const SENDER_NAME_TTL_MS = 10 * 60 * 1000; +const senderNameCache = new Map(); + +// Cache permission errors to avoid spamming the user with repeated notifications. +// Key: appId or "default", Value: timestamp of last notification +const permissionErrorNotifiedAt = new Map(); +const PERMISSION_ERROR_COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes + +type SenderNameResult = { + name?: string; + permissionError?: PermissionError; +}; + +async function resolveFeishuSenderName(params: { + feishuCfg?: FeishuConfig; + senderOpenId: string; + log: (...args: any[]) => void; +}): Promise { + const { feishuCfg, senderOpenId, log } = params; + if (!feishuCfg) return {}; + if (!senderOpenId) return {}; + + const cached = senderNameCache.get(senderOpenId); + const now = Date.now(); + if (cached && cached.expireAt > now) return { name: cached.name }; + + try { + const client = createFeishuClient(feishuCfg); + + // contact/v3/users/:user_id?user_id_type=open_id + const res: any = await client.contact.user.get({ + path: { user_id: senderOpenId }, + params: { user_id_type: "open_id" }, + }); + + const name: string | undefined = + res?.data?.user?.name || + res?.data?.user?.display_name || + res?.data?.user?.nickname || + res?.data?.user?.en_name; + + if (name && typeof name === "string") { + senderNameCache.set(senderOpenId, { name, expireAt: now + SENDER_NAME_TTL_MS }); + return { name }; + } + + return {}; + } catch (err) { + // Check if this is a permission error + const permErr = extractPermissionError(err); + if (permErr) { + log(`feishu: permission error resolving sender name: code=${permErr.code}`); + return { permissionError: permErr }; + } + + // Best-effort. Don't fail message handling if name lookup fails. + log(`feishu: failed to resolve sender name for ${senderOpenId}: ${String(err)}`); + return {}; + } +} + +export type FeishuMessageEvent = { + sender: { + sender_id: { + open_id?: string; + user_id?: string; + union_id?: string; + }; + sender_type?: string; + tenant_key?: string; + }; + message: { + message_id: string; + root_id?: string; + parent_id?: string; + chat_id: string; + chat_type: "p2p" | "group"; + message_type: string; + content: string; + mentions?: Array<{ + key: string; + id: { + open_id?: string; + user_id?: string; + union_id?: string; + }; + name: string; + tenant_key?: string; + }>; + }; +}; + +export type FeishuBotAddedEvent = { + chat_id: string; + operator_id: { + open_id?: string; + user_id?: string; + union_id?: string; + }; + external: boolean; + operator_tenant_key?: string; +}; + +function parseMessageContent(content: string, messageType: string): string { + try { + const parsed = JSON.parse(content); + if (messageType === "text") { + return parsed.text || ""; + } + if (messageType === "post") { + // Extract text content from rich text post + const { textContent } = parsePostContent(content); + return textContent; + } + return content; + } catch { + return content; + } +} + +function checkBotMentioned(event: FeishuMessageEvent, botOpenId?: string): boolean { + const mentions = event.message.mentions ?? []; + if (mentions.length === 0) return false; + if (!botOpenId) return mentions.length > 0; + return mentions.some((m) => m.id.open_id === botOpenId); +} + +function stripBotMention( + text: string, + mentions?: FeishuMessageEvent["message"]["mentions"], +): string { + if (!mentions || mentions.length === 0) return text; + let result = text; + for (const mention of mentions) { + result = result.replace(new RegExp(`@${mention.name}\\s*`, "g"), "").trim(); + result = result.replace(new RegExp(mention.key, "g"), "").trim(); + } + return result; +} + +/** + * Parse media keys from message content based on message type. + */ +function parseMediaKeys( + content: string, + messageType: string, +): { + imageKey?: string; + fileKey?: string; + fileName?: string; +} { + try { + const parsed = JSON.parse(content); + switch (messageType) { + case "image": + return { imageKey: parsed.image_key }; + case "file": + return { fileKey: parsed.file_key, fileName: parsed.file_name }; + case "audio": + return { fileKey: parsed.file_key }; + case "video": + // Video has both file_key (video) and image_key (thumbnail) + return { fileKey: parsed.file_key, imageKey: parsed.image_key }; + case "sticker": + return { fileKey: parsed.file_key }; + default: + return {}; + } + } catch { + return {}; + } +} + +/** + * Parse post (rich text) content and extract embedded image keys. + * Post structure: { title?: string, content: [[{ tag, text?, image_key?, ... }]] } + */ +function parsePostContent(content: string): { + textContent: string; + imageKeys: string[]; +} { + try { + const parsed = JSON.parse(content); + const title = parsed.title || ""; + const contentBlocks = parsed.content || []; + let textContent = title ? `${title}\n\n` : ""; + const imageKeys: string[] = []; + + for (const paragraph of contentBlocks) { + if (Array.isArray(paragraph)) { + for (const element of paragraph) { + if (element.tag === "text") { + textContent += element.text || ""; + } else if (element.tag === "a") { + // Link: show text or href + textContent += element.text || element.href || ""; + } else if (element.tag === "at") { + // Mention: @username + textContent += `@${element.user_name || element.user_id || ""}`; + } else if (element.tag === "img" && element.image_key) { + // Embedded image + imageKeys.push(element.image_key); + } + } + textContent += "\n"; + } + } + + return { + textContent: textContent.trim() || "[富文本消息]", + imageKeys, + }; + } catch { + return { textContent: "[富文本消息]", imageKeys: [] }; + } +} + +/** + * Infer placeholder text based on message type. + */ +function inferPlaceholder(messageType: string): string { + switch (messageType) { + case "image": + return ""; + case "file": + return ""; + case "audio": + return ""; + case "video": + return ""; + case "sticker": + return ""; + default: + return ""; + } +} + +/** + * Resolve media from a Feishu message, downloading and saving to disk. + * Similar to Discord's resolveMediaList(). + */ +async function resolveFeishuMediaList(params: { + cfg: ClawdbotConfig; + messageId: string; + messageType: string; + content: string; + maxBytes: number; + log?: (msg: string) => void; +}): Promise { + const { cfg, messageId, messageType, content, maxBytes, log } = params; + + // Only process media message types (including post for embedded images) + const mediaTypes = ["image", "file", "audio", "video", "sticker", "post"]; + if (!mediaTypes.includes(messageType)) { + return []; + } + + const out: FeishuMediaInfo[] = []; + const core = getFeishuRuntime(); + + // Handle post (rich text) messages with embedded images + if (messageType === "post") { + const { imageKeys } = parsePostContent(content); + if (imageKeys.length === 0) { + return []; + } + + log?.(`feishu: post message contains ${imageKeys.length} embedded image(s)`); + + for (const imageKey of imageKeys) { + try { + // Embedded images in post use messageResource API with image_key as file_key + const result = await downloadMessageResourceFeishu({ + cfg, + messageId, + fileKey: imageKey, + type: "image", + }); + + let contentType = result.contentType; + if (!contentType) { + contentType = await core.media.detectMime({ buffer: result.buffer }); + } + + const saved = await core.channel.media.saveMediaBuffer( + result.buffer, + contentType, + "inbound", + maxBytes, + ); + + out.push({ + path: saved.path, + contentType: saved.contentType, + placeholder: "", + }); + + log?.(`feishu: downloaded embedded image ${imageKey}, saved to ${saved.path}`); + } catch (err) { + log?.(`feishu: failed to download embedded image ${imageKey}: ${String(err)}`); + } + } + + return out; + } + + // Handle other media types + const mediaKeys = parseMediaKeys(content, messageType); + if (!mediaKeys.imageKey && !mediaKeys.fileKey) { + return []; + } + + try { + let buffer: Buffer; + let contentType: string | undefined; + let fileName: string | undefined; + + // For message media, always use messageResource API + // The image.get API is only for images uploaded via im/v1/images, not for message attachments + const fileKey = mediaKeys.imageKey || mediaKeys.fileKey; + if (!fileKey) { + return []; + } + + const resourceType = messageType === "image" ? "image" : "file"; + const result = await downloadMessageResourceFeishu({ + cfg, + messageId, + fileKey, + type: resourceType, + }); + buffer = result.buffer; + contentType = result.contentType; + fileName = result.fileName || mediaKeys.fileName; + + // Detect mime type if not provided + if (!contentType) { + contentType = await core.media.detectMime({ buffer }); + } + + // Save to disk using core's saveMediaBuffer + const saved = await core.channel.media.saveMediaBuffer( + buffer, + contentType, + "inbound", + maxBytes, + fileName, + ); + + out.push({ + path: saved.path, + contentType: saved.contentType, + placeholder: inferPlaceholder(messageType), + }); + + log?.(`feishu: downloaded ${messageType} media, saved to ${saved.path}`); + } catch (err) { + log?.(`feishu: failed to download ${messageType} media: ${String(err)}`); + } + + return out; +} + +/** + * Build media payload for inbound context. + * Similar to Discord's buildDiscordMediaPayload(). + */ +function buildFeishuMediaPayload(mediaList: FeishuMediaInfo[]): { + MediaPath?: string; + MediaType?: string; + MediaUrl?: string; + MediaPaths?: string[]; + MediaUrls?: string[]; + MediaTypes?: string[]; +} { + const first = mediaList[0]; + const mediaPaths = mediaList.map((media) => media.path); + const mediaTypes = mediaList.map((media) => media.contentType).filter(Boolean) as string[]; + return { + MediaPath: first?.path, + MediaType: first?.contentType, + MediaUrl: first?.path, + MediaPaths: mediaPaths.length > 0 ? mediaPaths : undefined, + MediaUrls: mediaPaths.length > 0 ? mediaPaths : undefined, + MediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined, + }; +} + +export function parseFeishuMessageEvent( + event: FeishuMessageEvent, + botOpenId?: string, +): FeishuMessageContext { + const rawContent = parseMessageContent(event.message.content, event.message.message_type); + const mentionedBot = checkBotMentioned(event, botOpenId); + const content = stripBotMention(rawContent, event.message.mentions); + + const ctx: FeishuMessageContext = { + chatId: event.message.chat_id, + messageId: event.message.message_id, + senderId: event.sender.sender_id.user_id || event.sender.sender_id.open_id || "", + senderOpenId: event.sender.sender_id.open_id || "", + chatType: event.message.chat_type, + mentionedBot, + rootId: event.message.root_id || undefined, + parentId: event.message.parent_id || undefined, + content, + contentType: event.message.message_type, + }; + + // Detect mention forward request: message mentions bot + at least one other user + if (isMentionForwardRequest(event, botOpenId)) { + const mentionTargets = extractMentionTargets(event, botOpenId); + if (mentionTargets.length > 0) { + ctx.mentionTargets = mentionTargets; + // Extract message body (remove all @ placeholders) + const allMentionKeys = (event.message.mentions ?? []).map((m) => m.key); + ctx.mentionMessageBody = extractMessageBody(content, allMentionKeys); + } + } + + return ctx; +} + +export async function handleFeishuMessage(params: { + cfg: ClawdbotConfig; + event: FeishuMessageEvent; + botOpenId?: string; + runtime?: RuntimeEnv; + chatHistories?: Map; +}): Promise { + const { cfg, event, botOpenId, runtime, chatHistories } = params; + const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; + const log = runtime?.log ?? console.log; + const error = runtime?.error ?? console.error; + + let ctx = parseFeishuMessageEvent(event, botOpenId); + const isGroup = ctx.chatType === "group"; + + // Resolve sender display name (best-effort) so the agent can attribute messages correctly. + const senderResult = await resolveFeishuSenderName({ + feishuCfg, + senderOpenId: ctx.senderOpenId, + log, + }); + if (senderResult.name) ctx = { ...ctx, senderName: senderResult.name }; + + // Track permission error to inform agent later (with cooldown to avoid repetition) + let permissionErrorForAgent: PermissionError | undefined; + if (senderResult.permissionError) { + const appKey = feishuCfg?.appId ?? "default"; + const now = Date.now(); + const lastNotified = permissionErrorNotifiedAt.get(appKey) ?? 0; + + if (now - lastNotified > PERMISSION_ERROR_COOLDOWN_MS) { + permissionErrorNotifiedAt.set(appKey, now); + permissionErrorForAgent = senderResult.permissionError; + } + } + + log(`feishu: received message from ${ctx.senderOpenId} in ${ctx.chatId} (${ctx.chatType})`); + + // Log mention targets if detected + if (ctx.mentionTargets && ctx.mentionTargets.length > 0) { + const names = ctx.mentionTargets.map((t) => t.name).join(", "); + log(`feishu: detected @ forward request, targets: [${names}]`); + } + + const historyLimit = Math.max( + 0, + feishuCfg?.historyLimit ?? cfg.messages?.groupChat?.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT, + ); + + if (isGroup) { + const groupPolicy = feishuCfg?.groupPolicy ?? "open"; + const groupAllowFrom = feishuCfg?.groupAllowFrom ?? []; + const groupConfig = resolveFeishuGroupConfig({ cfg: feishuCfg, groupId: ctx.chatId }); + + // Check if this GROUP is allowed (groupAllowFrom contains group IDs like oc_xxx, not user IDs) + const groupAllowed = isFeishuGroupAllowed({ + groupPolicy, + allowFrom: groupAllowFrom, + senderId: ctx.chatId, // Check group ID, not sender ID + senderName: undefined, + }); + + if (!groupAllowed) { + log(`feishu: group ${ctx.chatId} not in allowlist`); + return; + } + + // Additional sender-level allowlist check if group has specific allowFrom config + const senderAllowFrom = groupConfig?.allowFrom ?? []; + if (senderAllowFrom.length > 0) { + const senderAllowed = isFeishuGroupAllowed({ + groupPolicy: "allowlist", + allowFrom: senderAllowFrom, + senderId: ctx.senderOpenId, + senderName: ctx.senderName, + }); + if (!senderAllowed) { + log(`feishu: sender ${ctx.senderOpenId} not in group ${ctx.chatId} sender allowlist`); + return; + } + } + + const { requireMention } = resolveFeishuReplyPolicy({ + isDirectMessage: false, + globalConfig: feishuCfg, + groupConfig, + }); + + if (requireMention && !ctx.mentionedBot) { + log(`feishu: message in group ${ctx.chatId} did not mention bot, recording to history`); + if (chatHistories) { + recordPendingHistoryEntryIfEnabled({ + historyMap: chatHistories, + historyKey: ctx.chatId, + limit: historyLimit, + entry: { + sender: ctx.senderOpenId, + body: `${ctx.senderName ?? ctx.senderOpenId}: ${ctx.content}`, + timestamp: Date.now(), + messageId: ctx.messageId, + }, + }); + } + return; + } + } else { + const dmPolicy = feishuCfg?.dmPolicy ?? "pairing"; + const allowFrom = feishuCfg?.allowFrom ?? []; + + if (dmPolicy === "allowlist") { + const match = resolveFeishuAllowlistMatch({ + allowFrom, + senderId: ctx.senderOpenId, + }); + if (!match.allowed) { + log(`feishu: sender ${ctx.senderOpenId} not in DM allowlist`); + return; + } + } + } + + try { + const core = getFeishuRuntime(); + + // In group chats, the session is scoped to the group, but the *speaker* is the sender. + // Using a group-scoped From causes the agent to treat different users as the same person. + const feishuFrom = `feishu:${ctx.senderOpenId}`; + const feishuTo = isGroup ? `chat:${ctx.chatId}` : `user:${ctx.senderOpenId}`; + + const route = core.channel.routing.resolveAgentRoute({ + cfg, + channel: "feishu", + peer: { + kind: isGroup ? "group" : "dm", + id: isGroup ? ctx.chatId : ctx.senderOpenId, + }, + }); + + const preview = ctx.content.replace(/\s+/g, " ").slice(0, 160); + const inboundLabel = isGroup + ? `Feishu message in group ${ctx.chatId}` + : `Feishu DM from ${ctx.senderOpenId}`; + + core.system.enqueueSystemEvent(`${inboundLabel}: ${preview}`, { + sessionKey: route.sessionKey, + contextKey: `feishu:message:${ctx.chatId}:${ctx.messageId}`, + }); + + // Resolve media from message + const mediaMaxBytes = (feishuCfg?.mediaMaxMb ?? 30) * 1024 * 1024; // 30MB default + const mediaList = await resolveFeishuMediaList({ + cfg, + messageId: ctx.messageId, + messageType: event.message.message_type, + content: event.message.content, + maxBytes: mediaMaxBytes, + log, + }); + const mediaPayload = buildFeishuMediaPayload(mediaList); + + // Fetch quoted/replied message content if parentId exists + let quotedContent: string | undefined; + if (ctx.parentId) { + try { + const quotedMsg = await getMessageFeishu({ cfg, messageId: ctx.parentId }); + if (quotedMsg) { + quotedContent = quotedMsg.content; + log(`feishu: fetched quoted message: ${quotedContent?.slice(0, 100)}`); + } + } catch (err) { + log(`feishu: failed to fetch quoted message: ${String(err)}`); + } + } + + const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg); + + // Build message body with quoted content if available + let messageBody = ctx.content; + if (quotedContent) { + messageBody = `[Replying to: "${quotedContent}"]\n\n${ctx.content}`; + } + + // Include a readable speaker label so the model can attribute instructions. + // (DMs already have per-sender sessions, but the prefix is still useful for clarity.) + const speaker = ctx.senderName ?? ctx.senderOpenId; + messageBody = `${speaker}: ${messageBody}`; + + // If there are mention targets, inform the agent that replies will auto-mention them + if (ctx.mentionTargets && ctx.mentionTargets.length > 0) { + const targetNames = ctx.mentionTargets.map((t) => t.name).join(", "); + messageBody += `\n\n[System: Your reply will automatically @mention: ${targetNames}. Do not write @xxx yourself.]`; + } + + const envelopeFrom = isGroup ? `${ctx.chatId}:${ctx.senderOpenId}` : ctx.senderOpenId; + + // If there's a permission error, dispatch a separate notification first + if (permissionErrorForAgent) { + const grantUrl = permissionErrorForAgent.grantUrl ?? ""; + const permissionNotifyBody = `[System: The bot encountered a Feishu API permission error. Please inform the user about this issue and provide the permission grant URL for the admin to authorize. Permission grant URL: ${grantUrl}]`; + + const permissionBody = core.channel.reply.formatAgentEnvelope({ + channel: "Feishu", + from: envelopeFrom, + timestamp: new Date(), + envelope: envelopeOptions, + body: permissionNotifyBody, + }); + + const permissionCtx = core.channel.reply.finalizeInboundContext({ + Body: permissionBody, + RawBody: permissionNotifyBody, + CommandBody: permissionNotifyBody, + From: feishuFrom, + To: feishuTo, + SessionKey: route.sessionKey, + AccountId: route.accountId, + ChatType: isGroup ? "group" : "direct", + GroupSubject: isGroup ? ctx.chatId : undefined, + SenderName: "system", + SenderId: "system", + Provider: "feishu" as const, + Surface: "feishu" as const, + MessageSid: `${ctx.messageId}:permission-error`, + Timestamp: Date.now(), + WasMentioned: false, + CommandAuthorized: true, + OriginatingChannel: "feishu" as const, + OriginatingTo: feishuTo, + }); + + const { + dispatcher: permDispatcher, + replyOptions: permReplyOptions, + markDispatchIdle: markPermIdle, + } = createFeishuReplyDispatcher({ + cfg, + agentId: route.agentId, + runtime: runtime as RuntimeEnv, + chatId: ctx.chatId, + replyToMessageId: ctx.messageId, + }); + + log(`feishu: dispatching permission error notification to agent`); + + await core.channel.reply.dispatchReplyFromConfig({ + ctx: permissionCtx, + cfg, + dispatcher: permDispatcher, + replyOptions: permReplyOptions, + }); + + markPermIdle(); + } + + const body = core.channel.reply.formatAgentEnvelope({ + channel: "Feishu", + from: envelopeFrom, + timestamp: new Date(), + envelope: envelopeOptions, + body: messageBody, + }); + + let combinedBody = body; + const historyKey = isGroup ? ctx.chatId : undefined; + + if (isGroup && historyKey && chatHistories) { + combinedBody = buildPendingHistoryContextFromMap({ + historyMap: chatHistories, + historyKey, + limit: historyLimit, + currentMessage: combinedBody, + formatEntry: (entry) => + core.channel.reply.formatAgentEnvelope({ + channel: "Feishu", + // Preserve speaker identity in group history as well. + from: `${ctx.chatId}:${entry.sender}`, + timestamp: entry.timestamp, + body: entry.body, + envelope: envelopeOptions, + }), + }); + } + + const ctxPayload = core.channel.reply.finalizeInboundContext({ + Body: combinedBody, + RawBody: ctx.content, + CommandBody: ctx.content, + From: feishuFrom, + To: feishuTo, + SessionKey: route.sessionKey, + AccountId: route.accountId, + ChatType: isGroup ? "group" : "direct", + GroupSubject: isGroup ? ctx.chatId : undefined, + SenderName: ctx.senderName ?? ctx.senderOpenId, + SenderId: ctx.senderOpenId, + Provider: "feishu" as const, + Surface: "feishu" as const, + MessageSid: ctx.messageId, + Timestamp: Date.now(), + WasMentioned: ctx.mentionedBot, + CommandAuthorized: true, + OriginatingChannel: "feishu" as const, + OriginatingTo: feishuTo, + ...mediaPayload, + }); + + const { dispatcher, replyOptions, markDispatchIdle } = createFeishuReplyDispatcher({ + cfg, + agentId: route.agentId, + runtime: runtime as RuntimeEnv, + chatId: ctx.chatId, + replyToMessageId: ctx.messageId, + mentionTargets: ctx.mentionTargets, + }); + + log(`feishu: dispatching to agent (session=${route.sessionKey})`); + + const { queuedFinal, counts } = await core.channel.reply.dispatchReplyFromConfig({ + ctx: ctxPayload, + cfg, + dispatcher, + replyOptions, + }); + + markDispatchIdle(); + + if (isGroup && historyKey && chatHistories) { + clearHistoryEntriesIfEnabled({ + historyMap: chatHistories, + historyKey, + limit: historyLimit, + }); + } + + log(`feishu: dispatch complete (queuedFinal=${queuedFinal}, replies=${counts.final})`); + } catch (err) { + error(`feishu: failed to dispatch message: ${String(err)}`); + } +} diff --git a/extensions/feishu/src/channel.ts b/extensions/feishu/src/channel.ts index dff6e24fb2..a3076b615a 100644 --- a/extensions/feishu/src/channel.ts +++ b/extensions/feishu/src/channel.ts @@ -1,55 +1,45 @@ +import type { ChannelPlugin, ClawdbotConfig } from "openclaw/plugin-sdk"; +import { DEFAULT_ACCOUNT_ID, PAIRING_APPROVED_MESSAGE } from "openclaw/plugin-sdk"; +import type { ResolvedFeishuAccount, FeishuConfig } from "./types.js"; +import { resolveFeishuAccount, resolveFeishuCredentials } from "./accounts.js"; import { - buildChannelConfigSchema, - DEFAULT_ACCOUNT_ID, - deleteAccountFromConfigSection, - feishuOutbound, - formatPairingApproveHint, - listFeishuAccountIds, - monitorFeishuProvider, - normalizeFeishuTarget, - PAIRING_APPROVED_MESSAGE, - probeFeishu, - resolveDefaultFeishuAccountId, - resolveFeishuAccount, - resolveFeishuConfig, - resolveFeishuGroupRequireMention, - setAccountEnabledInConfigSection, - type ChannelAccountSnapshot, - type ChannelPlugin, - type ChannelStatusIssue, - type ResolvedFeishuAccount, -} from "openclaw/plugin-sdk"; -import { FeishuConfigSchema } from "./config-schema.js"; + listFeishuDirectoryPeers, + listFeishuDirectoryGroups, + listFeishuDirectoryPeersLive, + listFeishuDirectoryGroupsLive, +} from "./directory.js"; import { feishuOnboardingAdapter } from "./onboarding.js"; +import { feishuOutbound } from "./outbound.js"; +import { resolveFeishuGroupToolPolicy } from "./policy.js"; +import { probeFeishu } from "./probe.js"; +import { sendMessageFeishu } from "./send.js"; +import { normalizeFeishuTarget, looksLikeFeishuId } from "./targets.js"; const meta = { id: "feishu", label: "Feishu", - selectionLabel: "Feishu (Lark Open Platform)", - detailLabel: "Feishu Bot", + selectionLabel: "Feishu/Lark (飞书)", docsPath: "/channels/feishu", docsLabel: "feishu", - blurb: "Feishu/Lark bot via WebSocket.", + blurb: "飞书/Lark enterprise messaging.", aliases: ["lark"], - order: 35, - quickstartAllowFrom: true, -}; - -const normalizeAllowEntry = (entry: string) => entry.replace(/^(feishu|lark):/i, "").trim(); + order: 70, +} as const; export const feishuPlugin: ChannelPlugin = { id: "feishu", - meta, - onboarding: feishuOnboardingAdapter, + meta: { + ...meta, + }, pairing: { - idLabel: "feishuOpenId", - normalizeAllowEntry: normalizeAllowEntry, + idLabel: "feishuUserId", + normalizeAllowEntry: (entry) => entry.replace(/^(feishu|user|open_id):/i, ""), notifyApproval: async ({ cfg, id }) => { - const account = resolveFeishuAccount({ cfg }); - if (!account.config.appId || !account.config.appSecret) { - throw new Error("Feishu app credentials not configured"); - } - await feishuOutbound.sendText({ cfg, to: id, text: PAIRING_APPROVED_MESSAGE }); + await sendMessageFeishu({ + cfg, + to: id, + text: PAIRING_APPROVED_MESSAGE, + }); }, }, capabilities: { @@ -61,113 +51,136 @@ export const feishuPlugin: ChannelPlugin = { nativeCommands: true, blockStreaming: true, }, + agentPrompt: { + messageToolHints: () => [ + "- Feishu targeting: omit `target` to reply to the current conversation (auto-inferred). Explicit targets: `user:open_id` or `chat:chat_id`.", + "- Feishu supports interactive cards for rich messages.", + ], + }, + groups: { + resolveToolPolicy: resolveFeishuGroupToolPolicy, + }, reload: { configPrefixes: ["channels.feishu"] }, - outbound: feishuOutbound, - messaging: { - normalizeTarget: normalizeFeishuTarget, - targetResolver: { - looksLikeId: (raw, normalized) => { - const value = (normalized ?? raw).trim(); - if (!value) { - return false; - } - return /^o[cun]_[a-zA-Z0-9]+$/.test(value) || /^(user|group|chat):/i.test(value); + configSchema: { + schema: { + type: "object", + additionalProperties: false, + properties: { + enabled: { type: "boolean" }, + appId: { type: "string" }, + appSecret: { type: "string" }, + encryptKey: { type: "string" }, + verificationToken: { type: "string" }, + domain: { + oneOf: [ + { type: "string", enum: ["feishu", "lark"] }, + { type: "string", format: "uri", pattern: "^https://" }, + ], + }, + connectionMode: { type: "string", enum: ["websocket", "webhook"] }, + webhookPath: { type: "string" }, + webhookPort: { type: "integer", minimum: 1 }, + dmPolicy: { type: "string", enum: ["open", "pairing", "allowlist"] }, + allowFrom: { type: "array", items: { oneOf: [{ type: "string" }, { type: "number" }] } }, + groupPolicy: { type: "string", enum: ["open", "allowlist", "disabled"] }, + groupAllowFrom: { + type: "array", + items: { oneOf: [{ type: "string" }, { type: "number" }] }, + }, + requireMention: { type: "boolean" }, + historyLimit: { type: "integer", minimum: 0 }, + dmHistoryLimit: { type: "integer", minimum: 0 }, + textChunkLimit: { type: "integer", minimum: 1 }, + chunkMode: { type: "string", enum: ["length", "newline"] }, + mediaMaxMb: { type: "number", minimum: 0 }, + renderMode: { type: "string", enum: ["auto", "raw", "card"] }, }, - hint: "", }, }, - configSchema: buildChannelConfigSchema(FeishuConfigSchema), config: { - listAccountIds: (cfg) => listFeishuAccountIds(cfg), - resolveAccount: (cfg, accountId) => resolveFeishuAccount({ cfg, accountId }), - defaultAccountId: (cfg) => resolveDefaultFeishuAccountId(cfg), - setAccountEnabled: ({ cfg, accountId, enabled }) => - setAccountEnabledInConfigSection({ - cfg, - sectionKey: "feishu", - accountId, - enabled, - allowTopLevel: true, - }), - deleteAccount: ({ cfg, accountId }) => - deleteAccountFromConfigSection({ - cfg, - sectionKey: "feishu", - accountId, - clearBaseFields: ["appId", "appSecret", "appSecretFile", "name", "botName"], - }), - isConfigured: (account) => account.tokenSource !== "none", - describeAccount: (account): ChannelAccountSnapshot => ({ - accountId: account.accountId, - name: account.name, - enabled: account.enabled, - configured: account.tokenSource !== "none", - tokenSource: account.tokenSource, + listAccountIds: () => [DEFAULT_ACCOUNT_ID], + resolveAccount: (cfg) => resolveFeishuAccount({ cfg }), + defaultAccountId: () => DEFAULT_ACCOUNT_ID, + setAccountEnabled: ({ cfg, enabled }) => ({ + ...cfg, + channels: { + ...cfg.channels, + feishu: { + ...cfg.channels?.feishu, + enabled, + }, + }, }), - resolveAllowFrom: ({ cfg, accountId }) => - resolveFeishuConfig({ cfg, accountId: accountId ?? undefined }).allowFrom.map((entry) => - String(entry), - ), + deleteAccount: ({ cfg }) => { + const next = { ...cfg } as ClawdbotConfig; + const nextChannels = { ...cfg.channels }; + delete (nextChannels as Record).feishu; + if (Object.keys(nextChannels).length > 0) { + next.channels = nextChannels; + } else { + delete next.channels; + } + return next; + }, + isConfigured: (_account, cfg) => + Boolean(resolveFeishuCredentials(cfg.channels?.feishu as FeishuConfig | undefined)), + describeAccount: (account) => ({ + accountId: account.accountId, + enabled: account.enabled, + configured: account.configured, + }), + resolveAllowFrom: ({ cfg }) => + (cfg.channels?.feishu as FeishuConfig | undefined)?.allowFrom ?? [], formatAllowFrom: ({ allowFrom }) => allowFrom .map((entry) => String(entry).trim()) .filter(Boolean) - .map((entry) => (entry === "*" ? entry : normalizeAllowEntry(entry))) - .map((entry) => (entry === "*" ? entry : entry.toLowerCase())), + .map((entry) => entry.toLowerCase()), }, security: { - resolveDmPolicy: ({ cfg, accountId, account }) => { - const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID; - const useAccountPath = Boolean(cfg.channels?.feishu?.accounts?.[resolvedAccountId]); - const basePath = useAccountPath - ? `channels.feishu.accounts.${resolvedAccountId}.` - : "channels.feishu."; - return { - policy: account.config.dmPolicy ?? "pairing", - allowFrom: account.config.allowFrom ?? [], - policyPath: `${basePath}dmPolicy`, - allowFromPath: basePath, - approveHint: formatPairingApproveHint("feishu"), - normalizeEntry: normalizeAllowEntry, - }; + collectWarnings: ({ cfg }) => { + const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; + const defaultGroupPolicy = ( + cfg.channels as Record | undefined + )?.defaults?.groupPolicy; + const groupPolicy = feishuCfg?.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + if (groupPolicy !== "open") return []; + return [ + `- Feishu groups: groupPolicy="open" allows any member to trigger (mention-gated). Set channels.feishu.groupPolicy="allowlist" + channels.feishu.groupAllowFrom to restrict senders.`, + ]; }, }, - groups: { - resolveRequireMention: ({ cfg, accountId, groupId }) => { - if (!groupId) { - return true; - } - return resolveFeishuGroupRequireMention({ - cfg, - accountId: accountId ?? undefined, - chatId: groupId, - }); + setup: { + resolveAccountId: () => DEFAULT_ACCOUNT_ID, + applyAccountConfig: ({ cfg }) => ({ + ...cfg, + channels: { + ...cfg.channels, + feishu: { + ...cfg.channels?.feishu, + enabled: true, + }, + }, + }), + }, + onboarding: feishuOnboardingAdapter, + messaging: { + normalizeTarget: normalizeFeishuTarget, + targetResolver: { + looksLikeId: looksLikeFeishuId, + hint: "", }, }, directory: { self: async () => null, - listPeers: async ({ cfg, accountId, query, limit }) => { - const resolved = resolveFeishuConfig({ cfg, accountId: accountId ?? undefined }); - const normalizedQuery = query?.trim().toLowerCase() ?? ""; - const peers = resolved.allowFrom - .map((entry) => String(entry).trim()) - .filter((entry) => Boolean(entry) && entry !== "*") - .map((entry) => normalizeAllowEntry(entry)) - .filter((entry) => (normalizedQuery ? entry.toLowerCase().includes(normalizedQuery) : true)) - .slice(0, limit && limit > 0 ? limit : undefined) - .map((id) => ({ kind: "user", id }) as const); - return peers; - }, - listGroups: async ({ cfg, accountId, query, limit }) => { - const resolved = resolveFeishuConfig({ cfg, accountId: accountId ?? undefined }); - const normalizedQuery = query?.trim().toLowerCase() ?? ""; - const groups = Object.keys(resolved.groups ?? {}) - .filter((id) => (normalizedQuery ? id.toLowerCase().includes(normalizedQuery) : true)) - .slice(0, limit && limit > 0 ? limit : undefined) - .map((id) => ({ kind: "group", id }) as const); - return groups; - }, + listPeers: async ({ cfg, query, limit }) => listFeishuDirectoryPeers({ cfg, query, limit }), + listGroups: async ({ cfg, query, limit }) => listFeishuDirectoryGroups({ cfg, query, limit }), + listPeersLive: async ({ cfg, query, limit }) => + listFeishuDirectoryPeersLive({ cfg, query, limit }), + listGroupsLive: async ({ cfg, query, limit }) => + listFeishuDirectoryGroupsLive({ cfg, query, limit }), }, + outbound: feishuOutbound, status: { defaultRuntime: { accountId: DEFAULT_ACCOUNT_ID, @@ -175,102 +188,45 @@ export const feishuPlugin: ChannelPlugin = { lastStartAt: null, lastStopAt: null, lastError: null, + port: null, }, - collectStatusIssues: (accounts) => { - const issues: ChannelStatusIssue[] = []; - for (const account of accounts) { - if (!account.configured) { - issues.push({ - channel: "feishu", - accountId: account.accountId ?? DEFAULT_ACCOUNT_ID, - kind: "config", - message: "Feishu app ID/secret not configured", - }); - } - } - return issues; - }, - buildChannelSummary: async ({ snapshot }) => ({ + buildChannelSummary: ({ snapshot }) => ({ configured: snapshot.configured ?? false, - tokenSource: snapshot.tokenSource ?? "none", running: snapshot.running ?? false, lastStartAt: snapshot.lastStartAt ?? null, lastStopAt: snapshot.lastStopAt ?? null, lastError: snapshot.lastError ?? null, + port: snapshot.port ?? null, probe: snapshot.probe, lastProbeAt: snapshot.lastProbeAt ?? null, }), - probeAccount: async ({ account, timeoutMs }) => - probeFeishu(account.config.appId, account.config.appSecret, timeoutMs, account.config.domain), - buildAccountSnapshot: ({ account, runtime, probe }) => { - const configured = account.tokenSource !== "none"; - return { - accountId: account.accountId, - name: account.name, - enabled: account.enabled, - configured, - tokenSource: account.tokenSource, - running: runtime?.running ?? false, - lastStartAt: runtime?.lastStartAt ?? null, - lastStopAt: runtime?.lastStopAt ?? null, - lastError: runtime?.lastError ?? null, - probe, - lastInboundAt: runtime?.lastInboundAt ?? null, - lastOutboundAt: runtime?.lastOutboundAt ?? null, - }; - }, - logSelfId: ({ account, runtime }) => { - const appId = account.config.appId; - if (appId) { - runtime.log?.(`feishu:${appId}`); - } - }, + probeAccount: async ({ cfg }) => + await probeFeishu(cfg.channels?.feishu as FeishuConfig | undefined), + buildAccountSnapshot: ({ account, runtime, probe }) => ({ + accountId: account.accountId, + enabled: account.enabled, + configured: account.configured, + running: runtime?.running ?? false, + lastStartAt: runtime?.lastStartAt ?? null, + lastStopAt: runtime?.lastStopAt ?? null, + lastError: runtime?.lastError ?? null, + port: runtime?.port ?? null, + probe, + }), }, gateway: { startAccount: async (ctx) => { - const { account, log, setStatus, abortSignal, cfg, runtime } = ctx; - const { appId, appSecret, domain } = account.config; - if (!appId || !appSecret) { - throw new Error("Feishu app ID/secret not configured"); - } - - let feishuBotLabel = ""; - try { - const probe = await probeFeishu(appId, appSecret, 5000, domain); - if (probe.ok && probe.bot?.appName) { - feishuBotLabel = ` (${probe.bot.appName})`; - } - if (probe.ok && probe.bot) { - setStatus({ accountId: account.accountId, bot: probe.bot }); - } - } catch (err) { - log?.debug?.(`[${account.accountId}] bot probe failed: ${String(err)}`); - } - - log?.info(`[${account.accountId}] starting Feishu provider${feishuBotLabel}`); - setStatus({ - accountId: account.accountId, - running: true, - lastStartAt: Date.now(), + const { monitorFeishuProvider } = await import("./monitor.js"); + const feishuCfg = ctx.cfg.channels?.feishu as FeishuConfig | undefined; + const port = feishuCfg?.webhookPort ?? null; + ctx.setStatus({ accountId: ctx.accountId, port }); + ctx.log?.info(`starting feishu provider (mode: ${feishuCfg?.connectionMode ?? "websocket"})`); + return monitorFeishuProvider({ + config: ctx.cfg, + runtime: ctx.runtime, + abortSignal: ctx.abortSignal, + accountId: ctx.accountId, }); - - try { - await monitorFeishuProvider({ - appId, - appSecret, - accountId: account.accountId, - config: cfg, - runtime, - abortSignal, - }); - } catch (err) { - setStatus({ - accountId: account.accountId, - running: false, - lastError: err instanceof Error ? err.message : String(err), - }); - throw err; - } }, }, }; diff --git a/extensions/feishu/src/client.ts b/extensions/feishu/src/client.ts new file mode 100644 index 0000000000..458eba1852 --- /dev/null +++ b/extensions/feishu/src/client.ts @@ -0,0 +1,68 @@ +import * as Lark from "@larksuiteoapi/node-sdk"; +import type { FeishuConfig, FeishuDomain } from "./types.js"; +import { resolveFeishuCredentials } from "./accounts.js"; + +let cachedClient: Lark.Client | null = null; +let cachedConfig: { appId: string; appSecret: string; domain: FeishuDomain } | null = null; + +function resolveDomain(domain: FeishuDomain): Lark.Domain | string { + if (domain === "lark") return Lark.Domain.Lark; + if (domain === "feishu") return Lark.Domain.Feishu; + return domain.replace(/\/+$/, ""); // Custom URL, remove trailing slashes +} + +export function createFeishuClient(cfg: FeishuConfig): Lark.Client { + const creds = resolveFeishuCredentials(cfg); + if (!creds) { + throw new Error("Feishu credentials not configured (appId, appSecret required)"); + } + + if ( + cachedClient && + cachedConfig && + cachedConfig.appId === creds.appId && + cachedConfig.appSecret === creds.appSecret && + cachedConfig.domain === creds.domain + ) { + return cachedClient; + } + + const client = new Lark.Client({ + appId: creds.appId, + appSecret: creds.appSecret, + appType: Lark.AppType.SelfBuild, + domain: resolveDomain(creds.domain), + }); + + cachedClient = client; + cachedConfig = { appId: creds.appId, appSecret: creds.appSecret, domain: creds.domain }; + + return client; +} + +export function createFeishuWSClient(cfg: FeishuConfig): Lark.WSClient { + const creds = resolveFeishuCredentials(cfg); + if (!creds) { + throw new Error("Feishu credentials not configured (appId, appSecret required)"); + } + + return new Lark.WSClient({ + appId: creds.appId, + appSecret: creds.appSecret, + domain: resolveDomain(creds.domain), + loggerLevel: Lark.LoggerLevel.info, + }); +} + +export function createEventDispatcher(cfg: FeishuConfig): Lark.EventDispatcher { + const creds = resolveFeishuCredentials(cfg); + return new Lark.EventDispatcher({ + encryptKey: creds?.encryptKey, + verificationToken: creds?.verificationToken, + }); +} + +export function clearClientCache() { + cachedClient = null; + cachedConfig = null; +} diff --git a/extensions/feishu/src/config-schema.ts b/extensions/feishu/src/config-schema.ts index 68e1975805..a05a7163b2 100644 --- a/extensions/feishu/src/config-schema.ts +++ b/extensions/feishu/src/config-schema.ts @@ -1,47 +1,131 @@ -import { MarkdownConfigSchema, ToolPolicySchema } from "openclaw/plugin-sdk"; import { z } from "zod"; +export { z }; -const allowFromEntry = z.union([z.string(), z.number()]); -const toolsBySenderSchema = z.record(z.string(), ToolPolicySchema).optional(); +const DmPolicySchema = z.enum(["open", "pairing", "allowlist"]); +const GroupPolicySchema = z.enum(["open", "allowlist", "disabled"]); +const FeishuDomainSchema = z.union([ + z.enum(["feishu", "lark"]), + z.string().url().startsWith("https://"), +]); +const FeishuConnectionModeSchema = z.enum(["websocket", "webhook"]); -const FeishuGroupSchema = z +const ToolPolicySchema = z + .object({ + allow: z.array(z.string()).optional(), + deny: z.array(z.string()).optional(), + }) + .strict() + .optional(); + +const DmConfigSchema = z .object({ enabled: z.boolean().optional(), - requireMention: z.boolean().optional(), - allowFrom: z.array(allowFromEntry).optional(), - tools: ToolPolicySchema, - toolsBySender: toolsBySenderSchema, systemPrompt: z.string().optional(), + }) + .strict() + .optional(); + +const MarkdownConfigSchema = z + .object({ + mode: z.enum(["native", "escape", "strip"]).optional(), + tableMode: z.enum(["native", "ascii", "simple"]).optional(), + }) + .strict() + .optional(); + +// Message render mode: auto (default) = detect markdown, raw = plain text, card = always card +const RenderModeSchema = z.enum(["auto", "raw", "card"]).optional(); + +const BlockStreamingCoalesceSchema = z + .object({ + enabled: z.boolean().optional(), + minDelayMs: z.number().int().positive().optional(), + maxDelayMs: z.number().int().positive().optional(), + }) + .strict() + .optional(); + +const ChannelHeartbeatVisibilitySchema = z + .object({ + visibility: z.enum(["visible", "hidden"]).optional(), + intervalMs: z.number().int().positive().optional(), + }) + .strict() + .optional(); + +/** + * Feishu tools configuration. + * Controls which tool categories are enabled. + * + * Dependencies: + * - wiki requires doc (wiki content is edited via doc tools) + * - perm can work independently but is typically used with drive + */ +const FeishuToolsConfigSchema = z + .object({ + doc: z.boolean().optional(), // Document operations (default: true) + wiki: z.boolean().optional(), // Knowledge base operations (default: true, requires doc) + drive: z.boolean().optional(), // Cloud storage operations (default: true) + perm: z.boolean().optional(), // Permission management (default: false, sensitive) + scopes: z.boolean().optional(), // App scopes diagnostic (default: true) + }) + .strict() + .optional(); + +export const FeishuGroupSchema = z + .object({ + requireMention: z.boolean().optional(), + tools: ToolPolicySchema, skills: z.array(z.string()).optional(), + enabled: z.boolean().optional(), + allowFrom: z.array(z.union([z.string(), z.number()])).optional(), + systemPrompt: z.string().optional(), }) .strict(); -const FeishuAccountSchema = z +export const FeishuConfigSchema = z .object({ - name: z.string().optional(), enabled: z.boolean().optional(), appId: z.string().optional(), appSecret: z.string().optional(), - appSecretFile: z.string().optional(), - domain: z.string().optional(), - botName: z.string().optional(), + encryptKey: z.string().optional(), + verificationToken: z.string().optional(), + domain: FeishuDomainSchema.optional().default("feishu"), + connectionMode: FeishuConnectionModeSchema.optional().default("websocket"), + webhookPath: z.string().optional().default("/feishu/events"), + webhookPort: z.number().int().positive().optional(), + capabilities: z.array(z.string()).optional(), markdown: MarkdownConfigSchema, - dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(), - groupPolicy: z.enum(["open", "allowlist", "disabled"]).optional(), - allowFrom: z.array(allowFromEntry).optional(), - groupAllowFrom: z.array(allowFromEntry).optional(), - historyLimit: z.number().optional(), - dmHistoryLimit: z.number().optional(), - textChunkLimit: z.number().optional(), - chunkMode: z.enum(["length", "newline"]).optional(), - blockStreaming: z.boolean().optional(), - streaming: z.boolean().optional(), - mediaMaxMb: z.number().optional(), - responsePrefix: z.string().optional(), + configWrites: z.boolean().optional(), + dmPolicy: DmPolicySchema.optional().default("pairing"), + allowFrom: z.array(z.union([z.string(), z.number()])).optional(), + groupPolicy: GroupPolicySchema.optional().default("allowlist"), + groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(), + requireMention: z.boolean().optional().default(true), groups: z.record(z.string(), FeishuGroupSchema.optional()).optional(), + historyLimit: z.number().int().min(0).optional(), + dmHistoryLimit: z.number().int().min(0).optional(), + dms: z.record(z.string(), DmConfigSchema).optional(), + textChunkLimit: z.number().int().positive().optional(), + chunkMode: z.enum(["length", "newline"]).optional(), + blockStreamingCoalesce: BlockStreamingCoalesceSchema, + mediaMaxMb: z.number().positive().optional(), + heartbeat: ChannelHeartbeatVisibilitySchema, + renderMode: RenderModeSchema, // raw = plain text (default), card = interactive card with markdown + tools: FeishuToolsConfigSchema, }) - .strict(); - -export const FeishuConfigSchema = FeishuAccountSchema.extend({ - accounts: z.object({}).catchall(FeishuAccountSchema).optional(), -}); + .strict() + .superRefine((value, ctx) => { + if (value.dmPolicy === "open") { + const allowFrom = value.allowFrom ?? []; + const hasWildcard = allowFrom.some((entry) => String(entry).trim() === "*"); + if (!hasWildcard) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["allowFrom"], + message: + 'channels.feishu.dmPolicy="open" requires channels.feishu.allowFrom to include "*"', + }); + } + } + }); diff --git a/extensions/feishu/src/directory.ts b/extensions/feishu/src/directory.ts new file mode 100644 index 0000000000..77b61e4fe7 --- /dev/null +++ b/extensions/feishu/src/directory.ts @@ -0,0 +1,159 @@ +import type { ClawdbotConfig } from "openclaw/plugin-sdk"; +import type { FeishuConfig } from "./types.js"; +import { createFeishuClient } from "./client.js"; +import { normalizeFeishuTarget } from "./targets.js"; + +export type FeishuDirectoryPeer = { + kind: "user"; + id: string; + name?: string; +}; + +export type FeishuDirectoryGroup = { + kind: "group"; + id: string; + name?: string; +}; + +export async function listFeishuDirectoryPeers(params: { + cfg: ClawdbotConfig; + query?: string; + limit?: number; +}): Promise { + const feishuCfg = params.cfg.channels?.feishu as FeishuConfig | undefined; + const q = params.query?.trim().toLowerCase() || ""; + const ids = new Set(); + + for (const entry of feishuCfg?.allowFrom ?? []) { + const trimmed = String(entry).trim(); + if (trimmed && trimmed !== "*") ids.add(trimmed); + } + + for (const userId of Object.keys(feishuCfg?.dms ?? {})) { + const trimmed = userId.trim(); + if (trimmed) ids.add(trimmed); + } + + return Array.from(ids) + .map((raw) => raw.trim()) + .filter(Boolean) + .map((raw) => normalizeFeishuTarget(raw) ?? raw) + .filter((id) => (q ? id.toLowerCase().includes(q) : true)) + .slice(0, params.limit && params.limit > 0 ? params.limit : undefined) + .map((id) => ({ kind: "user" as const, id })); +} + +export async function listFeishuDirectoryGroups(params: { + cfg: ClawdbotConfig; + query?: string; + limit?: number; +}): Promise { + const feishuCfg = params.cfg.channels?.feishu as FeishuConfig | undefined; + const q = params.query?.trim().toLowerCase() || ""; + const ids = new Set(); + + for (const groupId of Object.keys(feishuCfg?.groups ?? {})) { + const trimmed = groupId.trim(); + if (trimmed && trimmed !== "*") ids.add(trimmed); + } + + for (const entry of feishuCfg?.groupAllowFrom ?? []) { + const trimmed = String(entry).trim(); + if (trimmed && trimmed !== "*") ids.add(trimmed); + } + + return Array.from(ids) + .map((raw) => raw.trim()) + .filter(Boolean) + .filter((id) => (q ? id.toLowerCase().includes(q) : true)) + .slice(0, params.limit && params.limit > 0 ? params.limit : undefined) + .map((id) => ({ kind: "group" as const, id })); +} + +export async function listFeishuDirectoryPeersLive(params: { + cfg: ClawdbotConfig; + query?: string; + limit?: number; +}): Promise { + const feishuCfg = params.cfg.channels?.feishu as FeishuConfig | undefined; + if (!feishuCfg?.appId || !feishuCfg?.appSecret) { + return listFeishuDirectoryPeers(params); + } + + try { + const client = createFeishuClient(feishuCfg); + const peers: FeishuDirectoryPeer[] = []; + const limit = params.limit ?? 50; + + const response = await client.contact.user.list({ + params: { + page_size: Math.min(limit, 50), + }, + }); + + if (response.code === 0 && response.data?.items) { + for (const user of response.data.items) { + if (user.open_id) { + const q = params.query?.trim().toLowerCase() || ""; + const name = user.name || ""; + if (!q || user.open_id.toLowerCase().includes(q) || name.toLowerCase().includes(q)) { + peers.push({ + kind: "user", + id: user.open_id, + name: name || undefined, + }); + } + } + if (peers.length >= limit) break; + } + } + + return peers; + } catch { + return listFeishuDirectoryPeers(params); + } +} + +export async function listFeishuDirectoryGroupsLive(params: { + cfg: ClawdbotConfig; + query?: string; + limit?: number; +}): Promise { + const feishuCfg = params.cfg.channels?.feishu as FeishuConfig | undefined; + if (!feishuCfg?.appId || !feishuCfg?.appSecret) { + return listFeishuDirectoryGroups(params); + } + + try { + const client = createFeishuClient(feishuCfg); + const groups: FeishuDirectoryGroup[] = []; + const limit = params.limit ?? 50; + + const response = await client.im.chat.list({ + params: { + page_size: Math.min(limit, 100), + }, + }); + + if (response.code === 0 && response.data?.items) { + for (const chat of response.data.items) { + if (chat.chat_id) { + const q = params.query?.trim().toLowerCase() || ""; + const name = chat.name || ""; + if (!q || chat.chat_id.toLowerCase().includes(q) || name.toLowerCase().includes(q)) { + groups.push({ + kind: "group", + id: chat.chat_id, + name: name || undefined, + }); + } + } + if (groups.length >= limit) break; + } + } + + return groups; + } catch { + return listFeishuDirectoryGroups(params); + } +} diff --git a/extensions/feishu/src/doc-schema.ts b/extensions/feishu/src/doc-schema.ts new file mode 100644 index 0000000000..811835f75f --- /dev/null +++ b/extensions/feishu/src/doc-schema.ts @@ -0,0 +1,47 @@ +import { Type, type Static } from "@sinclair/typebox"; + +export const FeishuDocSchema = Type.Union([ + Type.Object({ + action: Type.Literal("read"), + doc_token: Type.String({ description: "Document token (extract from URL /docx/XXX)" }), + }), + Type.Object({ + action: Type.Literal("write"), + doc_token: Type.String({ description: "Document token" }), + content: Type.String({ + description: "Markdown content to write (replaces entire document content)", + }), + }), + Type.Object({ + action: Type.Literal("append"), + doc_token: Type.String({ description: "Document token" }), + content: Type.String({ description: "Markdown content to append to end of document" }), + }), + Type.Object({ + action: Type.Literal("create"), + title: Type.String({ description: "Document title" }), + folder_token: Type.Optional(Type.String({ description: "Target folder token (optional)" })), + }), + Type.Object({ + action: Type.Literal("list_blocks"), + doc_token: Type.String({ description: "Document token" }), + }), + Type.Object({ + action: Type.Literal("get_block"), + doc_token: Type.String({ description: "Document token" }), + block_id: Type.String({ description: "Block ID (from list_blocks)" }), + }), + Type.Object({ + action: Type.Literal("update_block"), + doc_token: Type.String({ description: "Document token" }), + block_id: Type.String({ description: "Block ID (from list_blocks)" }), + content: Type.String({ description: "New text content" }), + }), + Type.Object({ + action: Type.Literal("delete_block"), + doc_token: Type.String({ description: "Document token" }), + block_id: Type.String({ description: "Block ID" }), + }), +]); + +export type FeishuDocParams = Static; diff --git a/extensions/feishu/src/docx.ts b/extensions/feishu/src/docx.ts new file mode 100644 index 0000000000..ce1a0aeb1b --- /dev/null +++ b/extensions/feishu/src/docx.ts @@ -0,0 +1,470 @@ +import type * as Lark from "@larksuiteoapi/node-sdk"; +import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; +import { Type } from "@sinclair/typebox"; +import { Readable } from "stream"; +import type { FeishuConfig } from "./types.js"; +import { createFeishuClient } from "./client.js"; +import { FeishuDocSchema, type FeishuDocParams } from "./doc-schema.js"; +import { resolveToolsConfig } from "./tools-config.js"; + +// ============ Helpers ============ + +function json(data: unknown) { + return { + content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }], + details: data, + }; +} + +/** Extract image URLs from markdown content */ +function extractImageUrls(markdown: string): string[] { + const regex = /!\[[^\]]*\]\(([^)]+)\)/g; + const urls: string[] = []; + let match; + while ((match = regex.exec(markdown)) !== null) { + const url = match[1].trim(); + if (url.startsWith("http://") || url.startsWith("https://")) { + urls.push(url); + } + } + return urls; +} + +const BLOCK_TYPE_NAMES: Record = { + 1: "Page", + 2: "Text", + 3: "Heading1", + 4: "Heading2", + 5: "Heading3", + 12: "Bullet", + 13: "Ordered", + 14: "Code", + 15: "Quote", + 17: "Todo", + 18: "Bitable", + 21: "Diagram", + 22: "Divider", + 23: "File", + 27: "Image", + 30: "Sheet", + 31: "Table", + 32: "TableCell", +}; + +// Block types that cannot be created via documentBlockChildren.create API +const UNSUPPORTED_CREATE_TYPES = new Set([31, 32]); + +/** Clean blocks for insertion (remove unsupported types and read-only fields) */ +function cleanBlocksForInsert(blocks: any[]): { cleaned: any[]; skipped: string[] } { + const skipped: string[] = []; + const cleaned = blocks + .filter((block) => { + if (UNSUPPORTED_CREATE_TYPES.has(block.block_type)) { + const typeName = BLOCK_TYPE_NAMES[block.block_type] || `type_${block.block_type}`; + skipped.push(typeName); + return false; + } + return true; + }) + .map((block) => { + if (block.block_type === 31 && block.table?.merge_info) { + const { merge_info, ...tableRest } = block.table; + return { ...block, table: tableRest }; + } + return block; + }); + return { cleaned, skipped }; +} + +// ============ Core Functions ============ + +async function convertMarkdown(client: Lark.Client, markdown: string) { + const res = await client.docx.document.convert({ + data: { content_type: "markdown", content: markdown }, + }); + if (res.code !== 0) throw new Error(res.msg); + return { + blocks: res.data?.blocks ?? [], + firstLevelBlockIds: res.data?.first_level_block_ids ?? [], + }; +} + +async function insertBlocks( + client: Lark.Client, + docToken: string, + blocks: any[], + parentBlockId?: string, +): Promise<{ children: any[]; skipped: string[] }> { + const { cleaned, skipped } = cleanBlocksForInsert(blocks); + const blockId = parentBlockId ?? docToken; + + if (cleaned.length === 0) { + return { children: [], skipped }; + } + + const res = await client.docx.documentBlockChildren.create({ + path: { document_id: docToken, block_id: blockId }, + data: { children: cleaned }, + }); + if (res.code !== 0) throw new Error(res.msg); + return { children: res.data?.children ?? [], skipped }; +} + +async function clearDocumentContent(client: Lark.Client, docToken: string) { + const existing = await client.docx.documentBlock.list({ + path: { document_id: docToken }, + }); + if (existing.code !== 0) throw new Error(existing.msg); + + const childIds = + existing.data?.items + ?.filter((b) => b.parent_id === docToken && b.block_type !== 1) + .map((b) => b.block_id) ?? []; + + if (childIds.length > 0) { + const res = await client.docx.documentBlockChildren.batchDelete({ + path: { document_id: docToken, block_id: docToken }, + data: { start_index: 0, end_index: childIds.length }, + }); + if (res.code !== 0) throw new Error(res.msg); + } + + return childIds.length; +} + +async function uploadImageToDocx( + client: Lark.Client, + blockId: string, + imageBuffer: Buffer, + fileName: string, +): Promise { + const res = await client.drive.media.uploadAll({ + data: { + file_name: fileName, + parent_type: "docx_image", + parent_node: blockId, + size: imageBuffer.length, + file: Readable.from(imageBuffer) as any, + }, + }); + + const fileToken = res?.file_token; + if (!fileToken) { + throw new Error("Image upload failed: no file_token returned"); + } + return fileToken; +} + +async function downloadImage(url: string): Promise { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to download image: ${response.status} ${response.statusText}`); + } + return Buffer.from(await response.arrayBuffer()); +} + +async function processImages( + client: Lark.Client, + docToken: string, + markdown: string, + insertedBlocks: any[], +): Promise { + const imageUrls = extractImageUrls(markdown); + if (imageUrls.length === 0) return 0; + + const imageBlocks = insertedBlocks.filter((b) => b.block_type === 27); + + let processed = 0; + for (let i = 0; i < Math.min(imageUrls.length, imageBlocks.length); i++) { + const url = imageUrls[i]; + const blockId = imageBlocks[i].block_id; + + try { + const buffer = await downloadImage(url); + const urlPath = new URL(url).pathname; + const fileName = urlPath.split("/").pop() || `image_${i}.png`; + const fileToken = await uploadImageToDocx(client, blockId, buffer, fileName); + + await client.docx.documentBlock.patch({ + path: { document_id: docToken, block_id: blockId }, + data: { + replace_image: { token: fileToken }, + }, + }); + + processed++; + } catch (err) { + console.error(`Failed to process image ${url}:`, err); + } + } + + return processed; +} + +// ============ Actions ============ + +const STRUCTURED_BLOCK_TYPES = new Set([14, 18, 21, 23, 27, 30, 31, 32]); + +async function readDoc(client: Lark.Client, docToken: string) { + const [contentRes, infoRes, blocksRes] = await Promise.all([ + client.docx.document.rawContent({ path: { document_id: docToken } }), + client.docx.document.get({ path: { document_id: docToken } }), + client.docx.documentBlock.list({ path: { document_id: docToken } }), + ]); + + if (contentRes.code !== 0) throw new Error(contentRes.msg); + + const blocks = blocksRes.data?.items ?? []; + const blockCounts: Record = {}; + const structuredTypes: string[] = []; + + for (const b of blocks) { + const type = b.block_type ?? 0; + const name = BLOCK_TYPE_NAMES[type] || `type_${type}`; + blockCounts[name] = (blockCounts[name] || 0) + 1; + + if (STRUCTURED_BLOCK_TYPES.has(type) && !structuredTypes.includes(name)) { + structuredTypes.push(name); + } + } + + let hint: string | undefined; + if (structuredTypes.length > 0) { + hint = `This document contains ${structuredTypes.join(", ")} which are NOT included in the plain text above. Use feishu_doc with action: "list_blocks" to get full content.`; + } + + return { + title: infoRes.data?.document?.title, + content: contentRes.data?.content, + revision_id: infoRes.data?.document?.revision_id, + block_count: blocks.length, + block_types: blockCounts, + ...(hint && { hint }), + }; +} + +async function createDoc(client: Lark.Client, title: string, folderToken?: string) { + const res = await client.docx.document.create({ + data: { title, folder_token: folderToken }, + }); + if (res.code !== 0) throw new Error(res.msg); + const doc = res.data?.document; + return { + document_id: doc?.document_id, + title: doc?.title, + url: `https://feishu.cn/docx/${doc?.document_id}`, + }; +} + +async function writeDoc(client: Lark.Client, docToken: string, markdown: string) { + const deleted = await clearDocumentContent(client, docToken); + + const { blocks } = await convertMarkdown(client, markdown); + if (blocks.length === 0) { + return { success: true, blocks_deleted: deleted, blocks_added: 0, images_processed: 0 }; + } + + const { children: inserted, skipped } = await insertBlocks(client, docToken, blocks); + const imagesProcessed = await processImages(client, docToken, markdown, inserted); + + return { + success: true, + blocks_deleted: deleted, + blocks_added: inserted.length, + images_processed: imagesProcessed, + ...(skipped.length > 0 && { + warning: `Skipped unsupported block types: ${skipped.join(", ")}. Tables are not supported via this API.`, + }), + }; +} + +async function appendDoc(client: Lark.Client, docToken: string, markdown: string) { + const { blocks } = await convertMarkdown(client, markdown); + if (blocks.length === 0) { + throw new Error("Content is empty"); + } + + const { children: inserted, skipped } = await insertBlocks(client, docToken, blocks); + const imagesProcessed = await processImages(client, docToken, markdown, inserted); + + return { + success: true, + blocks_added: inserted.length, + images_processed: imagesProcessed, + block_ids: inserted.map((b: any) => b.block_id), + ...(skipped.length > 0 && { + warning: `Skipped unsupported block types: ${skipped.join(", ")}. Tables are not supported via this API.`, + }), + }; +} + +async function updateBlock( + client: Lark.Client, + docToken: string, + blockId: string, + content: string, +) { + const blockInfo = await client.docx.documentBlock.get({ + path: { document_id: docToken, block_id: blockId }, + }); + if (blockInfo.code !== 0) throw new Error(blockInfo.msg); + + const res = await client.docx.documentBlock.patch({ + path: { document_id: docToken, block_id: blockId }, + data: { + update_text_elements: { + elements: [{ text_run: { content } }], + }, + }, + }); + if (res.code !== 0) throw new Error(res.msg); + + return { success: true, block_id: blockId }; +} + +async function deleteBlock(client: Lark.Client, docToken: string, blockId: string) { + const blockInfo = await client.docx.documentBlock.get({ + path: { document_id: docToken, block_id: blockId }, + }); + if (blockInfo.code !== 0) throw new Error(blockInfo.msg); + + const parentId = blockInfo.data?.block?.parent_id ?? docToken; + + const children = await client.docx.documentBlockChildren.get({ + path: { document_id: docToken, block_id: parentId }, + }); + if (children.code !== 0) throw new Error(children.msg); + + const items = children.data?.items ?? []; + const index = items.findIndex((item: any) => item.block_id === blockId); + if (index === -1) throw new Error("Block not found"); + + const res = await client.docx.documentBlockChildren.batchDelete({ + path: { document_id: docToken, block_id: parentId }, + data: { start_index: index, end_index: index + 1 }, + }); + if (res.code !== 0) throw new Error(res.msg); + + return { success: true, deleted_block_id: blockId }; +} + +async function listBlocks(client: Lark.Client, docToken: string) { + const res = await client.docx.documentBlock.list({ + path: { document_id: docToken }, + }); + if (res.code !== 0) throw new Error(res.msg); + + return { + blocks: res.data?.items ?? [], + }; +} + +async function getBlock(client: Lark.Client, docToken: string, blockId: string) { + const res = await client.docx.documentBlock.get({ + path: { document_id: docToken, block_id: blockId }, + }); + if (res.code !== 0) throw new Error(res.msg); + + return { + block: res.data?.block, + }; +} + +async function listAppScopes(client: Lark.Client) { + const res = await client.application.scope.list({}); + if (res.code !== 0) throw new Error(res.msg); + + const scopes = res.data?.scopes ?? []; + const granted = scopes.filter((s) => s.grant_status === 1); + const pending = scopes.filter((s) => s.grant_status !== 1); + + return { + granted: granted.map((s) => ({ name: s.scope_name, type: s.scope_type })), + pending: pending.map((s) => ({ name: s.scope_name, type: s.scope_type })), + summary: `${granted.length} granted, ${pending.length} pending`, + }; +} + +// ============ Tool Registration ============ + +export function registerFeishuDocTools(api: OpenClawPluginApi) { + const feishuCfg = api.config?.channels?.feishu as FeishuConfig | undefined; + if (!feishuCfg?.appId || !feishuCfg?.appSecret) { + api.logger.debug?.("feishu_doc: Feishu credentials not configured, skipping doc tools"); + return; + } + + const toolsCfg = resolveToolsConfig(feishuCfg.tools); + const getClient = () => createFeishuClient(feishuCfg); + const registered: string[] = []; + + // Main document tool with action-based dispatch + if (toolsCfg.doc) { + api.registerTool( + { + name: "feishu_doc", + label: "Feishu Doc", + description: + "Feishu document operations. Actions: read, write, append, create, list_blocks, get_block, update_block, delete_block", + parameters: FeishuDocSchema, + async execute(_toolCallId, params) { + const p = params as FeishuDocParams; + try { + const client = getClient(); + switch (p.action) { + case "read": + return json(await readDoc(client, p.doc_token)); + case "write": + return json(await writeDoc(client, p.doc_token, p.content)); + case "append": + return json(await appendDoc(client, p.doc_token, p.content)); + case "create": + return json(await createDoc(client, p.title, p.folder_token)); + case "list_blocks": + return json(await listBlocks(client, p.doc_token)); + case "get_block": + return json(await getBlock(client, p.doc_token, p.block_id)); + case "update_block": + return json(await updateBlock(client, p.doc_token, p.block_id, p.content)); + case "delete_block": + return json(await deleteBlock(client, p.doc_token, p.block_id)); + default: + return json({ error: `Unknown action: ${(p as any).action}` }); + } + } catch (err) { + return json({ error: err instanceof Error ? err.message : String(err) }); + } + }, + }, + { name: "feishu_doc" }, + ); + registered.push("feishu_doc"); + } + + // Keep feishu_app_scopes as independent tool + if (toolsCfg.scopes) { + api.registerTool( + { + name: "feishu_app_scopes", + label: "Feishu App Scopes", + description: + "List current app permissions (scopes). Use to debug permission issues or check available capabilities.", + parameters: Type.Object({}), + async execute() { + try { + const result = await listAppScopes(getClient()); + return json(result); + } catch (err) { + return json({ error: err instanceof Error ? err.message : String(err) }); + } + }, + }, + { name: "feishu_app_scopes" }, + ); + registered.push("feishu_app_scopes"); + } + + if (registered.length > 0) { + api.logger.info?.(`feishu_doc: Registered ${registered.join(", ")}`); + } +} diff --git a/extensions/feishu/src/drive-schema.ts b/extensions/feishu/src/drive-schema.ts new file mode 100644 index 0000000000..4642aad820 --- /dev/null +++ b/extensions/feishu/src/drive-schema.ts @@ -0,0 +1,46 @@ +import { Type, type Static } from "@sinclair/typebox"; + +const FileType = Type.Union([ + Type.Literal("doc"), + Type.Literal("docx"), + Type.Literal("sheet"), + Type.Literal("bitable"), + Type.Literal("folder"), + Type.Literal("file"), + Type.Literal("mindnote"), + Type.Literal("shortcut"), +]); + +export const FeishuDriveSchema = Type.Union([ + Type.Object({ + action: Type.Literal("list"), + folder_token: Type.Optional( + Type.String({ description: "Folder token (optional, omit for root directory)" }), + ), + }), + Type.Object({ + action: Type.Literal("info"), + file_token: Type.String({ description: "File or folder token" }), + type: FileType, + }), + Type.Object({ + action: Type.Literal("create_folder"), + name: Type.String({ description: "Folder name" }), + folder_token: Type.Optional( + Type.String({ description: "Parent folder token (optional, omit for root)" }), + ), + }), + Type.Object({ + action: Type.Literal("move"), + file_token: Type.String({ description: "File token to move" }), + type: FileType, + folder_token: Type.String({ description: "Target folder token" }), + }), + Type.Object({ + action: Type.Literal("delete"), + file_token: Type.String({ description: "File token to delete" }), + type: FileType, + }), +]); + +export type FeishuDriveParams = Static; diff --git a/extensions/feishu/src/drive.ts b/extensions/feishu/src/drive.ts new file mode 100644 index 0000000000..f40cab0414 --- /dev/null +++ b/extensions/feishu/src/drive.ts @@ -0,0 +1,204 @@ +import type * as Lark from "@larksuiteoapi/node-sdk"; +import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; +import type { FeishuConfig } from "./types.js"; +import { createFeishuClient } from "./client.js"; +import { FeishuDriveSchema, type FeishuDriveParams } from "./drive-schema.js"; +import { resolveToolsConfig } from "./tools-config.js"; + +// ============ Helpers ============ + +function json(data: unknown) { + return { + content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }], + details: data, + }; +} + +// ============ Actions ============ + +async function getRootFolderToken(client: Lark.Client): Promise { + // Use generic HTTP client to call the root folder meta API + // as it's not directly exposed in the SDK + const domain = (client as any).domain ?? "https://open.feishu.cn"; + const res = (await (client as any).httpInstance.get( + `${domain}/open-apis/drive/explorer/v2/root_folder/meta`, + )) as { code: number; msg?: string; data?: { token?: string } }; + if (res.code !== 0) throw new Error(res.msg ?? "Failed to get root folder"); + const token = res.data?.token; + if (!token) throw new Error("Root folder token not found"); + return token; +} + +async function listFolder(client: Lark.Client, folderToken?: string) { + // Filter out invalid folder_token values (empty, "0", etc.) + const validFolderToken = folderToken && folderToken !== "0" ? folderToken : undefined; + const res = await client.drive.file.list({ + params: validFolderToken ? { folder_token: validFolderToken } : {}, + }); + if (res.code !== 0) throw new Error(res.msg); + + return { + files: + res.data?.files?.map((f) => ({ + token: f.token, + name: f.name, + type: f.type, + url: f.url, + created_time: f.created_time, + modified_time: f.modified_time, + owner_id: f.owner_id, + })) ?? [], + next_page_token: res.data?.next_page_token, + }; +} + +async function getFileInfo(client: Lark.Client, fileToken: string, folderToken?: string) { + // Use list with folder_token to find file info + const res = await client.drive.file.list({ + params: folderToken ? { folder_token: folderToken } : {}, + }); + if (res.code !== 0) throw new Error(res.msg); + + const file = res.data?.files?.find((f) => f.token === fileToken); + if (!file) { + throw new Error(`File not found: ${fileToken}`); + } + + return { + token: file.token, + name: file.name, + type: file.type, + url: file.url, + created_time: file.created_time, + modified_time: file.modified_time, + owner_id: file.owner_id, + }; +} + +async function createFolder(client: Lark.Client, name: string, folderToken?: string) { + // Feishu supports using folder_token="0" as the root folder. + // We *try* to resolve the real root token (explorer API), but fall back to "0" + // because some tenants/apps return 400 for that explorer endpoint. + let effectiveToken = folderToken && folderToken !== "0" ? folderToken : "0"; + if (effectiveToken === "0") { + try { + effectiveToken = await getRootFolderToken(client); + } catch { + // ignore and keep "0" + } + } + + const res = await client.drive.file.createFolder({ + data: { + name, + folder_token: effectiveToken, + }, + }); + if (res.code !== 0) throw new Error(res.msg); + + return { + token: res.data?.token, + url: res.data?.url, + }; +} + +async function moveFile(client: Lark.Client, fileToken: string, type: string, folderToken: string) { + const res = await client.drive.file.move({ + path: { file_token: fileToken }, + data: { + type: type as + | "doc" + | "docx" + | "sheet" + | "bitable" + | "folder" + | "file" + | "mindnote" + | "slides", + folder_token: folderToken, + }, + }); + if (res.code !== 0) throw new Error(res.msg); + + return { + success: true, + task_id: res.data?.task_id, + }; +} + +async function deleteFile(client: Lark.Client, fileToken: string, type: string) { + const res = await client.drive.file.delete({ + path: { file_token: fileToken }, + params: { + type: type as + | "doc" + | "docx" + | "sheet" + | "bitable" + | "folder" + | "file" + | "mindnote" + | "slides" + | "shortcut", + }, + }); + if (res.code !== 0) throw new Error(res.msg); + + return { + success: true, + task_id: res.data?.task_id, + }; +} + +// ============ Tool Registration ============ + +export function registerFeishuDriveTools(api: OpenClawPluginApi) { + const feishuCfg = api.config?.channels?.feishu as FeishuConfig | undefined; + if (!feishuCfg?.appId || !feishuCfg?.appSecret) { + api.logger.debug?.("feishu_drive: Feishu credentials not configured, skipping drive tools"); + return; + } + + const toolsCfg = resolveToolsConfig(feishuCfg.tools); + if (!toolsCfg.drive) { + api.logger.debug?.("feishu_drive: drive tool disabled in config"); + return; + } + + const getClient = () => createFeishuClient(feishuCfg); + + api.registerTool( + { + name: "feishu_drive", + label: "Feishu Drive", + description: + "Feishu cloud storage operations. Actions: list, info, create_folder, move, delete", + parameters: FeishuDriveSchema, + async execute(_toolCallId, params) { + const p = params as FeishuDriveParams; + try { + const client = getClient(); + switch (p.action) { + case "list": + return json(await listFolder(client, p.folder_token)); + case "info": + return json(await getFileInfo(client, p.file_token)); + case "create_folder": + return json(await createFolder(client, p.name, p.folder_token)); + case "move": + return json(await moveFile(client, p.file_token, p.type, p.folder_token)); + case "delete": + return json(await deleteFile(client, p.file_token, p.type)); + default: + return json({ error: `Unknown action: ${(p as any).action}` }); + } + } catch (err) { + return json({ error: err instanceof Error ? err.message : String(err) }); + } + }, + }, + { name: "feishu_drive" }, + ); + + api.logger.info?.(`feishu_drive: Registered feishu_drive tool`); +} diff --git a/extensions/feishu/src/media.ts b/extensions/feishu/src/media.ts new file mode 100644 index 0000000000..cfa79d99ba --- /dev/null +++ b/extensions/feishu/src/media.ts @@ -0,0 +1,513 @@ +import type { ClawdbotConfig } from "openclaw/plugin-sdk"; +import fs from "fs"; +import os from "os"; +import path from "path"; +import { Readable } from "stream"; +import type { FeishuConfig } from "./types.js"; +import { createFeishuClient } from "./client.js"; +import { resolveReceiveIdType, normalizeFeishuTarget } from "./targets.js"; + +export type DownloadImageResult = { + buffer: Buffer; + contentType?: string; +}; + +export type DownloadMessageResourceResult = { + buffer: Buffer; + contentType?: string; + fileName?: string; +}; + +/** + * Download an image from Feishu using image_key. + * Used for downloading images sent in messages. + */ +export async function downloadImageFeishu(params: { + cfg: ClawdbotConfig; + imageKey: string; +}): Promise { + const { cfg, imageKey } = params; + const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; + if (!feishuCfg) { + throw new Error("Feishu channel not configured"); + } + + const client = createFeishuClient(feishuCfg); + + const response = await client.im.image.get({ + path: { image_key: imageKey }, + }); + + const responseAny = response as any; + if (responseAny.code !== undefined && responseAny.code !== 0) { + throw new Error( + `Feishu image download failed: ${responseAny.msg || `code ${responseAny.code}`}`, + ); + } + + // Handle various response formats from Feishu SDK + let buffer: Buffer; + + if (Buffer.isBuffer(response)) { + buffer = response; + } else if (response instanceof ArrayBuffer) { + buffer = Buffer.from(response); + } else if (responseAny.data && Buffer.isBuffer(responseAny.data)) { + buffer = responseAny.data; + } else if (responseAny.data instanceof ArrayBuffer) { + buffer = Buffer.from(responseAny.data); + } else if (typeof responseAny.getReadableStream === "function") { + // SDK provides getReadableStream method + const stream = responseAny.getReadableStream(); + const chunks: Buffer[] = []; + for await (const chunk of stream) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + buffer = Buffer.concat(chunks); + } else if (typeof responseAny.writeFile === "function") { + // SDK provides writeFile method - use a temp file + const tmpPath = path.join(os.tmpdir(), `feishu_img_${Date.now()}_${imageKey}`); + await responseAny.writeFile(tmpPath); + buffer = await fs.promises.readFile(tmpPath); + await fs.promises.unlink(tmpPath).catch(() => {}); // cleanup + } else if (typeof responseAny[Symbol.asyncIterator] === "function") { + // Response is an async iterable + const chunks: Buffer[] = []; + for await (const chunk of responseAny) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + buffer = Buffer.concat(chunks); + } else if (typeof responseAny.read === "function") { + // Response is a Readable stream + const chunks: Buffer[] = []; + for await (const chunk of responseAny as Readable) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + buffer = Buffer.concat(chunks); + } else { + // Debug: log what we actually received + const keys = Object.keys(responseAny); + const types = keys.map((k) => `${k}: ${typeof responseAny[k]}`).join(", "); + throw new Error(`Feishu image download failed: unexpected response format. Keys: [${types}]`); + } + + return { buffer }; +} + +/** + * Download a message resource (file/image/audio/video) from Feishu. + * Used for downloading files, audio, and video from messages. + */ +export async function downloadMessageResourceFeishu(params: { + cfg: ClawdbotConfig; + messageId: string; + fileKey: string; + type: "image" | "file"; +}): Promise { + const { cfg, messageId, fileKey, type } = params; + const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; + if (!feishuCfg) { + throw new Error("Feishu channel not configured"); + } + + const client = createFeishuClient(feishuCfg); + + const response = await client.im.messageResource.get({ + path: { message_id: messageId, file_key: fileKey }, + params: { type }, + }); + + const responseAny = response as any; + if (responseAny.code !== undefined && responseAny.code !== 0) { + throw new Error( + `Feishu message resource download failed: ${responseAny.msg || `code ${responseAny.code}`}`, + ); + } + + // Handle various response formats from Feishu SDK + let buffer: Buffer; + + if (Buffer.isBuffer(response)) { + buffer = response; + } else if (response instanceof ArrayBuffer) { + buffer = Buffer.from(response); + } else if (responseAny.data && Buffer.isBuffer(responseAny.data)) { + buffer = responseAny.data; + } else if (responseAny.data instanceof ArrayBuffer) { + buffer = Buffer.from(responseAny.data); + } else if (typeof responseAny.getReadableStream === "function") { + // SDK provides getReadableStream method + const stream = responseAny.getReadableStream(); + const chunks: Buffer[] = []; + for await (const chunk of stream) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + buffer = Buffer.concat(chunks); + } else if (typeof responseAny.writeFile === "function") { + // SDK provides writeFile method - use a temp file + const tmpPath = path.join(os.tmpdir(), `feishu_${Date.now()}_${fileKey}`); + await responseAny.writeFile(tmpPath); + buffer = await fs.promises.readFile(tmpPath); + await fs.promises.unlink(tmpPath).catch(() => {}); // cleanup + } else if (typeof responseAny[Symbol.asyncIterator] === "function") { + // Response is an async iterable + const chunks: Buffer[] = []; + for await (const chunk of responseAny) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + buffer = Buffer.concat(chunks); + } else if (typeof responseAny.read === "function") { + // Response is a Readable stream + const chunks: Buffer[] = []; + for await (const chunk of responseAny as Readable) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + buffer = Buffer.concat(chunks); + } else { + // Debug: log what we actually received + const keys = Object.keys(responseAny); + const types = keys.map((k) => `${k}: ${typeof responseAny[k]}`).join(", "); + throw new Error( + `Feishu message resource download failed: unexpected response format. Keys: [${types}]`, + ); + } + + return { buffer }; +} + +export type UploadImageResult = { + imageKey: string; +}; + +export type UploadFileResult = { + fileKey: string; +}; + +export type SendMediaResult = { + messageId: string; + chatId: string; +}; + +/** + * Upload an image to Feishu and get an image_key for sending. + * Supports: JPEG, PNG, WEBP, GIF, TIFF, BMP, ICO + */ +export async function uploadImageFeishu(params: { + cfg: ClawdbotConfig; + image: Buffer | string; // Buffer or file path + imageType?: "message" | "avatar"; +}): Promise { + const { cfg, image, imageType = "message" } = params; + const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; + if (!feishuCfg) { + throw new Error("Feishu channel not configured"); + } + + const client = createFeishuClient(feishuCfg); + + // SDK expects a Readable stream, not a Buffer + // Use type assertion since SDK actually accepts any Readable at runtime + const imageStream = typeof image === "string" ? fs.createReadStream(image) : Readable.from(image); + + const response = await client.im.image.create({ + data: { + image_type: imageType, + image: imageStream as any, + }, + }); + + // SDK v1.30+ returns data directly without code wrapper on success + // On error, it throws or returns { code, msg } + const responseAny = response as any; + if (responseAny.code !== undefined && responseAny.code !== 0) { + throw new Error(`Feishu image upload failed: ${responseAny.msg || `code ${responseAny.code}`}`); + } + + const imageKey = responseAny.image_key ?? responseAny.data?.image_key; + if (!imageKey) { + throw new Error("Feishu image upload failed: no image_key returned"); + } + + return { imageKey }; +} + +/** + * Upload a file to Feishu and get a file_key for sending. + * Max file size: 30MB + */ +export async function uploadFileFeishu(params: { + cfg: ClawdbotConfig; + file: Buffer | string; // Buffer or file path + fileName: string; + fileType: "opus" | "mp4" | "pdf" | "doc" | "xls" | "ppt" | "stream"; + duration?: number; // Required for audio/video files, in milliseconds +}): Promise { + const { cfg, file, fileName, fileType, duration } = params; + const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; + if (!feishuCfg) { + throw new Error("Feishu channel not configured"); + } + + const client = createFeishuClient(feishuCfg); + + // SDK expects a Readable stream, not a Buffer + // Use type assertion since SDK actually accepts any Readable at runtime + const fileStream = typeof file === "string" ? fs.createReadStream(file) : Readable.from(file); + + const response = await client.im.file.create({ + data: { + file_type: fileType, + file_name: fileName, + file: fileStream as any, + ...(duration !== undefined && { duration }), + }, + }); + + // SDK v1.30+ returns data directly without code wrapper on success + const responseAny = response as any; + if (responseAny.code !== undefined && responseAny.code !== 0) { + throw new Error(`Feishu file upload failed: ${responseAny.msg || `code ${responseAny.code}`}`); + } + + const fileKey = responseAny.file_key ?? responseAny.data?.file_key; + if (!fileKey) { + throw new Error("Feishu file upload failed: no file_key returned"); + } + + return { fileKey }; +} + +/** + * Send an image message using an image_key + */ +export async function sendImageFeishu(params: { + cfg: ClawdbotConfig; + to: string; + imageKey: string; + replyToMessageId?: string; +}): Promise { + const { cfg, to, imageKey, replyToMessageId } = params; + const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; + if (!feishuCfg) { + throw new Error("Feishu channel not configured"); + } + + const client = createFeishuClient(feishuCfg); + const receiveId = normalizeFeishuTarget(to); + if (!receiveId) { + throw new Error(`Invalid Feishu target: ${to}`); + } + + const receiveIdType = resolveReceiveIdType(receiveId); + const content = JSON.stringify({ image_key: imageKey }); + + if (replyToMessageId) { + const response = await client.im.message.reply({ + path: { message_id: replyToMessageId }, + data: { + content, + msg_type: "image", + }, + }); + + if (response.code !== 0) { + throw new Error(`Feishu image reply failed: ${response.msg || `code ${response.code}`}`); + } + + return { + messageId: response.data?.message_id ?? "unknown", + chatId: receiveId, + }; + } + + const response = await client.im.message.create({ + params: { receive_id_type: receiveIdType }, + data: { + receive_id: receiveId, + content, + msg_type: "image", + }, + }); + + if (response.code !== 0) { + throw new Error(`Feishu image send failed: ${response.msg || `code ${response.code}`}`); + } + + return { + messageId: response.data?.message_id ?? "unknown", + chatId: receiveId, + }; +} + +/** + * Send a file message using a file_key + */ +export async function sendFileFeishu(params: { + cfg: ClawdbotConfig; + to: string; + fileKey: string; + replyToMessageId?: string; +}): Promise { + const { cfg, to, fileKey, replyToMessageId } = params; + const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; + if (!feishuCfg) { + throw new Error("Feishu channel not configured"); + } + + const client = createFeishuClient(feishuCfg); + const receiveId = normalizeFeishuTarget(to); + if (!receiveId) { + throw new Error(`Invalid Feishu target: ${to}`); + } + + const receiveIdType = resolveReceiveIdType(receiveId); + const content = JSON.stringify({ file_key: fileKey }); + + if (replyToMessageId) { + const response = await client.im.message.reply({ + path: { message_id: replyToMessageId }, + data: { + content, + msg_type: "file", + }, + }); + + if (response.code !== 0) { + throw new Error(`Feishu file reply failed: ${response.msg || `code ${response.code}`}`); + } + + return { + messageId: response.data?.message_id ?? "unknown", + chatId: receiveId, + }; + } + + const response = await client.im.message.create({ + params: { receive_id_type: receiveIdType }, + data: { + receive_id: receiveId, + content, + msg_type: "file", + }, + }); + + if (response.code !== 0) { + throw new Error(`Feishu file send failed: ${response.msg || `code ${response.code}`}`); + } + + return { + messageId: response.data?.message_id ?? "unknown", + chatId: receiveId, + }; +} + +/** + * Helper to detect file type from extension + */ +export function detectFileType( + fileName: string, +): "opus" | "mp4" | "pdf" | "doc" | "xls" | "ppt" | "stream" { + const ext = path.extname(fileName).toLowerCase(); + switch (ext) { + case ".opus": + case ".ogg": + return "opus"; + case ".mp4": + case ".mov": + case ".avi": + return "mp4"; + case ".pdf": + return "pdf"; + case ".doc": + case ".docx": + return "doc"; + case ".xls": + case ".xlsx": + return "xls"; + case ".ppt": + case ".pptx": + return "ppt"; + default: + return "stream"; + } +} + +/** + * Check if a string is a local file path (not a URL) + */ +function isLocalPath(urlOrPath: string): boolean { + // Starts with / or ~ or drive letter (Windows) + if (urlOrPath.startsWith("/") || urlOrPath.startsWith("~") || /^[a-zA-Z]:/.test(urlOrPath)) { + return true; + } + // Try to parse as URL - if it fails or has no protocol, it's likely a local path + try { + const url = new URL(urlOrPath); + return url.protocol === "file:"; + } catch { + return true; // Not a valid URL, treat as local path + } +} + +/** + * Upload and send media (image or file) from URL, local path, or buffer + */ +export async function sendMediaFeishu(params: { + cfg: ClawdbotConfig; + to: string; + mediaUrl?: string; + mediaBuffer?: Buffer; + fileName?: string; + replyToMessageId?: string; +}): Promise { + const { cfg, to, mediaUrl, mediaBuffer, fileName, replyToMessageId } = params; + + let buffer: Buffer; + let name: string; + + if (mediaBuffer) { + buffer = mediaBuffer; + name = fileName ?? "file"; + } else if (mediaUrl) { + if (isLocalPath(mediaUrl)) { + // Local file path - read directly + const filePath = mediaUrl.startsWith("~") + ? mediaUrl.replace("~", process.env.HOME ?? "") + : mediaUrl.replace("file://", ""); + + if (!fs.existsSync(filePath)) { + throw new Error(`Local file not found: ${filePath}`); + } + buffer = fs.readFileSync(filePath); + name = fileName ?? path.basename(filePath); + } else { + // Remote URL - fetch + const response = await fetch(mediaUrl); + if (!response.ok) { + throw new Error(`Failed to fetch media from URL: ${response.status}`); + } + buffer = Buffer.from(await response.arrayBuffer()); + name = fileName ?? (path.basename(new URL(mediaUrl).pathname) || "file"); + } + } else { + throw new Error("Either mediaUrl or mediaBuffer must be provided"); + } + + // Determine if it's an image based on extension + const ext = path.extname(name).toLowerCase(); + const isImage = [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".ico", ".tiff"].includes(ext); + + if (isImage) { + const { imageKey } = await uploadImageFeishu({ cfg, image: buffer }); + return sendImageFeishu({ cfg, to, imageKey, replyToMessageId }); + } else { + const fileType = detectFileType(name); + const { fileKey } = await uploadFileFeishu({ + cfg, + file: buffer, + fileName: name, + fileType, + }); + return sendFileFeishu({ cfg, to, fileKey, replyToMessageId }); + } +} diff --git a/extensions/feishu/src/mention.ts b/extensions/feishu/src/mention.ts new file mode 100644 index 0000000000..cd786791cd --- /dev/null +++ b/extensions/feishu/src/mention.ts @@ -0,0 +1,118 @@ +import type { FeishuMessageEvent } from "./bot.js"; + +/** + * Mention target user info + */ +export type MentionTarget = { + openId: string; + name: string; + key: string; // Placeholder in original message, e.g. @_user_1 +}; + +/** + * Extract mention targets from message event (excluding the bot itself) + */ +export function extractMentionTargets( + event: FeishuMessageEvent, + botOpenId?: string, +): MentionTarget[] { + const mentions = event.message.mentions ?? []; + + return mentions + .filter((m) => { + // Exclude the bot itself + if (botOpenId && m.id.open_id === botOpenId) return false; + // Must have open_id + return !!m.id.open_id; + }) + .map((m) => ({ + openId: m.id.open_id!, + name: m.name, + key: m.key, + })); +} + +/** + * Check if message is a mention forward request + * Rules: + * - Group: message mentions bot + at least one other user + * - DM: message mentions any user (no need to mention bot) + */ +export function isMentionForwardRequest(event: FeishuMessageEvent, botOpenId?: string): boolean { + const mentions = event.message.mentions ?? []; + if (mentions.length === 0) return false; + + const isDirectMessage = event.message.chat_type === "p2p"; + const hasOtherMention = mentions.some((m) => m.id.open_id !== botOpenId); + + if (isDirectMessage) { + // DM: trigger if any non-bot user is mentioned + return hasOtherMention; + } else { + // Group: need to mention both bot and other users + const hasBotMention = mentions.some((m) => m.id.open_id === botOpenId); + return hasBotMention && hasOtherMention; + } +} + +/** + * Extract message body from text (remove @ placeholders) + */ +export function extractMessageBody(text: string, allMentionKeys: string[]): string { + let result = text; + + // Remove all @ placeholders + for (const key of allMentionKeys) { + result = result.replace(new RegExp(key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g"), ""); + } + + return result.replace(/\s+/g, " ").trim(); +} + +/** + * Format @mention for text message + */ +export function formatMentionForText(target: MentionTarget): string { + return `${target.name}`; +} + +/** + * Format @everyone for text message + */ +export function formatMentionAllForText(): string { + return `Everyone`; +} + +/** + * Format @mention for card message (lark_md) + */ +export function formatMentionForCard(target: MentionTarget): string { + return ``; +} + +/** + * Format @everyone for card message + */ +export function formatMentionAllForCard(): string { + return ``; +} + +/** + * Build complete message with @mentions (text format) + */ +export function buildMentionedMessage(targets: MentionTarget[], message: string): string { + if (targets.length === 0) return message; + + const mentionParts = targets.map((t) => formatMentionForText(t)); + return `${mentionParts.join(" ")} ${message}`; +} + +/** + * Build card content with @mentions (Markdown format) + */ +export function buildMentionedCardContent(targets: MentionTarget[], message: string): string { + if (targets.length === 0) return message; + + const mentionParts = targets.map((t) => formatMentionForCard(t)); + return `${mentionParts.join(" ")} ${message}`; +} diff --git a/extensions/feishu/src/monitor.ts b/extensions/feishu/src/monitor.ts new file mode 100644 index 0000000000..e84e51a18f --- /dev/null +++ b/extensions/feishu/src/monitor.ts @@ -0,0 +1,156 @@ +import type { ClawdbotConfig, RuntimeEnv, HistoryEntry } from "openclaw/plugin-sdk"; +import * as Lark from "@larksuiteoapi/node-sdk"; +import type { FeishuConfig } from "./types.js"; +import { resolveFeishuCredentials } from "./accounts.js"; +import { handleFeishuMessage, type FeishuMessageEvent, type FeishuBotAddedEvent } from "./bot.js"; +import { createFeishuWSClient, createEventDispatcher } from "./client.js"; +import { probeFeishu } from "./probe.js"; + +export type MonitorFeishuOpts = { + config?: ClawdbotConfig; + runtime?: RuntimeEnv; + abortSignal?: AbortSignal; + accountId?: string; +}; + +let currentWsClient: Lark.WSClient | null = null; +let botOpenId: string | undefined; + +async function fetchBotOpenId(cfg: FeishuConfig): Promise { + try { + const result = await probeFeishu(cfg); + return result.ok ? result.botOpenId : undefined; + } catch { + return undefined; + } +} + +export async function monitorFeishuProvider(opts: MonitorFeishuOpts = {}): Promise { + const cfg = opts.config; + if (!cfg) { + throw new Error("Config is required for Feishu monitor"); + } + + const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; + const creds = resolveFeishuCredentials(feishuCfg); + if (!creds) { + throw new Error("Feishu credentials not configured (appId, appSecret required)"); + } + + const log = opts.runtime?.log ?? console.log; + const error = opts.runtime?.error ?? console.error; + + if (feishuCfg) { + botOpenId = await fetchBotOpenId(feishuCfg); + log(`feishu: bot open_id resolved: ${botOpenId ?? "unknown"}`); + } + + const connectionMode = feishuCfg?.connectionMode ?? "websocket"; + + if (connectionMode === "websocket") { + return monitorWebSocket({ + cfg, + feishuCfg: feishuCfg!, + runtime: opts.runtime, + abortSignal: opts.abortSignal, + }); + } + + log("feishu: webhook mode not implemented in monitor, use HTTP server directly"); +} + +async function monitorWebSocket(params: { + cfg: ClawdbotConfig; + feishuCfg: FeishuConfig; + runtime?: RuntimeEnv; + abortSignal?: AbortSignal; +}): Promise { + const { cfg, feishuCfg, runtime, abortSignal } = params; + const log = runtime?.log ?? console.log; + const error = runtime?.error ?? console.error; + + log("feishu: starting WebSocket connection..."); + + const wsClient = createFeishuWSClient(feishuCfg); + currentWsClient = wsClient; + + const chatHistories = new Map(); + + const eventDispatcher = createEventDispatcher(feishuCfg); + + eventDispatcher.register({ + "im.message.receive_v1": async (data) => { + try { + const event = data as unknown as FeishuMessageEvent; + await handleFeishuMessage({ + cfg, + event, + botOpenId, + runtime, + chatHistories, + }); + } catch (err) { + error(`feishu: error handling message event: ${String(err)}`); + } + }, + "im.message.message_read_v1": async () => { + // Ignore read receipts + }, + "im.chat.member.bot.added_v1": async (data) => { + try { + const event = data as unknown as FeishuBotAddedEvent; + log(`feishu: bot added to chat ${event.chat_id}`); + } catch (err) { + error(`feishu: error handling bot added event: ${String(err)}`); + } + }, + "im.chat.member.bot.deleted_v1": async (data) => { + try { + const event = data as unknown as { chat_id: string }; + log(`feishu: bot removed from chat ${event.chat_id}`); + } catch (err) { + error(`feishu: error handling bot removed event: ${String(err)}`); + } + }, + }); + + return new Promise((resolve, reject) => { + const cleanup = () => { + if (currentWsClient === wsClient) { + currentWsClient = null; + } + }; + + const handleAbort = () => { + log("feishu: abort signal received, stopping WebSocket client"); + cleanup(); + resolve(); + }; + + if (abortSignal?.aborted) { + cleanup(); + resolve(); + return; + } + + abortSignal?.addEventListener("abort", handleAbort, { once: true }); + + try { + wsClient.start({ + eventDispatcher, + }); + + log("feishu: WebSocket client started"); + } catch (err) { + cleanup(); + abortSignal?.removeEventListener("abort", handleAbort); + reject(err); + } + }); +} + +export function stopFeishuMonitor(): void { + if (currentWsClient) { + currentWsClient = null; + } +} diff --git a/extensions/feishu/src/onboarding.ts b/extensions/feishu/src/onboarding.ts index 07ee973673..38b619387c 100644 --- a/extensions/feishu/src/onboarding.ts +++ b/extensions/feishu/src/onboarding.ts @@ -1,124 +1,110 @@ import type { ChannelOnboardingAdapter, ChannelOnboardingDmPolicy, + ClawdbotConfig, DmPolicy, - OpenClawConfig, WizardPrompter, } from "openclaw/plugin-sdk"; -import { - addWildcardAllowFrom, - DEFAULT_ACCOUNT_ID, - formatDocsLink, - normalizeAccountId, - promptAccountId, -} from "openclaw/plugin-sdk"; -import { - listFeishuAccountIds, - resolveDefaultFeishuAccountId, - resolveFeishuAccount, -} from "openclaw/plugin-sdk"; +import { addWildcardAllowFrom, DEFAULT_ACCOUNT_ID, formatDocsLink } from "openclaw/plugin-sdk"; +import type { FeishuConfig } from "./types.js"; +import { resolveFeishuCredentials } from "./accounts.js"; +import { probeFeishu } from "./probe.js"; const channel = "feishu" as const; -function setFeishuDmPolicy(cfg: OpenClawConfig, policy: DmPolicy): OpenClawConfig { +function setFeishuDmPolicy(cfg: ClawdbotConfig, dmPolicy: DmPolicy): ClawdbotConfig { const allowFrom = - policy === "open" ? addWildcardAllowFrom(cfg.channels?.feishu?.allowFrom) : undefined; + dmPolicy === "open" + ? addWildcardAllowFrom(cfg.channels?.feishu?.allowFrom)?.map((entry) => String(entry)) + : undefined; return { ...cfg, channels: { ...cfg.channels, feishu: { ...cfg.channels?.feishu, - enabled: true, - dmPolicy: policy, + dmPolicy, ...(allowFrom ? { allowFrom } : {}), }, }, }; } -async function noteFeishuSetup(prompter: WizardPrompter): Promise { - await prompter.note( - [ - "Create a Feishu/Lark app and enable Bot + Event Subscription (WebSocket).", - "Copy the App ID and App Secret from the app credentials page.", - 'Lark (global): use open.larksuite.com and set domain="lark".', - `Docs: ${formatDocsLink("/channels/feishu", "channels/feishu")}`, - ].join("\n"), - "Feishu setup", - ); +function setFeishuAllowFrom(cfg: ClawdbotConfig, allowFrom: string[]): ClawdbotConfig { + return { + ...cfg, + channels: { + ...cfg.channels, + feishu: { + ...cfg.channels?.feishu, + allowFrom, + }, + }, + }; } -function normalizeAllowEntry(entry: string): string { - return entry.replace(/^(feishu|lark):/i, "").trim(); -} - -function resolveDomainChoice(domain?: string | null): "feishu" | "lark" { - const normalized = String(domain ?? "").toLowerCase(); - if (normalized.includes("lark") || normalized.includes("larksuite")) { - return "lark"; - } - return "feishu"; +function parseAllowFromInput(raw: string): string[] { + return raw + .split(/[\n,;]+/g) + .map((entry) => entry.trim()) + .filter(Boolean); } async function promptFeishuAllowFrom(params: { - cfg: OpenClawConfig; + cfg: ClawdbotConfig; prompter: WizardPrompter; - accountId?: string | null; -}): Promise { - const { cfg, prompter } = params; - const accountId = normalizeAccountId(params.accountId); - const isDefault = accountId === DEFAULT_ACCOUNT_ID; - const existingAllowFrom = isDefault - ? (cfg.channels?.feishu?.allowFrom ?? []) - : (cfg.channels?.feishu?.accounts?.[accountId]?.allowFrom ?? []); +}): Promise { + const existing = params.cfg.channels?.feishu?.allowFrom ?? []; + await params.prompter.note( + [ + "Allowlist Feishu DMs by open_id or user_id.", + "You can find user open_id in Feishu admin console or via API.", + "Examples:", + "- ou_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "- on_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + ].join("\n"), + "Feishu allowlist", + ); - const entry = await prompter.text({ - message: "Feishu allowFrom (open_id or union_id)", - placeholder: "ou_xxx", - initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined, - validate: (value) => { - const raw = String(value ?? "").trim(); - if (!raw) { - return "Required"; - } - const entries = raw - .split(/[\n,;]+/g) - .map((item) => normalizeAllowEntry(item)) - .filter(Boolean); - const invalid = entries.filter((item) => item !== "*" && !/^o[un]_[a-zA-Z0-9]+$/.test(item)); - if (invalid.length > 0) { - return `Invalid Feishu ids: ${invalid.join(", ")}`; - } - return undefined; - }, - }); + while (true) { + const entry = await params.prompter.text({ + message: "Feishu allowFrom (user open_ids)", + placeholder: "ou_xxxxx, ou_yyyyy", + initialValue: existing[0] ? String(existing[0]) : undefined, + validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), + }); + const parts = parseAllowFromInput(String(entry)); + if (parts.length === 0) { + await params.prompter.note("Enter at least one user.", "Feishu allowlist"); + continue; + } - const parsed = String(entry) - .split(/[\n,;]+/g) - .map((item) => normalizeAllowEntry(item)) - .filter(Boolean); - const merged = [ - ...existingAllowFrom.map((item) => normalizeAllowEntry(String(item))), - ...parsed, - ].filter(Boolean); - const unique = Array.from(new Set(merged)); - - if (isDefault) { - return { - ...cfg, - channels: { - ...cfg.channels, - feishu: { - ...cfg.channels?.feishu, - enabled: true, - dmPolicy: "allowlist", - allowFrom: unique, - }, - }, - }; + const unique = [ + ...new Set([...existing.map((v) => String(v).trim()).filter(Boolean), ...parts]), + ]; + return setFeishuAllowFrom(params.cfg, unique); } +} +async function noteFeishuCredentialHelp(prompter: WizardPrompter): Promise { + await prompter.note( + [ + "1) Go to Feishu Open Platform (open.feishu.cn)", + "2) Create a self-built app", + "3) Get App ID and App Secret from Credentials page", + "4) Enable required permissions: im:message, im:chat, contact:user.base:readonly", + "5) Publish the app or add it to a test group", + "Tip: you can also set FEISHU_APP_ID / FEISHU_APP_SECRET env vars.", + `Docs: ${formatDocsLink("/channels/feishu", "feishu")}`, + ].join("\n"), + "Feishu credentials", + ); +} + +function setFeishuGroupPolicy( + cfg: ClawdbotConfig, + groupPolicy: "open" | "allowlist" | "disabled", +): ClawdbotConfig { return { ...cfg, channels: { @@ -126,15 +112,20 @@ async function promptFeishuAllowFrom(params: { feishu: { ...cfg.channels?.feishu, enabled: true, - accounts: { - ...cfg.channels?.feishu?.accounts, - [accountId]: { - ...cfg.channels?.feishu?.accounts?.[accountId], - enabled: cfg.channels?.feishu?.accounts?.[accountId]?.enabled ?? true, - dmPolicy: "allowlist", - allowFrom: unique, - }, - }, + groupPolicy, + }, + }, + }; +} + +function setFeishuGroupAllowFrom(cfg: ClawdbotConfig, groupAllowFrom: string[]): ClawdbotConfig { + return { + ...cfg, + channels: { + ...cfg.channels, + feishu: { + ...cfg.channels?.feishu, + groupAllowFrom, }, }, }; @@ -145,134 +136,221 @@ const dmPolicy: ChannelOnboardingDmPolicy = { channel, policyKey: "channels.feishu.dmPolicy", allowFromKey: "channels.feishu.allowFrom", - getCurrent: (cfg) => cfg.channels?.feishu?.dmPolicy ?? "pairing", + getCurrent: (cfg) => (cfg.channels?.feishu as FeishuConfig | undefined)?.dmPolicy ?? "pairing", setPolicy: (cfg, policy) => setFeishuDmPolicy(cfg, policy), promptAllowFrom: promptFeishuAllowFrom, }; -function updateFeishuConfig( - cfg: OpenClawConfig, - accountId: string, - updates: { appId?: string; appSecret?: string; domain?: string; enabled?: boolean }, -): OpenClawConfig { - const isDefault = accountId === DEFAULT_ACCOUNT_ID; - const next = { ...cfg } as OpenClawConfig; - const feishu = { ...next.channels?.feishu } as Record; - const accounts = feishu.accounts - ? { ...(feishu.accounts as Record) } - : undefined; - - if (isDefault && !accounts) { - return { - ...next, - channels: { - ...next.channels, - feishu: { - ...feishu, - ...updates, - enabled: updates.enabled ?? true, - }, - }, - }; - } - - const resolvedAccounts = accounts ?? {}; - const existing = (resolvedAccounts[accountId] as Record) ?? {}; - resolvedAccounts[accountId] = { - ...existing, - ...updates, - enabled: updates.enabled ?? true, - }; - - return { - ...next, - channels: { - ...next.channels, - feishu: { - ...feishu, - accounts: resolvedAccounts, - }, - }, - }; -} - export const feishuOnboardingAdapter: ChannelOnboardingAdapter = { channel, - dmPolicy, getStatus: async ({ cfg }) => { - const configured = listFeishuAccountIds(cfg).some((id) => { - const acc = resolveFeishuAccount({ cfg, accountId: id }); - return acc.tokenSource !== "none"; - }); + const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; + const configured = Boolean(resolveFeishuCredentials(feishuCfg)); + + // Try to probe if configured + let probeResult = null; + if (configured && feishuCfg) { + try { + probeResult = await probeFeishu(feishuCfg); + } catch { + // Ignore probe errors + } + } + + const statusLines: string[] = []; + if (!configured) { + statusLines.push("Feishu: needs app credentials"); + } else if (probeResult?.ok) { + statusLines.push( + `Feishu: connected as ${probeResult.botName ?? probeResult.botOpenId ?? "bot"}`, + ); + } else { + statusLines.push("Feishu: configured (connection not verified)"); + } + return { channel, configured, - statusLines: [`Feishu: ${configured ? "configured" : "needs app credentials"}`], - selectionHint: configured ? "configured" : "requires app credentials", - quickstartScore: configured ? 1 : 10, + statusLines, + selectionHint: configured ? "configured" : "needs app creds", + quickstartScore: configured ? 2 : 0, }; }, - configure: async ({ cfg, prompter, accountOverrides, shouldPromptAccountIds }) => { - let next = cfg; - const override = accountOverrides.feishu?.trim(); - const defaultId = resolveDefaultFeishuAccountId(next); - let accountId = override ? normalizeAccountId(override) : defaultId; - if (shouldPromptAccountIds && !override) { - accountId = await promptAccountId({ - cfg: next, - prompter, - label: "Feishu", - currentId: accountId, - listAccountIds: listFeishuAccountIds, - defaultAccountId: defaultId, - }); + configure: async ({ cfg, prompter }) => { + const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; + const resolved = resolveFeishuCredentials(feishuCfg); + const hasConfigCreds = Boolean(feishuCfg?.appId?.trim() && feishuCfg?.appSecret?.trim()); + const canUseEnv = Boolean( + !hasConfigCreds && process.env.FEISHU_APP_ID?.trim() && process.env.FEISHU_APP_SECRET?.trim(), + ); + + let next = cfg; + let appId: string | null = null; + let appSecret: string | null = null; + + if (!resolved) { + await noteFeishuCredentialHelp(prompter); } - await noteFeishuSetup(prompter); - - const resolved = resolveFeishuAccount({ cfg: next, accountId }); - const domainChoice = await prompter.select({ - message: "Feishu domain", - options: [ - { value: "feishu", label: "Feishu (China) — open.feishu.cn" }, - { value: "lark", label: "Lark (global) — open.larksuite.com" }, - ], - initialValue: resolveDomainChoice(resolved.config.domain), - }); - const domain = domainChoice === "lark" ? "lark" : "feishu"; - - const isDefault = accountId === DEFAULT_ACCOUNT_ID; - const envAppId = process.env.FEISHU_APP_ID?.trim(); - const envSecret = process.env.FEISHU_APP_SECRET?.trim(); - if (isDefault && envAppId && envSecret) { - const useEnv = await prompter.confirm({ - message: "FEISHU_APP_ID/FEISHU_APP_SECRET detected. Use env vars?", + if (canUseEnv) { + const keepEnv = await prompter.confirm({ + message: "FEISHU_APP_ID + FEISHU_APP_SECRET detected. Use env vars?", initialValue: true, }); - if (useEnv) { - next = updateFeishuConfig(next, accountId, { enabled: true, domain }); - return { cfg: next, accountId }; + if (keepEnv) { + next = { + ...next, + channels: { + ...next.channels, + feishu: { ...next.channels?.feishu, enabled: true }, + }, + }; + } else { + appId = String( + await prompter.text({ + message: "Enter Feishu App ID", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + appSecret = String( + await prompter.text({ + message: "Enter Feishu App Secret", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + } + } else if (hasConfigCreds) { + const keep = await prompter.confirm({ + message: "Feishu credentials already configured. Keep them?", + initialValue: true, + }); + if (!keep) { + appId = String( + await prompter.text({ + message: "Enter Feishu App ID", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + appSecret = String( + await prompter.text({ + message: "Enter Feishu App Secret", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + } + } else { + appId = String( + await prompter.text({ + message: "Enter Feishu App ID", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + appSecret = String( + await prompter.text({ + message: "Enter Feishu App Secret", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + } + + if (appId && appSecret) { + next = { + ...next, + channels: { + ...next.channels, + feishu: { + ...next.channels?.feishu, + enabled: true, + appId, + appSecret, + }, + }, + }; + + // Test connection + const testCfg = next.channels?.feishu as FeishuConfig; + try { + const probe = await probeFeishu(testCfg); + if (probe.ok) { + await prompter.note( + `Connected as ${probe.botName ?? probe.botOpenId ?? "bot"}`, + "Feishu connection test", + ); + } else { + await prompter.note( + `Connection failed: ${probe.error ?? "unknown error"}`, + "Feishu connection test", + ); + } + } catch (err) { + await prompter.note(`Connection test failed: ${String(err)}`, "Feishu connection test"); } } - const appId = String( - await prompter.text({ - message: "Feishu App ID (cli_...)", - initialValue: resolved.config.appId?.trim() || undefined, - validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), - }), - ).trim(); - const appSecret = String( - await prompter.text({ - message: "Feishu App Secret", - initialValue: resolved.config.appSecret?.trim() || undefined, - validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), - }), - ).trim(); + // Domain selection + const currentDomain = (next.channels?.feishu as FeishuConfig | undefined)?.domain ?? "feishu"; + const domain = await prompter.select({ + message: "Which Feishu domain?", + options: [ + { value: "feishu", label: "Feishu (feishu.cn) - China" }, + { value: "lark", label: "Lark (larksuite.com) - International" }, + ], + initialValue: currentDomain, + }); + if (domain) { + next = { + ...next, + channels: { + ...next.channels, + feishu: { + ...next.channels?.feishu, + domain: domain as "feishu" | "lark", + }, + }, + }; + } - next = updateFeishuConfig(next, accountId, { appId, appSecret, domain, enabled: true }); + // Group policy + const groupPolicy = await prompter.select({ + message: "Group chat policy", + options: [ + { value: "allowlist", label: "Allowlist - only respond in specific groups" }, + { value: "open", label: "Open - respond in all groups (requires mention)" }, + { value: "disabled", label: "Disabled - don't respond in groups" }, + ], + initialValue: (next.channels?.feishu as FeishuConfig | undefined)?.groupPolicy ?? "allowlist", + }); + if (groupPolicy) { + next = setFeishuGroupPolicy(next, groupPolicy as "open" | "allowlist" | "disabled"); + } - return { cfg: next, accountId }; + // Group allowlist if needed + if (groupPolicy === "allowlist") { + const existing = (next.channels?.feishu as FeishuConfig | undefined)?.groupAllowFrom ?? []; + const entry = await prompter.text({ + message: "Group chat allowlist (chat_ids)", + placeholder: "oc_xxxxx, oc_yyyyy", + initialValue: existing.length > 0 ? existing.map(String).join(", ") : undefined, + }); + if (entry) { + const parts = parseAllowFromInput(String(entry)); + if (parts.length > 0) { + next = setFeishuGroupAllowFrom(next, parts); + } + } + } + + return { cfg: next, accountId: DEFAULT_ACCOUNT_ID }; }, + + dmPolicy, + + disable: (cfg) => ({ + ...cfg, + channels: { + ...cfg.channels, + feishu: { ...cfg.channels?.feishu, enabled: false }, + }, + }), }; diff --git a/extensions/feishu/src/outbound.ts b/extensions/feishu/src/outbound.ts new file mode 100644 index 0000000000..db80f1a0e0 --- /dev/null +++ b/extensions/feishu/src/outbound.ts @@ -0,0 +1,40 @@ +import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk"; +import { sendMediaFeishu } from "./media.js"; +import { getFeishuRuntime } from "./runtime.js"; +import { sendMessageFeishu } from "./send.js"; + +export const feishuOutbound: ChannelOutboundAdapter = { + deliveryMode: "direct", + chunker: (text, limit) => getFeishuRuntime().channel.text.chunkMarkdownText(text, limit), + chunkerMode: "markdown", + textChunkLimit: 4000, + sendText: async ({ cfg, to, text }) => { + const result = await sendMessageFeishu({ cfg, to, text }); + return { channel: "feishu", ...result }; + }, + sendMedia: async ({ cfg, to, text, mediaUrl }) => { + // Send text first if provided + if (text?.trim()) { + await sendMessageFeishu({ cfg, to, text }); + } + + // Upload and send media if URL provided + if (mediaUrl) { + try { + const result = await sendMediaFeishu({ cfg, to, mediaUrl }); + return { channel: "feishu", ...result }; + } catch (err) { + // Log the error for debugging + console.error(`[feishu] sendMediaFeishu failed:`, err); + // Fallback to URL link if upload fails + const fallbackText = `📎 ${mediaUrl}`; + const result = await sendMessageFeishu({ cfg, to, text: fallbackText }); + return { channel: "feishu", ...result }; + } + } + + // No media URL, just return text result + const result = await sendMessageFeishu({ cfg, to, text: text ?? "" }); + return { channel: "feishu", ...result }; + }, +}; diff --git a/extensions/feishu/src/perm-schema.ts b/extensions/feishu/src/perm-schema.ts new file mode 100644 index 0000000000..ac645389e7 --- /dev/null +++ b/extensions/feishu/src/perm-schema.ts @@ -0,0 +1,52 @@ +import { Type, type Static } from "@sinclair/typebox"; + +const TokenType = Type.Union([ + Type.Literal("doc"), + Type.Literal("docx"), + Type.Literal("sheet"), + Type.Literal("bitable"), + Type.Literal("folder"), + Type.Literal("file"), + Type.Literal("wiki"), + Type.Literal("mindnote"), +]); + +const MemberType = Type.Union([ + Type.Literal("email"), + Type.Literal("openid"), + Type.Literal("userid"), + Type.Literal("unionid"), + Type.Literal("openchat"), + Type.Literal("opendepartmentid"), +]); + +const Permission = Type.Union([ + Type.Literal("view"), + Type.Literal("edit"), + Type.Literal("full_access"), +]); + +export const FeishuPermSchema = Type.Union([ + Type.Object({ + action: Type.Literal("list"), + token: Type.String({ description: "File token" }), + type: TokenType, + }), + Type.Object({ + action: Type.Literal("add"), + token: Type.String({ description: "File token" }), + type: TokenType, + member_type: MemberType, + member_id: Type.String({ description: "Member ID (email, open_id, user_id, etc.)" }), + perm: Permission, + }), + Type.Object({ + action: Type.Literal("remove"), + token: Type.String({ description: "File token" }), + type: TokenType, + member_type: MemberType, + member_id: Type.String({ description: "Member ID to remove" }), + }), +]); + +export type FeishuPermParams = Static; diff --git a/extensions/feishu/src/perm.ts b/extensions/feishu/src/perm.ts new file mode 100644 index 0000000000..88e234eb7d --- /dev/null +++ b/extensions/feishu/src/perm.ts @@ -0,0 +1,160 @@ +import type * as Lark from "@larksuiteoapi/node-sdk"; +import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; +import type { FeishuConfig } from "./types.js"; +import { createFeishuClient } from "./client.js"; +import { FeishuPermSchema, type FeishuPermParams } from "./perm-schema.js"; +import { resolveToolsConfig } from "./tools-config.js"; + +// ============ Helpers ============ + +function json(data: unknown) { + return { + content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }], + details: data, + }; +} + +type ListTokenType = + | "doc" + | "sheet" + | "file" + | "wiki" + | "bitable" + | "docx" + | "mindnote" + | "minutes" + | "slides"; +type CreateTokenType = + | "doc" + | "sheet" + | "file" + | "wiki" + | "bitable" + | "docx" + | "folder" + | "mindnote" + | "minutes" + | "slides"; +type MemberType = + | "email" + | "openid" + | "unionid" + | "openchat" + | "opendepartmentid" + | "userid" + | "groupid" + | "wikispaceid"; +type PermType = "view" | "edit" | "full_access"; + +// ============ Actions ============ + +async function listMembers(client: Lark.Client, token: string, type: string) { + const res = await client.drive.permissionMember.list({ + path: { token }, + params: { type: type as ListTokenType }, + }); + if (res.code !== 0) throw new Error(res.msg); + + return { + members: + res.data?.items?.map((m) => ({ + member_type: m.member_type, + member_id: m.member_id, + perm: m.perm, + name: m.name, + })) ?? [], + }; +} + +async function addMember( + client: Lark.Client, + token: string, + type: string, + memberType: string, + memberId: string, + perm: string, +) { + const res = await client.drive.permissionMember.create({ + path: { token }, + params: { type: type as CreateTokenType, need_notification: false }, + data: { + member_type: memberType as MemberType, + member_id: memberId, + perm: perm as PermType, + }, + }); + if (res.code !== 0) throw new Error(res.msg); + + return { + success: true, + member: res.data?.member, + }; +} + +async function removeMember( + client: Lark.Client, + token: string, + type: string, + memberType: string, + memberId: string, +) { + const res = await client.drive.permissionMember.delete({ + path: { token, member_id: memberId }, + params: { type: type as CreateTokenType, member_type: memberType as MemberType }, + }); + if (res.code !== 0) throw new Error(res.msg); + + return { + success: true, + }; +} + +// ============ Tool Registration ============ + +export function registerFeishuPermTools(api: OpenClawPluginApi) { + const feishuCfg = api.config?.channels?.feishu as FeishuConfig | undefined; + if (!feishuCfg?.appId || !feishuCfg?.appSecret) { + api.logger.debug?.("feishu_perm: Feishu credentials not configured, skipping perm tools"); + return; + } + + const toolsCfg = resolveToolsConfig(feishuCfg.tools); + if (!toolsCfg.perm) { + api.logger.debug?.("feishu_perm: perm tool disabled in config (default: false)"); + return; + } + + const getClient = () => createFeishuClient(feishuCfg); + + api.registerTool( + { + name: "feishu_perm", + label: "Feishu Perm", + description: "Feishu permission management. Actions: list, add, remove", + parameters: FeishuPermSchema, + async execute(_toolCallId, params) { + const p = params as FeishuPermParams; + try { + const client = getClient(); + switch (p.action) { + case "list": + return json(await listMembers(client, p.token, p.type)); + case "add": + return json( + await addMember(client, p.token, p.type, p.member_type, p.member_id, p.perm), + ); + case "remove": + return json(await removeMember(client, p.token, p.type, p.member_type, p.member_id)); + default: + return json({ error: `Unknown action: ${(p as any).action}` }); + } + } catch (err) { + return json({ error: err instanceof Error ? err.message : String(err) }); + } + }, + }, + { name: "feishu_perm" }, + ); + + api.logger.info?.(`feishu_perm: Registered feishu_perm tool`); +} diff --git a/extensions/feishu/src/policy.ts b/extensions/feishu/src/policy.ts new file mode 100644 index 0000000000..a0e1a0d84e --- /dev/null +++ b/extensions/feishu/src/policy.ts @@ -0,0 +1,92 @@ +import type { ChannelGroupContext, GroupToolPolicyConfig } from "openclaw/plugin-sdk"; +import type { FeishuConfig, FeishuGroupConfig } from "./types.js"; + +export type FeishuAllowlistMatch = { + allowed: boolean; + matchKey?: string; + matchSource?: "wildcard" | "id" | "name"; +}; + +export function resolveFeishuAllowlistMatch(params: { + allowFrom: Array; + senderId: string; + senderName?: string | null; +}): FeishuAllowlistMatch { + const allowFrom = params.allowFrom + .map((entry) => String(entry).trim().toLowerCase()) + .filter(Boolean); + + if (allowFrom.length === 0) return { allowed: false }; + if (allowFrom.includes("*")) { + return { allowed: true, matchKey: "*", matchSource: "wildcard" }; + } + + const senderId = params.senderId.toLowerCase(); + if (allowFrom.includes(senderId)) { + return { allowed: true, matchKey: senderId, matchSource: "id" }; + } + + const senderName = params.senderName?.toLowerCase(); + if (senderName && allowFrom.includes(senderName)) { + return { allowed: true, matchKey: senderName, matchSource: "name" }; + } + + return { allowed: false }; +} + +export function resolveFeishuGroupConfig(params: { + cfg?: FeishuConfig; + groupId?: string | null; +}): FeishuGroupConfig | undefined { + const groups = params.cfg?.groups ?? {}; + const groupId = params.groupId?.trim(); + if (!groupId) return undefined; + + const direct = groups[groupId] as FeishuGroupConfig | undefined; + if (direct) return direct; + + const lowered = groupId.toLowerCase(); + const matchKey = Object.keys(groups).find((key) => key.toLowerCase() === lowered); + return matchKey ? (groups[matchKey] as FeishuGroupConfig | undefined) : undefined; +} + +export function resolveFeishuGroupToolPolicy( + params: ChannelGroupContext, +): GroupToolPolicyConfig | undefined { + const cfg = params.cfg.channels?.feishu as FeishuConfig | undefined; + if (!cfg) return undefined; + + const groupConfig = resolveFeishuGroupConfig({ + cfg, + groupId: params.groupId, + }); + + return groupConfig?.tools; +} + +export function isFeishuGroupAllowed(params: { + groupPolicy: "open" | "allowlist" | "disabled"; + allowFrom: Array; + senderId: string; + senderName?: string | null; +}): boolean { + const { groupPolicy } = params; + if (groupPolicy === "disabled") return false; + if (groupPolicy === "open") return true; + return resolveFeishuAllowlistMatch(params).allowed; +} + +export function resolveFeishuReplyPolicy(params: { + isDirectMessage: boolean; + globalConfig?: FeishuConfig; + groupConfig?: FeishuGroupConfig; +}): { requireMention: boolean } { + if (params.isDirectMessage) { + return { requireMention: false }; + } + + const requireMention = + params.groupConfig?.requireMention ?? params.globalConfig?.requireMention ?? true; + + return { requireMention }; +} diff --git a/extensions/feishu/src/probe.ts b/extensions/feishu/src/probe.ts new file mode 100644 index 0000000000..88ae53f603 --- /dev/null +++ b/extensions/feishu/src/probe.ts @@ -0,0 +1,46 @@ +import type { FeishuConfig, FeishuProbeResult } from "./types.js"; +import { resolveFeishuCredentials } from "./accounts.js"; +import { createFeishuClient } from "./client.js"; + +export async function probeFeishu(cfg?: FeishuConfig): Promise { + const creds = resolveFeishuCredentials(cfg); + if (!creds) { + return { + ok: false, + error: "missing credentials (appId, appSecret)", + }; + } + + try { + const client = createFeishuClient(cfg!); + // Use im.chat.list as a simple connectivity test + // The bot info API path varies by SDK version + const response = await (client as any).request({ + method: "GET", + url: "/open-apis/bot/v3/info", + data: {}, + }); + + if (response.code !== 0) { + return { + ok: false, + appId: creds.appId, + error: `API error: ${response.msg || `code ${response.code}`}`, + }; + } + + const bot = response.bot || response.data?.bot; + return { + ok: true, + appId: creds.appId, + botName: bot?.bot_name, + botOpenId: bot?.open_id, + }; + } catch (err) { + return { + ok: false, + appId: creds.appId, + error: err instanceof Error ? err.message : String(err), + }; + } +} diff --git a/src/feishu/reactions.ts b/extensions/feishu/src/reactions.ts similarity index 68% rename from src/feishu/reactions.ts rename to extensions/feishu/src/reactions.ts index 05b48ec77d..44aa73c913 100644 --- a/src/feishu/reactions.ts +++ b/extensions/feishu/src/reactions.ts @@ -1,8 +1,7 @@ -import type { Client } from "@larksuiteoapi/node-sdk"; +import type { ClawdbotConfig } from "openclaw/plugin-sdk"; +import type { FeishuConfig } from "./types.js"; +import { createFeishuClient } from "./client.js"; -/** - * Reaction info returned from Feishu API - */ export type FeishuReaction = { reactionId: string; emojiType: string; @@ -12,14 +11,22 @@ export type FeishuReaction = { /** * Add a reaction (emoji) to a message. - * @param emojiType - Feishu emoji type, e.g., "SMILE", "THUMBSUP", "HEART", "Typing" + * @param emojiType - Feishu emoji type, e.g., "SMILE", "THUMBSUP", "HEART" * @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 }> { +export async function addReactionFeishu(params: { + cfg: ClawdbotConfig; + messageId: string; + emojiType: string; +}): Promise<{ reactionId: string }> { + const { cfg, messageId, emojiType } = params; + const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; + if (!feishuCfg) { + throw new Error("Feishu channel not configured"); + } + + const client = createFeishuClient(feishuCfg); + const response = (await client.im.messageReaction.create({ path: { message_id: messageId }, data: { @@ -48,11 +55,19 @@ export async function addReactionFeishu( /** * Remove a reaction from a message. */ -export async function removeReactionFeishu( - client: Client, - messageId: string, - reactionId: string, -): Promise { +export async function removeReactionFeishu(params: { + cfg: ClawdbotConfig; + messageId: string; + reactionId: string; +}): Promise { + const { cfg, messageId, reactionId } = params; + const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; + if (!feishuCfg) { + throw new Error("Feishu channel not configured"); + } + + const client = createFeishuClient(feishuCfg); + const response = (await client.im.messageReaction.delete({ path: { message_id: messageId, @@ -68,11 +83,19 @@ export async function removeReactionFeishu( /** * List all reactions for a message. */ -export async function listReactionsFeishu( - client: Client, - messageId: string, - emojiType?: string, -): Promise { +export async function listReactionsFeishu(params: { + cfg: ClawdbotConfig; + messageId: string; + emojiType?: string; +}): Promise { + const { cfg, messageId, emojiType } = params; + const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; + if (!feishuCfg) { + throw new Error("Feishu channel not configured"); + } + + const client = createFeishuClient(feishuCfg); + const response = (await client.im.messageReaction.list({ path: { message_id: messageId }, params: emojiType ? { reaction_type: emojiType } : undefined, @@ -129,8 +152,6 @@ export const FeishuEmoji = { CROSS: "CROSS", QUESTION: "QUESTION", EXCLAMATION: "EXCLAMATION", - // Special typing indicator - TYPING: "Typing", } as const; export type FeishuEmojiType = (typeof FeishuEmoji)[keyof typeof FeishuEmoji]; diff --git a/extensions/feishu/src/reply-dispatcher.ts b/extensions/feishu/src/reply-dispatcher.ts new file mode 100644 index 0000000000..a6ab843e36 --- /dev/null +++ b/extensions/feishu/src/reply-dispatcher.ts @@ -0,0 +1,161 @@ +import { + createReplyPrefixContext, + createTypingCallbacks, + logTypingFailure, + type ClawdbotConfig, + type RuntimeEnv, + type ReplyPayload, +} from "openclaw/plugin-sdk"; +import type { MentionTarget } from "./mention.js"; +import type { FeishuConfig } from "./types.js"; +import { getFeishuRuntime } from "./runtime.js"; +import { sendMessageFeishu, sendMarkdownCardFeishu } from "./send.js"; +import { addTypingIndicator, removeTypingIndicator, type TypingIndicatorState } from "./typing.js"; + +/** + * Detect if text contains markdown elements that benefit from card rendering. + * Used by auto render mode. + */ +function shouldUseCard(text: string): boolean { + // Code blocks (fenced) + if (/```[\s\S]*?```/.test(text)) return true; + // Tables (at least header + separator row with |) + if (/\|.+\|[\r\n]+\|[-:| ]+\|/.test(text)) return true; + return false; +} + +export type CreateFeishuReplyDispatcherParams = { + cfg: ClawdbotConfig; + agentId: string; + runtime: RuntimeEnv; + chatId: string; + replyToMessageId?: string; + /** Mention targets, will be auto-included in replies */ + mentionTargets?: MentionTarget[]; +}; + +export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherParams) { + const core = getFeishuRuntime(); + const { cfg, agentId, chatId, replyToMessageId, mentionTargets } = params; + + const prefixContext = createReplyPrefixContext({ + cfg, + agentId, + }); + + // Feishu doesn't have a native typing indicator API. + // We use message reactions as a typing indicator substitute. + let typingState: TypingIndicatorState | null = null; + + const typingCallbacks = createTypingCallbacks({ + start: async () => { + if (!replyToMessageId) return; + typingState = await addTypingIndicator({ cfg, messageId: replyToMessageId }); + params.runtime.log?.(`feishu: added typing indicator reaction`); + }, + stop: async () => { + if (!typingState) return; + await removeTypingIndicator({ cfg, state: typingState }); + typingState = null; + params.runtime.log?.(`feishu: removed typing indicator reaction`); + }, + onStartError: (err) => { + logTypingFailure({ + log: (message) => params.runtime.log?.(message), + channel: "feishu", + action: "start", + error: err, + }); + }, + onStopError: (err) => { + logTypingFailure({ + log: (message) => params.runtime.log?.(message), + channel: "feishu", + action: "stop", + error: err, + }); + }, + }); + + const textChunkLimit = core.channel.text.resolveTextChunkLimit({ + cfg, + channel: "feishu", + defaultLimit: 4000, + }); + const chunkMode = core.channel.text.resolveChunkMode(cfg, "feishu"); + const tableMode = core.channel.text.resolveMarkdownTableMode({ + cfg, + channel: "feishu", + }); + + const { dispatcher, replyOptions, markDispatchIdle } = + core.channel.reply.createReplyDispatcherWithTyping({ + responsePrefix: prefixContext.responsePrefix, + responsePrefixContextProvider: prefixContext.responsePrefixContextProvider, + humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, agentId), + onReplyStart: typingCallbacks.onReplyStart, + deliver: async (payload: ReplyPayload) => { + params.runtime.log?.(`feishu deliver called: text=${payload.text?.slice(0, 100)}`); + const text = payload.text ?? ""; + if (!text.trim()) { + params.runtime.log?.(`feishu deliver: empty text, skipping`); + return; + } + + // Check render mode: auto (default), raw, or card + const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; + const renderMode = feishuCfg?.renderMode ?? "auto"; + + // Determine if we should use card for this message + const useCard = renderMode === "card" || (renderMode === "auto" && shouldUseCard(text)); + + // Only include @mentions in the first chunk (avoid duplicate @s) + let isFirstChunk = true; + + if (useCard) { + // Card mode: send as interactive card with markdown rendering + const chunks = core.channel.text.chunkTextWithMode(text, textChunkLimit, chunkMode); + params.runtime.log?.(`feishu deliver: sending ${chunks.length} card chunks to ${chatId}`); + for (const chunk of chunks) { + await sendMarkdownCardFeishu({ + cfg, + to: chatId, + text: chunk, + replyToMessageId, + mentions: isFirstChunk ? mentionTargets : undefined, + }); + isFirstChunk = false; + } + } else { + // Raw mode: send as plain text with table conversion + const converted = core.channel.text.convertMarkdownTables(text, tableMode); + const chunks = core.channel.text.chunkTextWithMode(converted, textChunkLimit, chunkMode); + params.runtime.log?.(`feishu deliver: sending ${chunks.length} text chunks to ${chatId}`); + for (const chunk of chunks) { + await sendMessageFeishu({ + cfg, + to: chatId, + text: chunk, + replyToMessageId, + mentions: isFirstChunk ? mentionTargets : undefined, + }); + isFirstChunk = false; + } + } + }, + onError: (err, info) => { + params.runtime.error?.(`feishu ${info.kind} reply failed: ${String(err)}`); + typingCallbacks.onIdle?.(); + }, + onIdle: typingCallbacks.onIdle, + }); + + return { + dispatcher, + replyOptions: { + ...replyOptions, + onModelSelected: prefixContext.onModelSelected, + }, + markDispatchIdle, + }; +} diff --git a/extensions/feishu/src/runtime.ts b/extensions/feishu/src/runtime.ts new file mode 100644 index 0000000000..f1148c5e7d --- /dev/null +++ b/extensions/feishu/src/runtime.ts @@ -0,0 +1,14 @@ +import type { PluginRuntime } from "openclaw/plugin-sdk"; + +let runtime: PluginRuntime | null = null; + +export function setFeishuRuntime(next: PluginRuntime) { + runtime = next; +} + +export function getFeishuRuntime(): PluginRuntime { + if (!runtime) { + throw new Error("Feishu runtime not initialized"); + } + return runtime; +} diff --git a/extensions/feishu/src/send.ts b/extensions/feishu/src/send.ts new file mode 100644 index 0000000000..fb7fdd5d25 --- /dev/null +++ b/extensions/feishu/src/send.ts @@ -0,0 +1,356 @@ +import type { ClawdbotConfig } from "openclaw/plugin-sdk"; +import type { MentionTarget } from "./mention.js"; +import type { FeishuConfig, FeishuSendResult } from "./types.js"; +import { createFeishuClient } from "./client.js"; +import { buildMentionedMessage, buildMentionedCardContent } from "./mention.js"; +import { getFeishuRuntime } from "./runtime.js"; +import { resolveReceiveIdType, normalizeFeishuTarget } from "./targets.js"; + +export type FeishuMessageInfo = { + messageId: string; + chatId: string; + senderId?: string; + senderOpenId?: string; + content: string; + contentType: string; + createTime?: number; +}; + +/** + * Get a message by its ID. + * Useful for fetching quoted/replied message content. + */ +export async function getMessageFeishu(params: { + cfg: ClawdbotConfig; + messageId: string; +}): Promise { + const { cfg, messageId } = params; + const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; + if (!feishuCfg) { + throw new Error("Feishu channel not configured"); + } + + const client = createFeishuClient(feishuCfg); + + try { + const response = (await client.im.message.get({ + path: { message_id: messageId }, + })) as { + code?: number; + msg?: string; + data?: { + items?: Array<{ + message_id?: string; + chat_id?: string; + msg_type?: string; + body?: { content?: string }; + sender?: { + id?: string; + id_type?: string; + sender_type?: string; + }; + create_time?: string; + }>; + }; + }; + + if (response.code !== 0) { + return null; + } + + const item = response.data?.items?.[0]; + if (!item) { + return null; + } + + // Parse content based on message type + let content = item.body?.content ?? ""; + try { + const parsed = JSON.parse(content); + if (item.msg_type === "text" && parsed.text) { + content = parsed.text; + } + } catch { + // Keep raw content if parsing fails + } + + return { + messageId: item.message_id ?? messageId, + chatId: item.chat_id ?? "", + senderId: item.sender?.id, + senderOpenId: item.sender?.id_type === "open_id" ? item.sender?.id : undefined, + content, + contentType: item.msg_type ?? "text", + createTime: item.create_time ? parseInt(item.create_time, 10) : undefined, + }; + } catch { + return null; + } +} + +export type SendFeishuMessageParams = { + cfg: ClawdbotConfig; + to: string; + text: string; + replyToMessageId?: string; + /** Mention target users */ + mentions?: MentionTarget[]; +}; + +function buildFeishuPostMessagePayload(params: { feishuCfg: FeishuConfig; messageText: string }): { + content: string; + msgType: string; +} { + const { messageText } = params; + return { + content: JSON.stringify({ + zh_cn: { + content: [ + [ + { + tag: "md", + text: messageText, + }, + ], + ], + }, + }), + msgType: "post", + }; +} + +export async function sendMessageFeishu( + params: SendFeishuMessageParams, +): Promise { + const { cfg, to, text, replyToMessageId, mentions } = params; + const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; + if (!feishuCfg) { + throw new Error("Feishu channel not configured"); + } + + const client = createFeishuClient(feishuCfg); + const receiveId = normalizeFeishuTarget(to); + if (!receiveId) { + throw new Error(`Invalid Feishu target: ${to}`); + } + + const receiveIdType = resolveReceiveIdType(receiveId); + const tableMode = getFeishuRuntime().channel.text.resolveMarkdownTableMode({ + cfg, + channel: "feishu", + }); + + // Build message content (with @mention support) + let rawText = text ?? ""; + if (mentions && mentions.length > 0) { + rawText = buildMentionedMessage(mentions, rawText); + } + const messageText = getFeishuRuntime().channel.text.convertMarkdownTables(rawText, tableMode); + + const { content, msgType } = buildFeishuPostMessagePayload({ + feishuCfg, + messageText, + }); + + if (replyToMessageId) { + const response = await client.im.message.reply({ + path: { message_id: replyToMessageId }, + data: { + content, + msg_type: msgType, + }, + }); + + if (response.code !== 0) { + throw new Error(`Feishu reply failed: ${response.msg || `code ${response.code}`}`); + } + + return { + messageId: response.data?.message_id ?? "unknown", + chatId: receiveId, + }; + } + + const response = await client.im.message.create({ + params: { receive_id_type: receiveIdType }, + data: { + receive_id: receiveId, + content, + msg_type: msgType, + }, + }); + + if (response.code !== 0) { + throw new Error(`Feishu send failed: ${response.msg || `code ${response.code}`}`); + } + + return { + messageId: response.data?.message_id ?? "unknown", + chatId: receiveId, + }; +} + +export type SendFeishuCardParams = { + cfg: ClawdbotConfig; + to: string; + card: Record; + replyToMessageId?: string; +}; + +export async function sendCardFeishu(params: SendFeishuCardParams): Promise { + const { cfg, to, card, replyToMessageId } = params; + const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; + if (!feishuCfg) { + throw new Error("Feishu channel not configured"); + } + + const client = createFeishuClient(feishuCfg); + const receiveId = normalizeFeishuTarget(to); + if (!receiveId) { + throw new Error(`Invalid Feishu target: ${to}`); + } + + const receiveIdType = resolveReceiveIdType(receiveId); + const content = JSON.stringify(card); + + if (replyToMessageId) { + const response = await client.im.message.reply({ + path: { message_id: replyToMessageId }, + data: { + content, + msg_type: "interactive", + }, + }); + + if (response.code !== 0) { + throw new Error(`Feishu card reply failed: ${response.msg || `code ${response.code}`}`); + } + + return { + messageId: response.data?.message_id ?? "unknown", + chatId: receiveId, + }; + } + + const response = await client.im.message.create({ + params: { receive_id_type: receiveIdType }, + data: { + receive_id: receiveId, + content, + msg_type: "interactive", + }, + }); + + if (response.code !== 0) { + throw new Error(`Feishu card send failed: ${response.msg || `code ${response.code}`}`); + } + + return { + messageId: response.data?.message_id ?? "unknown", + chatId: receiveId, + }; +} + +export async function updateCardFeishu(params: { + cfg: ClawdbotConfig; + messageId: string; + card: Record; +}): Promise { + const { cfg, messageId, card } = params; + const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; + if (!feishuCfg) { + throw new Error("Feishu channel not configured"); + } + + const client = createFeishuClient(feishuCfg); + const content = JSON.stringify(card); + + const response = await client.im.message.patch({ + path: { message_id: messageId }, + data: { content }, + }); + + if (response.code !== 0) { + throw new Error(`Feishu card update failed: ${response.msg || `code ${response.code}`}`); + } +} + +/** + * Build a Feishu interactive card with markdown content. + * Cards render markdown properly (code blocks, tables, links, etc.) + */ +export function buildMarkdownCard(text: string): Record { + return { + config: { + wide_screen_mode: true, + }, + elements: [ + { + tag: "markdown", + content: text, + }, + ], + }; +} + +/** + * Send a message as a markdown card (interactive message). + * This renders markdown properly in Feishu (code blocks, tables, bold/italic, etc.) + */ +export async function sendMarkdownCardFeishu(params: { + cfg: ClawdbotConfig; + to: string; + text: string; + replyToMessageId?: string; + /** Mention target users */ + mentions?: MentionTarget[]; +}): Promise { + const { cfg, to, text, replyToMessageId, mentions } = params; + // Build message content (with @mention support) + let cardText = text; + if (mentions && mentions.length > 0) { + cardText = buildMentionedCardContent(mentions, text); + } + const card = buildMarkdownCard(cardText); + return sendCardFeishu({ cfg, to, card, replyToMessageId }); +} + +/** + * Edit an existing text message. + * Note: Feishu only allows editing messages within 24 hours. + */ +export async function editMessageFeishu(params: { + cfg: ClawdbotConfig; + messageId: string; + text: string; +}): Promise { + const { cfg, messageId, text } = params; + const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; + if (!feishuCfg) { + throw new Error("Feishu channel not configured"); + } + + const client = createFeishuClient(feishuCfg); + const tableMode = getFeishuRuntime().channel.text.resolveMarkdownTableMode({ + cfg, + channel: "feishu", + }); + const messageText = getFeishuRuntime().channel.text.convertMarkdownTables(text ?? "", tableMode); + + const { content, msgType } = buildFeishuPostMessagePayload({ + feishuCfg, + messageText, + }); + + const response = await client.im.message.update({ + path: { message_id: messageId }, + data: { + msg_type: msgType, + content, + }, + }); + + if (response.code !== 0) { + throw new Error(`Feishu message edit failed: ${response.msg || `code ${response.code}`}`); + } +} diff --git a/extensions/feishu/src/targets.ts b/extensions/feishu/src/targets.ts new file mode 100644 index 0000000000..16d3e99b9e --- /dev/null +++ b/extensions/feishu/src/targets.ts @@ -0,0 +1,58 @@ +import type { FeishuIdType } from "./types.js"; + +const CHAT_ID_PREFIX = "oc_"; +const OPEN_ID_PREFIX = "ou_"; +const USER_ID_REGEX = /^[a-zA-Z0-9_-]+$/; + +export function detectIdType(id: string): FeishuIdType | null { + const trimmed = id.trim(); + if (trimmed.startsWith(CHAT_ID_PREFIX)) return "chat_id"; + if (trimmed.startsWith(OPEN_ID_PREFIX)) return "open_id"; + if (USER_ID_REGEX.test(trimmed)) return "user_id"; + return null; +} + +export function normalizeFeishuTarget(raw: string): string | null { + const trimmed = raw.trim(); + if (!trimmed) return null; + + const lowered = trimmed.toLowerCase(); + if (lowered.startsWith("chat:")) { + return trimmed.slice("chat:".length).trim() || null; + } + if (lowered.startsWith("user:")) { + return trimmed.slice("user:".length).trim() || null; + } + if (lowered.startsWith("open_id:")) { + return trimmed.slice("open_id:".length).trim() || null; + } + + return trimmed; +} + +export function formatFeishuTarget(id: string, type?: FeishuIdType): string { + const trimmed = id.trim(); + if (type === "chat_id" || trimmed.startsWith(CHAT_ID_PREFIX)) { + return `chat:${trimmed}`; + } + if (type === "open_id" || trimmed.startsWith(OPEN_ID_PREFIX)) { + return `user:${trimmed}`; + } + return trimmed; +} + +export function resolveReceiveIdType(id: string): "chat_id" | "open_id" | "user_id" { + const trimmed = id.trim(); + if (trimmed.startsWith(CHAT_ID_PREFIX)) return "chat_id"; + if (trimmed.startsWith(OPEN_ID_PREFIX)) return "open_id"; + return "open_id"; +} + +export function looksLikeFeishuId(raw: string): boolean { + const trimmed = raw.trim(); + if (!trimmed) return false; + if (/^(chat|user|open_id):/i.test(trimmed)) return true; + if (trimmed.startsWith(CHAT_ID_PREFIX)) return true; + if (trimmed.startsWith(OPEN_ID_PREFIX)) return true; + return false; +} diff --git a/extensions/feishu/src/tools-config.ts b/extensions/feishu/src/tools-config.ts new file mode 100644 index 0000000000..1c1321ee42 --- /dev/null +++ b/extensions/feishu/src/tools-config.ts @@ -0,0 +1,21 @@ +import type { FeishuToolsConfig } from "./types.js"; + +/** + * Default tool configuration. + * - doc, wiki, drive, scopes: enabled by default + * - perm: disabled by default (sensitive operation) + */ +export const DEFAULT_TOOLS_CONFIG: Required = { + doc: true, + wiki: true, + drive: true, + perm: false, + scopes: true, +}; + +/** + * Resolve tools config with defaults. + */ +export function resolveToolsConfig(cfg?: FeishuToolsConfig): Required { + return { ...DEFAULT_TOOLS_CONFIG, ...cfg }; +} diff --git a/extensions/feishu/src/types.ts b/extensions/feishu/src/types.ts new file mode 100644 index 0000000000..1ab2d26129 --- /dev/null +++ b/extensions/feishu/src/types.ts @@ -0,0 +1,63 @@ +import type { FeishuConfigSchema, FeishuGroupSchema, z } from "./config-schema.js"; +import type { MentionTarget } from "./mention.js"; + +export type FeishuConfig = z.infer; +export type FeishuGroupConfig = z.infer; + +export type FeishuDomain = "feishu" | "lark" | (string & {}); +export type FeishuConnectionMode = "websocket" | "webhook"; + +export type ResolvedFeishuAccount = { + accountId: string; + enabled: boolean; + configured: boolean; + appId?: string; + domain: FeishuDomain; +}; + +export type FeishuIdType = "open_id" | "user_id" | "union_id" | "chat_id"; + +export type FeishuMessageContext = { + chatId: string; + messageId: string; + senderId: string; + senderOpenId: string; + senderName?: string; + chatType: "p2p" | "group"; + mentionedBot: boolean; + rootId?: string; + parentId?: string; + content: string; + contentType: string; + /** Mention forward targets (excluding the bot itself) */ + mentionTargets?: MentionTarget[]; + /** Extracted message body (after removing @ placeholders) */ + mentionMessageBody?: string; +}; + +export type FeishuSendResult = { + messageId: string; + chatId: string; +}; + +export type FeishuProbeResult = { + ok: boolean; + error?: string; + appId?: string; + botName?: string; + botOpenId?: string; +}; + +export type FeishuMediaInfo = { + path: string; + contentType?: string; + placeholder: string; +}; + +export type FeishuToolsConfig = { + doc?: boolean; + wiki?: boolean; + drive?: boolean; + perm?: boolean; + scopes?: boolean; +}; diff --git a/extensions/feishu/src/typing.ts b/extensions/feishu/src/typing.ts new file mode 100644 index 0000000000..e316f65dbf --- /dev/null +++ b/extensions/feishu/src/typing.ts @@ -0,0 +1,73 @@ +import type { ClawdbotConfig } from "openclaw/plugin-sdk"; +import type { FeishuConfig } from "./types.js"; +import { createFeishuClient } from "./client.js"; + +// Feishu emoji types for typing indicator +// See: https://open.feishu.cn/document/server-docs/im-v1/message-reaction/emojis-introduce +// Full list: https://github.com/go-lark/lark/blob/main/emoji.go +const TYPING_EMOJI = "Typing"; // Typing indicator emoji + +export type TypingIndicatorState = { + messageId: string; + reactionId: string | null; +}; + +/** + * Add a typing indicator (reaction) to a message + */ +export async function addTypingIndicator(params: { + cfg: ClawdbotConfig; + messageId: string; +}): Promise { + const { cfg, messageId } = params; + const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; + if (!feishuCfg) { + return { messageId, reactionId: null }; + } + + const client = createFeishuClient(feishuCfg); + + try { + const response = await client.im.messageReaction.create({ + path: { message_id: messageId }, + data: { + reaction_type: { emoji_type: TYPING_EMOJI }, + }, + }); + + const reactionId = (response as any)?.data?.reaction_id ?? null; + return { messageId, reactionId }; + } catch (err) { + // Silently fail - typing indicator is not critical + console.log(`[feishu] failed to add typing indicator: ${err}`); + return { messageId, reactionId: null }; + } +} + +/** + * Remove a typing indicator (reaction) from a message + */ +export async function removeTypingIndicator(params: { + cfg: ClawdbotConfig; + state: TypingIndicatorState; +}): Promise { + const { cfg, state } = params; + if (!state.reactionId) return; + + const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; + if (!feishuCfg) return; + + const client = createFeishuClient(feishuCfg); + + try { + await client.im.messageReaction.delete({ + path: { + message_id: state.messageId, + reaction_id: state.reactionId, + }, + }); + } catch (err) { + // Silently fail - cleanup is not critical + console.log(`[feishu] failed to remove typing indicator: ${err}`); + } +} diff --git a/extensions/feishu/src/wiki-schema.ts b/extensions/feishu/src/wiki-schema.ts new file mode 100644 index 0000000000..006cc2da39 --- /dev/null +++ b/extensions/feishu/src/wiki-schema.ts @@ -0,0 +1,55 @@ +import { Type, type Static } from "@sinclair/typebox"; + +export const FeishuWikiSchema = Type.Union([ + Type.Object({ + action: Type.Literal("spaces"), + }), + Type.Object({ + action: Type.Literal("nodes"), + space_id: Type.String({ description: "Knowledge space ID" }), + parent_node_token: Type.Optional( + Type.String({ description: "Parent node token (optional, omit for root)" }), + ), + }), + Type.Object({ + action: Type.Literal("get"), + token: Type.String({ description: "Wiki node token (from URL /wiki/XXX)" }), + }), + Type.Object({ + action: Type.Literal("search"), + query: Type.String({ description: "Search query" }), + space_id: Type.Optional(Type.String({ description: "Limit search to this space (optional)" })), + }), + Type.Object({ + action: Type.Literal("create"), + space_id: Type.String({ description: "Knowledge space ID" }), + title: Type.String({ description: "Node title" }), + obj_type: Type.Optional( + Type.Union([Type.Literal("docx"), Type.Literal("sheet"), Type.Literal("bitable")], { + description: "Object type (default: docx)", + }), + ), + parent_node_token: Type.Optional( + Type.String({ description: "Parent node token (optional, omit for root)" }), + ), + }), + Type.Object({ + action: Type.Literal("move"), + space_id: Type.String({ description: "Source knowledge space ID" }), + node_token: Type.String({ description: "Node token to move" }), + target_space_id: Type.Optional( + Type.String({ description: "Target space ID (optional, same space if omitted)" }), + ), + target_parent_token: Type.Optional( + Type.String({ description: "Target parent node token (optional, root if omitted)" }), + ), + }), + Type.Object({ + action: Type.Literal("rename"), + space_id: Type.String({ description: "Knowledge space ID" }), + node_token: Type.String({ description: "Node token to rename" }), + title: Type.String({ description: "New title" }), + }), +]); + +export type FeishuWikiParams = Static; diff --git a/extensions/feishu/src/wiki.ts b/extensions/feishu/src/wiki.ts new file mode 100644 index 0000000000..1a1b72d4f1 --- /dev/null +++ b/extensions/feishu/src/wiki.ts @@ -0,0 +1,213 @@ +import type * as Lark from "@larksuiteoapi/node-sdk"; +import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; +import type { FeishuConfig } from "./types.js"; +import { createFeishuClient } from "./client.js"; +import { resolveToolsConfig } from "./tools-config.js"; +import { FeishuWikiSchema, type FeishuWikiParams } from "./wiki-schema.js"; + +// ============ Helpers ============ + +function json(data: unknown) { + return { + content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }], + details: data, + }; +} + +type ObjType = "doc" | "sheet" | "mindnote" | "bitable" | "file" | "docx" | "slides"; + +// ============ Actions ============ + +const WIKI_ACCESS_HINT = + "To grant wiki access: Open wiki space → Settings → Members → Add the bot. " + + "See: https://open.feishu.cn/document/server-docs/docs/wiki-v2/wiki-qa#a40ad4ca"; + +async function listSpaces(client: Lark.Client) { + const res = await client.wiki.space.list({}); + if (res.code !== 0) throw new Error(res.msg); + + const spaces = + res.data?.items?.map((s) => ({ + space_id: s.space_id, + name: s.name, + description: s.description, + visibility: s.visibility, + })) ?? []; + + return { + spaces, + ...(spaces.length === 0 && { hint: WIKI_ACCESS_HINT }), + }; +} + +async function listNodes(client: Lark.Client, spaceId: string, parentNodeToken?: string) { + const res = await client.wiki.spaceNode.list({ + path: { space_id: spaceId }, + params: { parent_node_token: parentNodeToken }, + }); + if (res.code !== 0) throw new Error(res.msg); + + return { + nodes: + res.data?.items?.map((n) => ({ + node_token: n.node_token, + obj_token: n.obj_token, + obj_type: n.obj_type, + title: n.title, + has_child: n.has_child, + })) ?? [], + }; +} + +async function getNode(client: Lark.Client, token: string) { + const res = await client.wiki.space.getNode({ + params: { token }, + }); + if (res.code !== 0) throw new Error(res.msg); + + const node = res.data?.node; + return { + node_token: node?.node_token, + space_id: node?.space_id, + obj_token: node?.obj_token, + obj_type: node?.obj_type, + title: node?.title, + parent_node_token: node?.parent_node_token, + has_child: node?.has_child, + creator: node?.creator, + create_time: node?.node_create_time, + }; +} + +async function createNode( + client: Lark.Client, + spaceId: string, + title: string, + objType?: string, + parentNodeToken?: string, +) { + const res = await client.wiki.spaceNode.create({ + path: { space_id: spaceId }, + data: { + obj_type: (objType as ObjType) || "docx", + node_type: "origin" as const, + title, + parent_node_token: parentNodeToken, + }, + }); + if (res.code !== 0) throw new Error(res.msg); + + const node = res.data?.node; + return { + node_token: node?.node_token, + obj_token: node?.obj_token, + obj_type: node?.obj_type, + title: node?.title, + }; +} + +async function moveNode( + client: Lark.Client, + spaceId: string, + nodeToken: string, + targetSpaceId?: string, + targetParentToken?: string, +) { + const res = await client.wiki.spaceNode.move({ + path: { space_id: spaceId, node_token: nodeToken }, + data: { + target_space_id: targetSpaceId || spaceId, + target_parent_token: targetParentToken, + }, + }); + if (res.code !== 0) throw new Error(res.msg); + + return { + success: true, + node_token: res.data?.node?.node_token, + }; +} + +async function renameNode(client: Lark.Client, spaceId: string, nodeToken: string, title: string) { + const res = await client.wiki.spaceNode.updateTitle({ + path: { space_id: spaceId, node_token: nodeToken }, + data: { title }, + }); + if (res.code !== 0) throw new Error(res.msg); + + return { + success: true, + node_token: nodeToken, + title, + }; +} + +// ============ Tool Registration ============ + +export function registerFeishuWikiTools(api: OpenClawPluginApi) { + const feishuCfg = api.config?.channels?.feishu as FeishuConfig | undefined; + if (!feishuCfg?.appId || !feishuCfg?.appSecret) { + api.logger.debug?.("feishu_wiki: Feishu credentials not configured, skipping wiki tools"); + return; + } + + const toolsCfg = resolveToolsConfig(feishuCfg.tools); + if (!toolsCfg.wiki) { + api.logger.debug?.("feishu_wiki: wiki tool disabled in config"); + return; + } + + const getClient = () => createFeishuClient(feishuCfg); + + api.registerTool( + { + name: "feishu_wiki", + label: "Feishu Wiki", + description: + "Feishu knowledge base operations. Actions: spaces, nodes, get, create, move, rename", + parameters: FeishuWikiSchema, + async execute(_toolCallId, params) { + const p = params as FeishuWikiParams; + try { + const client = getClient(); + switch (p.action) { + case "spaces": + return json(await listSpaces(client)); + case "nodes": + return json(await listNodes(client, p.space_id, p.parent_node_token)); + case "get": + return json(await getNode(client, p.token)); + case "search": + return json({ + error: + "Search is not available. Use feishu_wiki with action: 'nodes' to browse or action: 'get' to lookup by token.", + }); + case "create": + return json( + await createNode(client, p.space_id, p.title, p.obj_type, p.parent_node_token), + ); + case "move": + return json( + await moveNode( + client, + p.space_id, + p.node_token, + p.target_space_id, + p.target_parent_token, + ), + ); + case "rename": + return json(await renameNode(client, p.space_id, p.node_token, p.title)); + default: + return json({ error: `Unknown action: ${(p as any).action}` }); + } + } catch (err) { + return json({ error: err instanceof Error ? err.message : String(err) }); + } + }, + }, + { name: "feishu_wiki" }, + ); + + api.logger.info?.(`feishu_wiki: Registered feishu_wiki tool`); +} diff --git a/src/channels/plugins/normalize/feishu.ts b/src/channels/plugins/normalize/feishu.ts deleted file mode 100644 index bd5efae754..0000000000 --- a/src/channels/plugins/normalize/feishu.ts +++ /dev/null @@ -1,5 +0,0 @@ -export function normalizeFeishuTarget(raw: string): string { - let normalized = raw.replace(/^(feishu|lark):/i, "").trim(); - normalized = normalized.replace(/^(group|chat|user|dm):/i, "").trim(); - return normalized; -} diff --git a/src/channels/plugins/outbound/feishu.ts b/src/channels/plugins/outbound/feishu.ts deleted file mode 100644 index 20a2b78cdc..0000000000 --- a/src/channels/plugins/outbound/feishu.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { ChannelOutboundAdapter } from "../types.js"; -import { chunkMarkdownText } from "../../../auto-reply/chunk.js"; -import { getFeishuClient } from "../../../feishu/client.js"; -import { sendMessageFeishu } from "../../../feishu/send.js"; - -function resolveReceiveIdType(target: string): "open_id" | "union_id" | "chat_id" { - const trimmed = target.trim().toLowerCase(); - if (trimmed.startsWith("ou_")) { - return "open_id"; - } - if (trimmed.startsWith("on_")) { - return "union_id"; - } - return "chat_id"; -} - -export const feishuOutbound: ChannelOutboundAdapter = { - deliveryMode: "direct", - chunker: (text, limit) => chunkMarkdownText(text, limit), - chunkerMode: "markdown", - textChunkLimit: 2000, - sendText: async ({ to, text, accountId }) => { - const client = getFeishuClient(accountId ?? undefined); - const result = await sendMessageFeishu( - client, - to, - { text }, - { - receiveIdType: resolveReceiveIdType(to), - }, - ); - return { - channel: "feishu", - messageId: result?.message_id || "unknown", - chatId: to, - }; - }, - sendMedia: async ({ to, text, mediaUrl, accountId }) => { - const client = getFeishuClient(accountId ?? undefined); - const result = await sendMessageFeishu( - client, - to, - { text: text || "" }, - { mediaUrl, receiveIdType: resolveReceiveIdType(to) }, - ); - return { - channel: "feishu", - messageId: result?.message_id || "unknown", - chatId: to, - }; - }, -}; diff --git a/src/config/types.channels.ts b/src/config/types.channels.ts index f48f516955..b6319f3a53 100644 --- a/src/config/types.channels.ts +++ b/src/config/types.channels.ts @@ -1,6 +1,5 @@ import type { GroupPolicy } from "./types.base.js"; import type { DiscordConfig } from "./types.discord.js"; -import type { FeishuConfig } from "./types.feishu.js"; import type { GoogleChatConfig } from "./types.googlechat.js"; import type { IMessageConfig } from "./types.imessage.js"; import type { MSTeamsConfig } from "./types.msteams.js"; @@ -29,7 +28,6 @@ export type ChannelsConfig = { whatsapp?: WhatsAppConfig; telegram?: TelegramConfig; discord?: DiscordConfig; - feishu?: FeishuConfig; googlechat?: GoogleChatConfig; slack?: SlackConfig; signal?: SignalConfig; diff --git a/src/config/types.feishu.ts b/src/config/types.feishu.ts deleted file mode 100644 index 1cb2288ee2..0000000000 --- a/src/config/types.feishu.ts +++ /dev/null @@ -1,100 +0,0 @@ -import type { DmPolicy, GroupPolicy, MarkdownConfig, OutboundRetryConfig } from "./types.base.js"; -import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js"; -import type { DmConfig, ProviderCommandsConfig } from "./types.messages.js"; -import type { GroupToolPolicyBySenderConfig, GroupToolPolicyConfig } from "./types.tools.js"; - -export type FeishuGroupConfig = { - requireMention?: boolean; - /** Optional tool policy overrides for this group. */ - tools?: GroupToolPolicyConfig; - toolsBySender?: GroupToolPolicyBySenderConfig; - /** If specified, only load these skills for this group. Omit = all skills; empty = no skills. */ - skills?: string[]; - /** If false, disable the bot for this group. */ - enabled?: boolean; - /** Optional allowlist for group senders (open_ids). */ - allowFrom?: Array; - /** Optional system prompt snippet for this group. */ - systemPrompt?: string; -}; - -export type FeishuAccountConfig = { - /** Optional display name for this account (used in CLI/UI lists). */ - name?: string; - /** Feishu app ID (cli_xxx). */ - appId?: string; - /** Feishu app secret. */ - appSecret?: string; - /** Path to file containing app secret (for secret managers). */ - appSecretFile?: string; - /** API domain override: "feishu" (default), "lark" (global), or full https:// domain. */ - domain?: string; - /** Bot display name (used for streaming card title). */ - botName?: string; - /** If false, do not start this Feishu account. Default: true. */ - enabled?: boolean; - /** Markdown formatting overrides (tables). */ - markdown?: MarkdownConfig; - /** Override native command registration for Feishu (bool or "auto"). */ - commands?: ProviderCommandsConfig; - /** Allow channel-initiated config writes (default: true). */ - configWrites?: boolean; - /** - * Controls how Feishu direct chats (DMs) are handled: - * - "pairing" (default): unknown senders get a pairing code; owner must approve - * - "allowlist": only allow senders in allowFrom (or paired allow store) - * - "open": allow all inbound DMs (requires allowFrom to include "*") - * - "disabled": ignore all inbound DMs - */ - dmPolicy?: DmPolicy; - /** - * Controls how group messages are handled: - * - "open": groups bypass allowFrom, only mention-gating applies - * - "disabled": block all group messages entirely - * - "allowlist": only allow group messages from senders in groupAllowFrom/allowFrom - */ - groupPolicy?: GroupPolicy; - /** Allowlist for DM senders (open_id or union_id). */ - allowFrom?: Array; - /** Optional allowlist for Feishu group senders. */ - groupAllowFrom?: Array; - /** Max group messages to keep as history context (0 disables). */ - historyLimit?: number; - /** Max DM turns to keep as history context. */ - dmHistoryLimit?: number; - /** Per-DM config overrides keyed by user open_id. */ - dms?: Record; - /** Per-group config keyed by chat_id (oc_xxx). */ - groups?: Record; - /** Outbound text chunk size (chars). Default: 2000. */ - textChunkLimit?: number; - /** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */ - chunkMode?: "length" | "newline"; - /** Disable block streaming for this account. */ - blockStreaming?: boolean; - /** - * Enable streaming card mode for replies (shows typing indicator). - * When true, replies are streamed via Feishu's CardKit API with typewriter effect. - * Default: true. - */ - streaming?: boolean; - /** Media max size in MB. */ - mediaMaxMb?: number; - /** Retry policy for outbound Feishu API calls. */ - retry?: OutboundRetryConfig; - /** Heartbeat visibility settings for this channel. */ - heartbeat?: ChannelHeartbeatVisibilityConfig; - /** Outbound response prefix override for this channel/account. */ - responsePrefix?: string; -}; - -export type FeishuConfig = { - /** Optional per-account Feishu configuration (multi-account). */ - accounts?: Record; - /** Top-level app ID (alternative to accounts). */ - appId?: string; - /** Top-level app secret (alternative to accounts). */ - appSecret?: string; - /** Top-level app secret file (alternative to accounts). */ - appSecretFile?: string; -} & Omit; diff --git a/src/config/types.ts b/src/config/types.ts index ba4ca1d701..d14f1178e8 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -10,7 +10,6 @@ export * from "./types.channels.js"; export * from "./types.openclaw.js"; export * from "./types.cron.js"; export * from "./types.discord.js"; -export * from "./types.feishu.js"; export * from "./types.googlechat.js"; export * from "./types.gateway.js"; export * from "./types.hooks.js"; diff --git a/src/feishu/access.ts b/src/feishu/access.ts deleted file mode 100644 index 12a0df57d1..0000000000 --- a/src/feishu/access.ts +++ /dev/null @@ -1,91 +0,0 @@ -import type { AllowlistMatch } from "../channels/allowlist-match.js"; - -export type NormalizedAllowFrom = { - entries: string[]; - entriesLower: string[]; - hasWildcard: boolean; - hasEntries: boolean; -}; - -export type AllowFromMatch = AllowlistMatch<"wildcard" | "id">; - -/** - * Normalize an allowlist for Feishu. - * Feishu IDs are open_id (ou_xxx) or union_id (on_xxx), no usernames. - */ -export const normalizeAllowFrom = (list?: Array): NormalizedAllowFrom => { - const entries = (list ?? []).map((value) => String(value).trim()).filter(Boolean); - const hasWildcard = entries.includes("*"); - // Strip optional "feishu:" prefix - const normalized = entries - .filter((value) => value !== "*") - .map((value) => value.replace(/^(feishu|lark):/i, "")); - const normalizedLower = normalized.map((value) => value.toLowerCase()); - return { - entries: normalized, - entriesLower: normalizedLower, - hasWildcard, - hasEntries: entries.length > 0, - }; -}; - -export const normalizeAllowFromWithStore = (params: { - allowFrom?: Array; - storeAllowFrom?: string[]; -}): NormalizedAllowFrom => { - const combined = [...(params.allowFrom ?? []), ...(params.storeAllowFrom ?? [])] - .map((value) => String(value).trim()) - .filter(Boolean); - return normalizeAllowFrom(combined); -}; - -export const firstDefined = (...values: Array) => { - for (const value of values) { - if (typeof value !== "undefined") { - return value; - } - } - return undefined; -}; - -/** - * Check if a sender is allowed based on the normalized allowlist. - * Feishu uses open_id (ou_xxx) or union_id (on_xxx) - no usernames. - */ -export const isSenderAllowed = (params: { allow: NormalizedAllowFrom; senderId?: string }) => { - const { allow, senderId } = params; - if (!allow.hasEntries) { - return true; - } - if (allow.hasWildcard) { - return true; - } - if (senderId && allow.entries.includes(senderId)) { - return true; - } - // Also check case-insensitive (though Feishu IDs are typically lowercase) - if (senderId && allow.entriesLower.includes(senderId.toLowerCase())) { - return true; - } - return false; -}; - -export const resolveSenderAllowMatch = (params: { - allow: NormalizedAllowFrom; - senderId?: string; -}): AllowFromMatch => { - const { allow, senderId } = params; - if (allow.hasWildcard) { - return { allowed: true, matchKey: "*", matchSource: "wildcard" }; - } - if (!allow.hasEntries) { - return { allowed: false }; - } - if (senderId && allow.entries.includes(senderId)) { - return { allowed: true, matchKey: senderId, matchSource: "id" }; - } - if (senderId && allow.entriesLower.includes(senderId.toLowerCase())) { - return { allowed: true, matchKey: senderId.toLowerCase(), matchSource: "id" }; - } - return { allowed: false }; -}; diff --git a/src/feishu/accounts.ts b/src/feishu/accounts.ts deleted file mode 100644 index 5b917a7eeb..0000000000 --- a/src/feishu/accounts.ts +++ /dev/null @@ -1,142 +0,0 @@ -import fs from "node:fs"; -import type { OpenClawConfig } from "../config/config.js"; -import type { FeishuAccountConfig } from "../config/types.feishu.js"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; - -export type FeishuTokenSource = "config" | "file" | "env" | "none"; - -export type ResolvedFeishuAccount = { - accountId: string; - config: FeishuAccountConfig; - tokenSource: FeishuTokenSource; - name?: string; - enabled: boolean; -}; - -function readFileIfExists(filePath?: string): string | undefined { - if (!filePath) { - return undefined; - } - try { - return fs.readFileSync(filePath, "utf-8").trim(); - } catch { - return undefined; - } -} - -function resolveAccountConfig( - cfg: OpenClawConfig, - accountId: string, -): FeishuAccountConfig | undefined { - const accounts = cfg.channels?.feishu?.accounts; - if (!accounts || typeof accounts !== "object") { - return undefined; - } - const direct = accounts[accountId] as FeishuAccountConfig | undefined; - if (direct) { - return direct; - } - const normalized = normalizeAccountId(accountId); - const matchKey = Object.keys(accounts).find((key) => normalizeAccountId(key) === normalized); - return matchKey ? (accounts[matchKey] as FeishuAccountConfig | undefined) : undefined; -} - -function mergeFeishuAccountConfig(cfg: OpenClawConfig, accountId: string): FeishuAccountConfig { - const { accounts: _ignored, ...base } = (cfg.channels?.feishu ?? {}) as FeishuAccountConfig & { - accounts?: unknown; - }; - const account = resolveAccountConfig(cfg, accountId) ?? {}; - return { ...base, ...account }; -} - -function resolveAppSecret(config?: { appSecret?: string; appSecretFile?: string }): { - value?: string; - source?: Exclude; -} { - const direct = config?.appSecret?.trim(); - if (direct) { - return { value: direct, source: "config" }; - } - const fromFile = readFileIfExists(config?.appSecretFile); - if (fromFile) { - return { value: fromFile, source: "file" }; - } - return {}; -} - -export function listFeishuAccountIds(cfg: OpenClawConfig): string[] { - const feishuCfg = cfg.channels?.feishu; - const accounts = feishuCfg?.accounts; - const ids = new Set(); - - const baseConfigured = Boolean( - feishuCfg?.appId?.trim() && (feishuCfg?.appSecret?.trim() || Boolean(feishuCfg?.appSecretFile)), - ); - const envConfigured = Boolean( - process.env.FEISHU_APP_ID?.trim() && process.env.FEISHU_APP_SECRET?.trim(), - ); - if (baseConfigured || envConfigured) { - ids.add(DEFAULT_ACCOUNT_ID); - } - - if (accounts) { - for (const id of Object.keys(accounts)) { - ids.add(normalizeAccountId(id)); - } - } - - return Array.from(ids); -} - -export function resolveDefaultFeishuAccountId(cfg: OpenClawConfig): string { - const ids = listFeishuAccountIds(cfg); - if (ids.includes(DEFAULT_ACCOUNT_ID)) { - return DEFAULT_ACCOUNT_ID; - } - return ids[0] ?? DEFAULT_ACCOUNT_ID; -} - -export function resolveFeishuAccount(params: { - cfg: OpenClawConfig; - accountId?: string | null; -}): ResolvedFeishuAccount { - const accountId = normalizeAccountId(params.accountId); - const baseEnabled = params.cfg.channels?.feishu?.enabled !== false; - const merged = mergeFeishuAccountConfig(params.cfg, accountId); - const accountEnabled = merged.enabled !== false; - const enabled = baseEnabled && accountEnabled; - - const allowEnv = accountId === DEFAULT_ACCOUNT_ID; - const envAppId = allowEnv ? process.env.FEISHU_APP_ID?.trim() : undefined; - const envAppSecret = allowEnv ? process.env.FEISHU_APP_SECRET?.trim() : undefined; - - const appId = merged.appId?.trim() || envAppId || ""; - const secretResolution = resolveAppSecret(merged); - const appSecret = secretResolution.value ?? envAppSecret ?? ""; - - let tokenSource: FeishuTokenSource = "none"; - if (secretResolution.value) { - tokenSource = secretResolution.source ?? "config"; - } else if (envAppSecret) { - tokenSource = "env"; - } - if (!appId || !appSecret) { - tokenSource = "none"; - } - - const config: FeishuAccountConfig = { - ...merged, - appId, - appSecret, - }; - - const name = config.name?.trim() || config.botName?.trim() || undefined; - - return { - accountId, - config, - tokenSource, - name, - enabled, - }; -} diff --git a/src/feishu/bot.ts b/src/feishu/bot.ts deleted file mode 100644 index c9ba9d8722..0000000000 --- a/src/feishu/bot.ts +++ /dev/null @@ -1,58 +0,0 @@ -import * as Lark from "@larksuiteoapi/node-sdk"; -import { formatErrorMessage } from "../infra/errors.js"; -import { getChildLogger } from "../logging.js"; -import { getFeishuClient } from "./client.js"; -import { processFeishuMessage } from "./message.js"; - -const logger = getChildLogger({ module: "feishu-bot" }); - -export type FeishuBotOptions = { - appId: string; - appSecret: string; -}; - -export function createFeishuBot(opts: FeishuBotOptions) { - const { appId, appSecret } = opts; - const client = getFeishuClient(appId, appSecret); - - const eventDispatcher = new Lark.EventDispatcher({}).register({ - "im.message.receive_v1": async (data) => { - try { - await processFeishuMessage(client, data, appId); - } catch (err) { - logger.error(`Error processing Feishu message: ${formatErrorMessage(err)}`); - } - }, - }); - - const wsClient = new Lark.WSClient({ - appId, - appSecret, - logger: { - debug: (...args) => { - logger.debug(args.join(" ")); - }, - info: (...args) => { - logger.info(args.join(" ")); - }, - warn: (...args) => { - logger.warn(args.join(" ")); - }, - error: (...args) => { - logger.error(args.join(" ")); - }, - trace: (...args) => { - logger.silly(args.join(" ")); - }, - }, - }); - - return { client, wsClient, eventDispatcher }; -} - -export async function startFeishuBot(bot: ReturnType) { - logger.info("Starting Feishu bot WS client..."); - await bot.wsClient.start({ - eventDispatcher: bot.eventDispatcher, - }); -} diff --git a/src/feishu/client.ts b/src/feishu/client.ts deleted file mode 100644 index 083c010612..0000000000 --- a/src/feishu/client.ts +++ /dev/null @@ -1,134 +0,0 @@ -import * as Lark from "@larksuiteoapi/node-sdk"; -import fs from "node:fs"; -import { loadConfig } from "../config/config.js"; -import { getChildLogger } from "../logging.js"; -import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js"; -import { normalizeFeishuDomain } from "./domain.js"; - -const logger = getChildLogger({ module: "feishu-client" }); - -function readFileIfExists(filePath?: string): string | undefined { - if (!filePath) { - return undefined; - } - try { - return fs.readFileSync(filePath, "utf-8").trim(); - } catch { - return undefined; - } -} - -function resolveAppSecret(config?: { - appSecret?: string; - appSecretFile?: string; -}): string | undefined { - const direct = config?.appSecret?.trim(); - if (direct) { - return direct; - } - return readFileIfExists(config?.appSecretFile); -} - -export function getFeishuClient(accountIdOrAppId?: string, explicitAppSecret?: string) { - const cfg = loadConfig(); - const feishuCfg = cfg.channels?.feishu; - - let appId: string | undefined; - let appSecret: string | undefined = explicitAppSecret?.trim() || undefined; - let domain: string | undefined; - - // Determine if we received an accountId or an appId - const isAppId = accountIdOrAppId?.startsWith("cli_"); - const accountId = isAppId ? undefined : accountIdOrAppId || DEFAULT_ACCOUNT_ID; - - if (!appSecret && feishuCfg?.accounts) { - if (isAppId) { - // When given an appId, find the account with matching appId - for (const [, acc] of Object.entries(feishuCfg.accounts)) { - if (acc.appId === accountIdOrAppId) { - appId = acc.appId; - appSecret = resolveAppSecret(acc); - domain = acc.domain ?? feishuCfg?.domain; - break; - } - } - // If not found in accounts, use the appId directly (secret from first account as fallback) - if (!appSecret) { - appId = accountIdOrAppId; - const firstKey = Object.keys(feishuCfg.accounts)[0]; - if (firstKey) { - const acc = feishuCfg.accounts[firstKey]; - appSecret = resolveAppSecret(acc); - domain = acc.domain ?? feishuCfg?.domain; - } - } - } else if (accountId && feishuCfg.accounts[accountId]) { - // Try to get from accounts config by accountId - const acc = feishuCfg.accounts[accountId]; - appId = acc.appId; - appSecret = resolveAppSecret(acc); - domain = acc.domain ?? feishuCfg?.domain; - } else if (!accountId) { - // Fallback to first account if accountId is not specified - const firstKey = Object.keys(feishuCfg.accounts)[0]; - if (firstKey) { - const acc = feishuCfg.accounts[firstKey]; - appId = acc.appId; - appSecret = resolveAppSecret(acc); - domain = acc.domain ?? feishuCfg?.domain; - } - } - } - - // Fallback to top-level feishu config (for backward compatibility) - if (!appId && feishuCfg?.appId) { - appId = feishuCfg.appId.trim(); - } - if (!appSecret) { - appSecret = resolveAppSecret(feishuCfg); - } - if (!domain) { - domain = feishuCfg?.domain; - } - - // Environment variables fallback - if (!appId) { - appId = process.env.FEISHU_APP_ID?.trim(); - } - if (!appSecret) { - appSecret = process.env.FEISHU_APP_SECRET?.trim(); - } - - if (!appId || !appSecret) { - throw new Error( - "Feishu app ID/secret not configured. Set channels.feishu.accounts..appId/appSecret (or appSecretFile) or FEISHU_APP_ID/FEISHU_APP_SECRET.", - ); - } - - const resolvedDomain = normalizeFeishuDomain(domain); - - const client = new Lark.Client({ - appId, - appSecret, - ...(resolvedDomain ? { domain: resolvedDomain } : {}), - logger: { - debug: (msg) => { - logger.debug(msg); - }, - info: (msg) => { - logger.info(msg); - }, - warn: (msg) => { - logger.warn(msg); - }, - error: (msg) => { - logger.error(msg); - }, - trace: (msg) => { - logger.silly(msg); - }, - }, - }); - - return client; -} diff --git a/src/feishu/config.ts b/src/feishu/config.ts deleted file mode 100644 index 0c82e7740c..0000000000 --- a/src/feishu/config.ts +++ /dev/null @@ -1,91 +0,0 @@ -import type { OpenClawConfig } from "../config/config.js"; -import type { DmPolicy, GroupPolicy } from "../config/types.base.js"; -import type { FeishuGroupConfig } from "../config/types.feishu.js"; -import { firstDefined } from "./access.js"; - -export type ResolvedFeishuConfig = { - enabled: boolean; - dmPolicy: DmPolicy; - groupPolicy: GroupPolicy; - allowFrom: string[]; - groupAllowFrom: string[]; - historyLimit: number; - dmHistoryLimit: number; - textChunkLimit: number; - chunkMode: "length" | "newline"; - blockStreaming: boolean; - streaming: boolean; - mediaMaxMb: number; - groups: Record; -}; - -/** - * Resolve effective Feishu configuration for an account. - * Account-level config overrides top-level feishu config, which overrides channel defaults. - */ -export function resolveFeishuConfig(params: { - cfg: OpenClawConfig; - accountId?: string; -}): ResolvedFeishuConfig { - const { cfg, accountId } = params; - const feishuCfg = cfg.channels?.feishu; - const accountCfg = accountId ? feishuCfg?.accounts?.[accountId] : undefined; - const defaults = cfg.channels?.defaults; - - // Merge with precedence: account > feishu top-level > channel defaults > hardcoded defaults - return { - enabled: firstDefined(accountCfg?.enabled, feishuCfg?.enabled, true) ?? true, - dmPolicy: firstDefined(accountCfg?.dmPolicy, feishuCfg?.dmPolicy) ?? "pairing", - groupPolicy: - firstDefined(accountCfg?.groupPolicy, feishuCfg?.groupPolicy, defaults?.groupPolicy) ?? - "open", - allowFrom: (accountCfg?.allowFrom ?? feishuCfg?.allowFrom ?? []).map(String), - groupAllowFrom: (accountCfg?.groupAllowFrom ?? feishuCfg?.groupAllowFrom ?? []).map(String), - historyLimit: firstDefined(accountCfg?.historyLimit, feishuCfg?.historyLimit) ?? 10, - dmHistoryLimit: firstDefined(accountCfg?.dmHistoryLimit, feishuCfg?.dmHistoryLimit) ?? 20, - textChunkLimit: firstDefined(accountCfg?.textChunkLimit, feishuCfg?.textChunkLimit) ?? 2000, - chunkMode: firstDefined(accountCfg?.chunkMode, feishuCfg?.chunkMode) ?? "length", - blockStreaming: firstDefined(accountCfg?.blockStreaming, feishuCfg?.blockStreaming) ?? true, - streaming: firstDefined(accountCfg?.streaming, feishuCfg?.streaming) ?? true, - mediaMaxMb: firstDefined(accountCfg?.mediaMaxMb, feishuCfg?.mediaMaxMb) ?? 30, - groups: { ...feishuCfg?.groups, ...accountCfg?.groups }, - }; -} - -/** - * Resolve group-specific configuration for a Feishu chat. - */ -export function resolveFeishuGroupConfig(params: { - cfg: OpenClawConfig; - accountId?: string; - chatId: string; -}): { groupConfig?: FeishuGroupConfig } { - const resolved = resolveFeishuConfig({ cfg: params.cfg, accountId: params.accountId }); - const groupConfig = resolved.groups[params.chatId]; - return { groupConfig }; -} - -/** - * Check if a group requires @mention for the bot to respond. - */ -export function resolveFeishuGroupRequireMention(params: { - cfg: OpenClawConfig; - accountId?: string; - chatId: string; -}): boolean { - const { groupConfig } = resolveFeishuGroupConfig(params); - // Default: require mention in groups - return groupConfig?.requireMention ?? true; -} - -/** - * Check if a group is enabled. - */ -export function resolveFeishuGroupEnabled(params: { - cfg: OpenClawConfig; - accountId?: string; - chatId: string; -}): boolean { - const { groupConfig } = resolveFeishuGroupConfig(params); - return groupConfig?.enabled ?? true; -} diff --git a/src/feishu/docs.test.ts b/src/feishu/docs.test.ts deleted file mode 100644 index 264f58a6e5..0000000000 --- a/src/feishu/docs.test.ts +++ /dev/null @@ -1,135 +0,0 @@ -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 deleted file mode 100644 index e01d4fd43c..0000000000 --- a/src/feishu/docs.ts +++ /dev/null @@ -1,456 +0,0 @@ -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/domain.ts b/src/feishu/domain.ts deleted file mode 100644 index 49c8e593b3..0000000000 --- a/src/feishu/domain.ts +++ /dev/null @@ -1,31 +0,0 @@ -export const FEISHU_DOMAIN = "https://open.feishu.cn"; -export const LARK_DOMAIN = "https://open.larksuite.com"; - -export type FeishuDomainInput = string | null | undefined; - -export function normalizeFeishuDomain(value?: FeishuDomainInput): string | undefined { - const trimmed = value?.trim(); - if (!trimmed) { - return undefined; - } - const lower = trimmed.toLowerCase(); - if (lower === "feishu" || lower === "cn" || lower === "china") { - return FEISHU_DOMAIN; - } - if (lower === "lark" || lower === "global" || lower === "intl" || lower === "international") { - return LARK_DOMAIN; - } - - const withScheme = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`; - const withoutTrailing = withScheme.replace(/\/+$/, ""); - return withoutTrailing.replace(/\/open-apis$/i, ""); -} - -export function resolveFeishuDomain(value?: FeishuDomainInput): string { - return normalizeFeishuDomain(value) ?? FEISHU_DOMAIN; -} - -export function resolveFeishuApiBase(value?: FeishuDomainInput): string { - const base = resolveFeishuDomain(value); - return `${base.replace(/\/+$/, "")}/open-apis`; -} diff --git a/src/feishu/download.ts b/src/feishu/download.ts deleted file mode 100644 index c69801b48b..0000000000 --- a/src/feishu/download.ts +++ /dev/null @@ -1,277 +0,0 @@ -import type { Client } from "@larksuiteoapi/node-sdk"; -import { formatErrorMessage } from "../infra/errors.js"; -import { getChildLogger } from "../logging.js"; -import { saveMediaBuffer } from "../media/store.js"; - -const logger = getChildLogger({ module: "feishu-download" }); - -export type FeishuMediaRef = { - path: string; - contentType?: string; - placeholder: string; -}; - -type FeishuMessagePayload = { - message_type?: string; - message_id?: string; - content?: string; -}; - -/** - * 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" 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", - maxBytes: number = 30 * 1024 * 1024, -): Promise { - logger.debug(`Downloading Feishu ${type}: messageId=${messageId}, fileKey=${fileKey}`); - - const res = await client.im.messageResource.get({ - params: { type }, - path: { - message_id: messageId, - file_key: fileKey, - }, - }); - - if (!res) { - throw new Error(`Failed to get ${type} resource: no response`); - } - - const stream = res.getReadableStream(); - const chunks: Buffer[] = []; - let totalSize = 0; - - for await (const chunk of stream) { - totalSize += chunk.length; - if (totalSize > maxBytes) { - throw new Error(`${type} resource exceeds ${Math.round(maxBytes / (1024 * 1024))}MB limit`); - } - chunks.push(Buffer.from(chunk)); - } - - const buffer = Buffer.concat(chunks); - - // Try to detect content type from headers - const contentType = - res.headers?.["content-type"] ?? res.headers?.["Content-Type"] ?? getDefaultContentType(type); - - const saved = await saveMediaBuffer(buffer, contentType, "inbound", maxBytes); - - return { - path: saved.path, - contentType: saved.contentType, - placeholder: getPlaceholder(type), - }; -} - -function getDefaultContentType(type: string): string { - switch (type) { - case "image": - return "image/jpeg"; - case "audio": - return "audio/ogg"; - case "video": - return "video/mp4"; - default: - return "application/octet-stream"; - } -} - -function getPlaceholder(type: string): string { - switch (type) { - case "image": - return ""; - case "audio": - return ""; - case "video": - return ""; - default: - return ""; - } -} - -/** - * Resolve media from a Feishu message - * Returns the downloaded media reference or null if no media - * - * Uses messageResource.get API to download resources from user messages. - */ -export async function resolveFeishuMedia( - client: Client, - message: FeishuMessagePayload, - maxBytes: number = 30 * 1024 * 1024, -): Promise { - const msgType = message.message_type; - const messageId = message.message_id; - - if (!messageId) { - logger.warn(`Cannot download media: message_id is missing`); - return null; - } - - try { - const rawContent = message.content; - if (!rawContent) { - return null; - } - - if (msgType === "image") { - // Image message: content = { image_key: "..." } - const content = JSON.parse(rawContent); - if (content.image_key) { - return await downloadFeishuMessageResource( - client, - messageId, - content.image_key, - "image", - maxBytes, - ); - } - } else if (msgType === "file") { - // File message: content = { file_key: "...", file_name: "..." } - const content = JSON.parse(rawContent); - if (content.file_key) { - return await downloadFeishuMessageResource( - client, - messageId, - content.file_key, - "file", - maxBytes, - ); - } - } 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) { - const result = await downloadFeishuMessageResource( - client, - messageId, - content.file_key, - "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) { - const result = await downloadFeishuMessageResource( - client, - messageId, - content.file_key, - "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 - logger.debug(`Sticker messages are not supported for download`); - return null; - } - } catch (err) { - logger.error(`Failed to resolve Feishu media (${msgType}): ${formatErrorMessage(err)}`); - } - - 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/format.test.ts b/src/feishu/format.test.ts deleted file mode 100644 index dea45a8d42..0000000000 --- a/src/feishu/format.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { containsMarkdown, markdownToFeishuPost } from "./format.js"; - -describe("containsMarkdown", () => { - it("detects bold text", () => { - expect(containsMarkdown("Hello **world**")).toBe(true); - }); - - it("detects italic text", () => { - expect(containsMarkdown("Hello *world*")).toBe(true); - }); - - it("detects inline code", () => { - expect(containsMarkdown("Run `npm install`")).toBe(true); - }); - - it("detects code blocks", () => { - expect(containsMarkdown("```js\nconsole.log('hi')\n```")).toBe(true); - }); - - it("detects links", () => { - expect(containsMarkdown("Visit [Google](https://google.com)")).toBe(true); - }); - - it("detects headings", () => { - expect(containsMarkdown("# Title")).toBe(true); - }); - - it("returns false for plain text", () => { - expect(containsMarkdown("Hello world")).toBe(false); - }); - - it("returns false for empty string", () => { - expect(containsMarkdown("")).toBe(false); - }); -}); - -describe("markdownToFeishuPost", () => { - it("converts plain text", () => { - const result = markdownToFeishuPost("Hello world"); - expect(result.zh_cn?.content).toBeDefined(); - expect(result.zh_cn?.content[0]).toContainEqual({ - tag: "text", - text: "Hello world", - }); - }); - - it("converts bold text", () => { - const result = markdownToFeishuPost("Hello **bold** text"); - const content = result.zh_cn?.content[0]; - expect(content).toBeDefined(); - // Should have at least one element with bold style - const boldElement = content?.find((el) => el.tag === "text" && el.style?.includes("bold")); - expect(boldElement).toBeDefined(); - }); - - it("converts italic text", () => { - const result = markdownToFeishuPost("Hello *italic* text"); - const content = result.zh_cn?.content[0]; - expect(content).toBeDefined(); - const italicElement = content?.find((el) => el.tag === "text" && el.style?.includes("italic")); - expect(italicElement).toBeDefined(); - }); - - it("converts links", () => { - const result = markdownToFeishuPost("Visit [Google](https://google.com)"); - const content = result.zh_cn?.content[0]; - expect(content).toBeDefined(); - const linkElement = content?.find((el) => el.tag === "a"); - expect(linkElement).toBeDefined(); - if (linkElement && linkElement.tag === "a") { - expect(linkElement.href).toBe("https://google.com"); - expect(linkElement.text).toBe("Google"); - } - }); - - it("handles multi-line text", () => { - const result = markdownToFeishuPost("Line 1\nLine 2\nLine 3"); - expect(result.zh_cn?.content.length).toBe(3); - }); - - it("converts code to code style", () => { - const result = markdownToFeishuPost("Run `npm install`"); - const content = result.zh_cn?.content[0]; - expect(content).toBeDefined(); - const codeElement = content?.find((el) => el.tag === "text" && el.style?.includes("code")); - expect(codeElement).toBeDefined(); - }); - - it("handles empty input", () => { - const result = markdownToFeishuPost(""); - expect(result.zh_cn?.content).toBeDefined(); - }); -}); diff --git a/src/feishu/format.ts b/src/feishu/format.ts deleted file mode 100644 index 444af5f797..0000000000 --- a/src/feishu/format.ts +++ /dev/null @@ -1,267 +0,0 @@ -import type { MarkdownTableMode } from "../config/types.base.js"; -import { - chunkMarkdownIR, - markdownToIR, - type MarkdownIR, - type MarkdownLinkSpan, - type MarkdownStyleSpan, -} from "../markdown/ir.js"; - -/** - * Feishu Post (rich text) format - * Reference: https://open.feishu.cn/document/server-docs/im-v1/message-content-description/create_json#c9e08671 - */ - -export type FeishuPostElement = - | { tag: "text"; text: string; style?: string[] } - | { tag: "a"; text: string; href: string; style?: string[] } - | { tag: "at"; user_id: string } - | { tag: "img"; image_key: string } - | { tag: "media"; file_key: string } - | { tag: "emotion"; emoji_type: string }; - -export type FeishuPostLine = FeishuPostElement[]; - -export type FeishuPostContent = { - zh_cn?: { - title?: string; - content: FeishuPostLine[]; - }; - en_us?: { - title?: string; - content: FeishuPostLine[]; - }; -}; - -export type FeishuFormattedChunk = { - post: FeishuPostContent; - text: string; -}; - -type StyleState = { - bold: boolean; - italic: boolean; - strikethrough: boolean; - code: boolean; -}; - -/** - * Convert MarkdownIR to Feishu Post format - */ -function renderFeishuPost(ir: MarkdownIR): FeishuPostContent { - const lines: FeishuPostLine[] = []; - const text = ir.text; - - if (!text) { - return { zh_cn: { content: [[{ tag: "text", text: "" }]] } }; - } - - // Build a map of style ranges for quick lookup - const styleRanges = buildStyleRanges(ir.styles, text.length); - const linkMap = buildLinkMap(ir.links); - - // Split text into lines - const textLines = text.split("\n"); - let charIndex = 0; - - for (const line of textLines) { - const lineElements: FeishuPostElement[] = []; - - if (line.length === 0) { - // Empty line - add empty text element - lineElements.push({ tag: "text", text: "" }); - } else { - // Process each character segment with consistent styling - let segmentStart = charIndex; - let currentStyles = getStylesAt(styleRanges, segmentStart); - let currentLink = getLinkAt(linkMap, segmentStart); - - for (let i = 0; i < line.length; i++) { - const pos = charIndex + i; - const newStyles = getStylesAt(styleRanges, pos); - const newLink = getLinkAt(linkMap, pos); - - // Check if style or link changed - const stylesChanged = !stylesEqual(currentStyles, newStyles); - const linkChanged = currentLink !== newLink; - - if (stylesChanged || linkChanged) { - // Emit previous segment - const segmentText = text.slice(segmentStart, pos); - if (segmentText) { - lineElements.push(createPostElement(segmentText, currentStyles, currentLink)); - } - segmentStart = pos; - currentStyles = newStyles; - currentLink = newLink; - } - } - - // Emit final segment of the line - const finalText = text.slice(segmentStart, charIndex + line.length); - if (finalText) { - lineElements.push(createPostElement(finalText, currentStyles, currentLink)); - } - } - - lines.push(lineElements.length > 0 ? lineElements : [{ tag: "text", text: "" }]); - charIndex += line.length + 1; // +1 for newline - } - - return { - zh_cn: { - content: lines, - }, - }; -} - -function buildStyleRanges(styles: MarkdownStyleSpan[], textLength: number): StyleState[] { - const ranges: StyleState[] = Array(textLength) - .fill(null) - .map(() => ({ - bold: false, - italic: false, - strikethrough: false, - code: false, - })); - - for (const span of styles) { - for (let i = span.start; i < span.end && i < textLength; i++) { - switch (span.style) { - case "bold": - ranges[i].bold = true; - break; - case "italic": - ranges[i].italic = true; - break; - case "strikethrough": - ranges[i].strikethrough = true; - break; - case "code": - case "code_block": - ranges[i].code = true; - break; - } - } - } - - return ranges; -} - -function buildLinkMap(links: MarkdownLinkSpan[]): Map { - const map = new Map(); - for (const link of links) { - for (let i = link.start; i < link.end; i++) { - map.set(i, link.href); - } - } - return map; -} - -function getStylesAt(ranges: StyleState[], pos: number): StyleState { - return ranges[pos] ?? { bold: false, italic: false, strikethrough: false, code: false }; -} - -function getLinkAt(linkMap: Map, pos: number): string | undefined { - return linkMap.get(pos); -} - -function stylesEqual(a: StyleState, b: StyleState): boolean { - return ( - a.bold === b.bold && - a.italic === b.italic && - a.strikethrough === b.strikethrough && - a.code === b.code - ); -} - -function createPostElement(text: string, styles: StyleState, link?: string): FeishuPostElement { - const styleArray: string[] = []; - - if (styles.bold) { - styleArray.push("bold"); - } - if (styles.italic) { - styleArray.push("italic"); - } - if (styles.strikethrough) { - styleArray.push("lineThrough"); - } - if (styles.code) { - styleArray.push("code"); - } - - if (link) { - return { - tag: "a", - text, - href: link, - ...(styleArray.length > 0 ? { style: styleArray } : {}), - }; - } - - return { - tag: "text", - text, - ...(styleArray.length > 0 ? { style: styleArray } : {}), - }; -} - -/** - * Convert Markdown to Feishu Post format - */ -export function markdownToFeishuPost( - markdown: string, - options: { tableMode?: MarkdownTableMode } = {}, -): FeishuPostContent { - const ir = markdownToIR(markdown ?? "", { - linkify: true, - headingStyle: "bold", - blockquotePrefix: "| ", - tableMode: options.tableMode, - }); - return renderFeishuPost(ir); -} - -/** - * Convert Markdown to Feishu Post chunks (for long messages) - */ -export function markdownToFeishuChunks( - markdown: string, - limit: number, - options: { tableMode?: MarkdownTableMode } = {}, -): FeishuFormattedChunk[] { - const ir = markdownToIR(markdown ?? "", { - linkify: true, - headingStyle: "bold", - blockquotePrefix: "| ", - tableMode: options.tableMode, - }); - const chunks = chunkMarkdownIR(ir, limit); - return chunks.map((chunk) => ({ - post: renderFeishuPost(chunk), - text: chunk.text, - })); -} - -/** - * Check if text contains Markdown formatting - */ -export function containsMarkdown(text: string): boolean { - if (!text) { - return false; - } - // Check for common Markdown patterns - const markdownPatterns = [ - /\*\*[^*]+\*\*/, // bold - /\*[^*]+\*/, // italic - /~~[^~]+~~/, // strikethrough - /`[^`]+`/, // inline code - /```[\s\S]*```/, // code block - /\[.+\]\(.+\)/, // links - /^#{1,6}\s/m, // headings - /^[-*]\s/m, // unordered list - /^\d+\.\s/m, // ordered list - ]; - return markdownPatterns.some((pattern) => pattern.test(text)); -} diff --git a/src/feishu/index.ts b/src/feishu/index.ts deleted file mode 100644 index 1f4aaaeae5..0000000000 --- a/src/feishu/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -export * from "./types.js"; -export * from "./client.js"; -export * from "./bot.js"; -export * from "./send.js"; -export * from "./message.js"; -export * from "./probe.js"; -export * from "./accounts.js"; -export * from "./monitor.js"; diff --git a/src/feishu/message.ts b/src/feishu/message.ts deleted file mode 100644 index 931b4d3aed..0000000000 --- a/src/feishu/message.ts +++ /dev/null @@ -1,619 +0,0 @@ -import type { Client } from "@larksuiteoapi/node-sdk"; -import type { OpenClawConfig } from "../config/config.js"; -import { resolveSessionAgentId } from "../agents/agent-scope.js"; -import { dispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/provider-dispatcher.js"; -import { createReplyPrefixOptions } from "../channels/reply-prefix.js"; -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, - resolveFeishuGroupConfig, - resolveFeishuGroupEnabled, - type ResolvedFeishuConfig, -} from "./config.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" }); - -type FeishuSender = { - sender_id?: { - open_id?: string; - user_id?: string; - union_id?: string; - }; -}; - -type FeishuMention = { - key?: string; - id?: { - open_id?: string; - user_id?: string; - union_id?: string; - }; - name?: string; -}; - -type FeishuMessage = { - chat_id?: string; - chat_type?: string; - message_type?: string; - content?: string; - mentions?: FeishuMention[]; - create_time?: string | number; - message_id?: string; - parent_id?: string; - root_id?: string; -}; - -type FeishuEventPayload = { - message?: FeishuMessage; - event?: { - message?: FeishuMessage; - sender?: FeishuSender; - }; - sender?: FeishuSender; - mentions?: FeishuMention[]; -}; - -// Supported message types for processing -const SUPPORTED_MSG_TYPES = new Set(["text", "post", "image", "file", "audio", "media", "sticker"]); - -export type ProcessFeishuMessageOptions = { - cfg?: OpenClawConfig; - accountId?: string; - resolvedConfig?: ResolvedFeishuConfig; - /** Feishu app credentials for streaming card API */ - 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( - client: Client, - data: unknown, - appId: string, - options: ProcessFeishuMessageOptions = {}, -) { - const cfg = options.cfg ?? loadConfig(); - const accountId = options.accountId ?? appId; - const feishuCfg = options.resolvedConfig ?? resolveFeishuConfig({ cfg, accountId }); - - const payload = data as FeishuEventPayload; - - // SDK 2.0 schema: data directly contains message, sender, etc. - const message = payload.message ?? payload.event?.message; - const sender = payload.sender ?? payload.event?.sender; - - if (!message) { - logger.warn(`Received event without message field`); - return; - } - - const chatId = message.chat_id; - if (!chatId) { - logger.warn("Received message without chat_id"); - return; - } - const isGroup = message.chat_type === "group"; - const msgType = message.message_type; - const senderId = sender?.sender_id?.open_id || sender?.sender_id?.user_id || "unknown"; - 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"}`); - return; - } - - // Load allowlist from store - const storeAllowFrom = await readFeishuAllowFromStore().catch(() => []); - - // ===== Access Control ===== - - // Group access control - if (isGroup) { - // Check if group is enabled - if (!resolveFeishuGroupEnabled({ cfg, accountId, chatId })) { - logVerbose(`Blocked feishu group ${chatId} (group disabled)`); - return; - } - - const { groupConfig } = resolveFeishuGroupConfig({ cfg, accountId, chatId }); - - // Check group-level allowFrom override - if (groupConfig?.allowFrom) { - const groupAllow = normalizeAllowFromWithStore({ - allowFrom: groupConfig.allowFrom, - storeAllowFrom, - }); - if (!isSenderAllowed({ allow: groupAllow, senderId })) { - logVerbose(`Blocked feishu group sender ${senderId} (group allowFrom override)`); - return; - } - } - - // Apply groupPolicy - const groupPolicy = feishuCfg.groupPolicy; - if (groupPolicy === "disabled") { - logVerbose(`Blocked feishu group message (groupPolicy: disabled)`); - return; - } - - if (groupPolicy === "allowlist") { - const groupAllow = normalizeAllowFromWithStore({ - allowFrom: - feishuCfg.groupAllowFrom.length > 0 ? feishuCfg.groupAllowFrom : feishuCfg.allowFrom, - storeAllowFrom, - }); - if (!groupAllow.hasEntries) { - logVerbose(`Blocked feishu group message (groupPolicy: allowlist, no entries)`); - return; - } - if (!isSenderAllowed({ allow: groupAllow, senderId })) { - logVerbose(`Blocked feishu group sender ${senderId} (groupPolicy: allowlist)`); - return; - } - } - } - - // DM access control - if (!isGroup) { - const dmPolicy = feishuCfg.dmPolicy; - - if (dmPolicy === "disabled") { - logVerbose(`Blocked feishu DM (dmPolicy: disabled)`); - return; - } - - if (dmPolicy !== "open") { - const dmAllow = normalizeAllowFromWithStore({ - allowFrom: feishuCfg.allowFrom, - storeAllowFrom, - }); - const allowMatch = resolveSenderAllowMatch({ allow: dmAllow, senderId }); - const allowed = dmAllow.hasWildcard || (dmAllow.hasEntries && allowMatch.allowed); - - if (!allowed) { - if (dmPolicy === "pairing") { - // Generate pairing code for unknown sender - try { - const { code, created } = await upsertFeishuPairingRequest({ - openId: senderId, - unionId: senderUnionId, - name: sender?.sender_id?.user_id, - }); - if (created) { - logger.info({ openId: senderId, unionId: senderUnionId }, "feishu pairing request"); - await sendMessageFeishu( - client, - senderId, - { - text: [ - "OpenClaw access not configured.", - "", - `Your Feishu Open ID: ${senderId}`, - "", - `Pairing code: ${code}`, - "", - "Ask the OpenClaw admin to approve with:", - `openclaw pairing approve feishu ${code}`, - ].join("\n"), - }, - { receiveIdType: "open_id" }, - ); - } - } catch (err) { - logger.error(`Failed to create pairing request: ${formatErrorMessage(err)}`); - } - return; - } - - // allowlist policy: silently block - logVerbose(`Blocked feishu DM from ${senderId} (dmPolicy: allowlist)`); - return; - } - } - } - - // Handle @mentions for group chats - const mentions = message.mentions ?? payload.mentions ?? []; - // 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) - : false; - - // In group chat, check requireMention setting - if (isGroup) { - const { groupConfig } = resolveFeishuGroupConfig({ cfg, accountId, chatId }); - const requireMention = groupConfig?.requireMention ?? true; - if (requireMention && !wasMentioned) { - logger.debug(`Ignoring group message without @mention (requireMention: true)`); - return; - } - } - - // Extract text content (for text messages or captions) - let text = ""; - if (msgType === "text") { - try { - if (message.content) { - const content = JSON.parse(message.content); - text = content.text || ""; - } - } 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 - for (const mention of mentions) { - if (mention.key) { - text = text.replace(mention.key, "").trim(); - } - } - - // Resolve media if present - let media: FeishuMediaRef | null = null; - 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) { - logger.error(`Failed to download media: ${formatErrorMessage(err)}`); - } - } - - // 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 && postImages.length === 0) { - logger.debug(`Empty message after processing, skipping`); - return; - } - - // 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); - const streamingSession = - streamingEnabled && options.credentials - ? new FeishuStreamingSession(client, options.credentials) - : null; - 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 || primaryMedia?.placeholder || "", - From: senderId, - To: chatId, - SessionKey: route.sessionKey, - SenderId: senderId, - SenderName: senderName, - ChatType: isGroup ? "group" : "dm", - Provider: "feishu", - Surface: "feishu", - Timestamp: Number(message.create_time), - MessageSid: message.message_id, - AccountId: route.accountId, - OriginatingChannel: "feishu", - OriginatingTo: chatId, - // Media fields (similar to Telegram) - 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 }); - const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({ - cfg, - agentId, - channel: "feishu", - accountId, - }); - - await dispatchReplyWithBufferedBlockDispatcher({ - ctx, - cfg, - dispatcherOptions: { - ...prefixOptions, - deliver: async (payload, info) => { - const hasMedia = payload.mediaUrl || (payload.mediaUrls && payload.mediaUrls.length > 0); - if (!payload.text && !hasMedia) { - return; - } - - // Handle block replies - update streaming card with partial text - if (streamingSession?.isActive() && info?.kind === "block" && payload.text) { - logger.debug(`Updating streaming card with block text: ${payload.text.length} chars`); - await streamingSession.update(payload.text); - return; - } - - // If streaming was active, close it with the final text - if (streamingSession?.isActive() && info?.kind === "final") { - await streamingSession.close(payload.text); - streamingStarted = false; - return; // Card already contains the final text - } - - // Handle media URLs - const mediaUrls = payload.mediaUrls?.length - ? payload.mediaUrls - : payload.mediaUrl - ? [payload.mediaUrl] - : []; - - if (mediaUrls.length > 0) { - // Close streaming session before sending media - if (streamingSession?.isActive()) { - await streamingSession.close(); - streamingStarted = false; - } - // Send each media item - for (let i = 0; i < mediaUrls.length; i++) { - const mediaUrl = mediaUrls[i]; - const caption = i === 0 ? payload.text || "" : ""; - await sendMessageFeishu( - client, - chatId, - { text: caption }, - { - mediaUrl, - receiveIdType: "chat_id", - // Only reply to the first media item to avoid spamming quote replies - replyToMessageId: i === 0 ? payload.replyToId : undefined, - }, - ); - } - } else if (payload.text) { - // If streaming wasn't used, send as regular message - if (!streamingSession?.isActive()) { - await sendMessageFeishu( - client, - chatId, - { text: payload.text }, - { - msgType: "text", - receiveIdType: "chat_id", - replyToMessageId: payload.replyToId, - }, - ); - } - } - }, - onError: (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 { - await streamingSession.start(chatId, "chat_id", options.botName); - streamingStarted = true; - logger.debug(`Started streaming card for chat ${chatId}`); - } catch (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 - } - } - }, - }, - replyOptions: { - disableBlockStreaming: !feishuCfg.blockStreaming, - onModelSelected, - onPartialReply: streamingSession - ? async (payload) => { - if (!streamingSession.isActive() || !payload.text) { - return; - } - if (payload.text === lastPartialText) { - return; - } - lastPartialText = payload.text; - await streamingSession.update(payload.text); - } - : undefined, - onReasoningStream: streamingSession - ? async (payload) => { - // Also update on reasoning stream for extended thinking models - if (!streamingSession.isActive() || !payload.text) { - return; - } - if (payload.text === lastPartialText) { - return; - } - lastPartialText = payload.text; - await streamingSession.update(payload.text); - } - : undefined, - }, - }); - - // Ensure streaming session is closed on completion - 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 deleted file mode 100644 index f17a88a4d3..0000000000 --- a/src/feishu/monitor.ts +++ /dev/null @@ -1,161 +0,0 @@ -import * as Lark from "@larksuiteoapi/node-sdk"; -import type { OpenClawConfig } from "../config/config.js"; -import type { RuntimeEnv } from "../runtime.js"; -import { loadConfig } from "../config/config.js"; -import { getChildLogger } from "../logging.js"; -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" }); - -export type MonitorFeishuOpts = { - appId?: string; - appSecret?: string; - accountId?: string; - config?: OpenClawConfig; - runtime?: RuntimeEnv; - abortSignal?: AbortSignal; -}; - -export async function monitorFeishuProvider(opts: MonitorFeishuOpts = {}): Promise { - const cfg = opts.config ?? loadConfig(); - const account = resolveFeishuAccount({ - cfg, - accountId: opts.accountId, - }); - - const appId = opts.appId?.trim() || account.config.appId; - const appSecret = opts.appSecret?.trim() || account.config.appSecret; - const domain = normalizeFeishuDomain(account.config.domain); - const accountId = account.accountId; - - if (!appId || !appSecret) { - throw new Error( - `Feishu app ID/secret missing for account "${accountId}" (set channels.feishu.accounts.${accountId}.appId/appSecret or FEISHU_APP_ID/FEISHU_APP_SECRET).`, - ); - } - - // Resolve effective config for this account - const feishuCfg = resolveFeishuConfig({ cfg, accountId }); - - // Check if account is enabled - if (!feishuCfg.enabled) { - logger.info(`Feishu account "${accountId}" is disabled, skipping monitor`); - return; - } - - // Create Lark client for API calls - const client = new Lark.Client({ - appId, - appSecret, - ...(domain ? { domain } : {}), - logger: { - debug: (msg) => { - logger.debug?.(msg); - }, - info: (msg) => { - logger.info(msg); - }, - warn: (msg) => { - logger.warn(msg); - }, - error: (msg) => { - logger.error(msg); - }, - trace: (msg) => { - logger.silly?.(msg); - }, - }, - }); - - // 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) => { - logger.info(`Received Feishu message event`); - try { - await processFeishuMessage(client, data, appId, { - cfg, - accountId, - resolvedConfig: feishuCfg, - credentials: { appId, appSecret, domain }, - botName: account.name, - botOpenId, - }); - } catch (err) { - logger.error(`Error processing Feishu message: ${String(err)}`); - } - }, - }); - - // Create WebSocket client - const wsClient = new Lark.WSClient({ - appId, - appSecret, - ...(domain ? { domain } : {}), - loggerLevel: Lark.LoggerLevel.info, - logger: { - debug: (msg) => { - logger.debug?.(msg); - }, - info: (msg) => { - logger.info(msg); - }, - warn: (msg) => { - logger.warn(msg); - }, - error: (msg) => { - logger.error(msg); - }, - trace: (msg) => { - logger.silly?.(msg); - }, - }, - }); - - // Handle abort signal - const handleAbort = () => { - logger.info("Stopping Feishu WS client..."); - // WSClient doesn't have a stop method exposed, but it should handle disconnection - // We'll let the process handle cleanup - }; - - if (opts.abortSignal) { - opts.abortSignal.addEventListener("abort", handleAbort, { once: true }); - } - - try { - logger.info("Starting Feishu WebSocket client..."); - await wsClient.start({ eventDispatcher }); - logger.info("Feishu WebSocket connection established"); - - // The WSClient.start() should keep running until disconnected - // If it returns, we need to keep the process alive - // Wait for abort signal - if (opts.abortSignal) { - await new Promise((resolve) => { - if (opts.abortSignal?.aborted) { - resolve(); - return; - } - opts.abortSignal?.addEventListener("abort", () => resolve(), { once: true }); - }); - } else { - // If no abort signal, wait indefinitely - await new Promise(() => {}); - } - } finally { - if (opts.abortSignal) { - opts.abortSignal.removeEventListener("abort", handleAbort); - } - } -} diff --git a/src/feishu/pairing-store.ts b/src/feishu/pairing-store.ts deleted file mode 100644 index 44f9015de7..0000000000 --- a/src/feishu/pairing-store.ts +++ /dev/null @@ -1,129 +0,0 @@ -import type { OpenClawConfig } from "../config/config.js"; -import { - addChannelAllowFromStoreEntry, - approveChannelPairingCode, - listChannelPairingRequests, - readChannelAllowFromStore, - upsertChannelPairingRequest, -} from "../pairing/pairing-store.js"; - -export type FeishuPairingListEntry = { - openId: string; - unionId?: string; - name?: string; - code: string; - createdAt: string; - lastSeenAt: string; -}; - -const PROVIDER = "feishu" as const; - -export async function readFeishuAllowFromStore( - env: NodeJS.ProcessEnv = process.env, -): Promise { - return readChannelAllowFromStore(PROVIDER, env); -} - -export async function addFeishuAllowFromStoreEntry(params: { - entry: string; - env?: NodeJS.ProcessEnv; -}): Promise<{ changed: boolean; allowFrom: string[] }> { - return addChannelAllowFromStoreEntry({ - channel: PROVIDER, - entry: params.entry, - env: params.env, - }); -} - -export async function listFeishuPairingRequests( - env: NodeJS.ProcessEnv = process.env, -): Promise { - const list = await listChannelPairingRequests(PROVIDER, env); - return list.map((r) => ({ - openId: r.id, - code: r.code, - createdAt: r.createdAt, - lastSeenAt: r.lastSeenAt, - unionId: r.meta?.unionId, - name: r.meta?.name, - })); -} - -export async function upsertFeishuPairingRequest(params: { - openId: string; - unionId?: string; - name?: string; - env?: NodeJS.ProcessEnv; -}): Promise<{ code: string; created: boolean }> { - return upsertChannelPairingRequest({ - channel: PROVIDER, - id: params.openId, - env: params.env, - meta: { - unionId: params.unionId, - name: params.name, - }, - }); -} - -export async function approveFeishuPairingCode(params: { - code: string; - env?: NodeJS.ProcessEnv; -}): Promise<{ openId: string; entry?: FeishuPairingListEntry } | null> { - const res = await approveChannelPairingCode({ - channel: PROVIDER, - code: params.code, - env: params.env, - }); - if (!res) { - return null; - } - const entry = res.entry - ? { - openId: res.entry.id, - code: res.entry.code, - createdAt: res.entry.createdAt, - lastSeenAt: res.entry.lastSeenAt, - unionId: res.entry.meta?.unionId, - name: res.entry.meta?.name, - } - : undefined; - return { openId: res.id, entry }; -} - -export async function resolveFeishuEffectiveAllowFrom(params: { - cfg: OpenClawConfig; - accountId?: string; - env?: NodeJS.ProcessEnv; -}): Promise<{ dm: string[]; group: string[] }> { - const env = params.env ?? process.env; - const feishuCfg = params.cfg.channels?.feishu; - const accountCfg = params.accountId ? feishuCfg?.accounts?.[params.accountId] : undefined; - - // Account-level config takes precedence over top-level - const allowFrom = accountCfg?.allowFrom ?? feishuCfg?.allowFrom ?? []; - const groupAllowFrom = accountCfg?.groupAllowFrom ?? feishuCfg?.groupAllowFrom ?? []; - - const cfgAllowFrom = allowFrom - .map((v) => String(v).trim()) - .filter(Boolean) - .map((v) => v.replace(/^feishu:/i, "")) - .filter((v) => v !== "*"); - - const cfgGroupAllowFrom = groupAllowFrom - .map((v) => String(v).trim()) - .filter(Boolean) - .map((v) => v.replace(/^feishu:/i, "")) - .filter((v) => v !== "*"); - - const storeAllowFrom = await readFeishuAllowFromStore(env); - - const dm = Array.from(new Set([...cfgAllowFrom, ...storeAllowFrom])); - const group = Array.from( - new Set([ - ...(cfgGroupAllowFrom.length > 0 ? cfgGroupAllowFrom : cfgAllowFrom), - ...storeAllowFrom, - ]), - ); - return { dm, group }; -} diff --git a/src/feishu/probe.ts b/src/feishu/probe.ts deleted file mode 100644 index bc2c600a29..0000000000 --- a/src/feishu/probe.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { formatErrorMessage } from "../infra/errors.js"; -import { getChildLogger } from "../logging.js"; -import { resolveFeishuApiBase } from "./domain.js"; - -const logger = getChildLogger({ module: "feishu-probe" }); - -export type FeishuProbe = { - ok: boolean; - error?: string | null; - elapsedMs: number; - bot?: { - appId?: string | null; - appName?: string | null; - avatarUrl?: string | null; - openId?: string | null; - }; -}; - -type TokenResponse = { - code: number; - msg: string; - tenant_access_token?: string; - expire?: number; -}; - -type BotInfoResponse = { - code: number; - msg: string; - bot?: { - app_name?: string; - avatar_url?: string; - open_id?: string; - }; -}; - -async function fetchWithTimeout( - url: string, - options: RequestInit, - timeoutMs: number, -): Promise { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeoutMs); - try { - return await fetch(url, { ...options, signal: controller.signal }); - } finally { - clearTimeout(timer); - } -} - -export async function probeFeishu( - appId: string, - appSecret: string, - timeoutMs: number = 5000, - domain?: string, -): Promise { - const started = Date.now(); - - const result: FeishuProbe = { - ok: false, - error: null, - elapsedMs: 0, - }; - - const apiBase = resolveFeishuApiBase(domain); - - try { - // Step 1: Get tenant_access_token - const tokenRes = await fetchWithTimeout( - `${apiBase}/auth/v3/tenant_access_token/internal`, - { - method: "POST", - headers: { "Content-Type": "application/json; charset=utf-8" }, - body: JSON.stringify({ app_id: appId, app_secret: appSecret }), - }, - timeoutMs, - ); - - const tokenJson = (await tokenRes.json()) as TokenResponse; - if (tokenJson.code !== 0 || !tokenJson.tenant_access_token) { - result.error = tokenJson.msg || `Failed to get access token: code ${tokenJson.code}`; - result.elapsedMs = Date.now() - started; - return result; - } - - const accessToken = tokenJson.tenant_access_token; - - // Step 2: Get bot info - const botRes = await fetchWithTimeout( - `${apiBase}/bot/v3/info`, - { - method: "GET", - headers: { - Authorization: `Bearer ${accessToken}`, - }, - }, - timeoutMs, - ); - - const botJson = (await botRes.json()) as BotInfoResponse; - if (botJson.code !== 0) { - result.error = botJson.msg || `Failed to get bot info: code ${botJson.code}`; - result.elapsedMs = Date.now() - started; - return result; - } - - result.ok = true; - result.bot = { - 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; - } catch (err) { - const errMsg = formatErrorMessage(err); - logger.debug?.(`Feishu probe failed: ${errMsg}`); - return { - ...result, - error: errMsg, - elapsedMs: Date.now() - started, - }; - } -} diff --git a/src/feishu/send.ts b/src/feishu/send.ts deleted file mode 100644 index 0bb8ebaac7..0000000000 --- a/src/feishu/send.ts +++ /dev/null @@ -1,374 +0,0 @@ -import type { Client } from "@larksuiteoapi/node-sdk"; -import { formatErrorMessage } from "../infra/errors.js"; -import { getChildLogger } from "../logging.js"; -import { mediaKindFromMime } from "../media/constants.js"; -import { loadWebMedia } from "../web/media.js"; -import { containsMarkdown, markdownToFeishuPost } from "./format.js"; - -const logger = getChildLogger({ module: "feishu-send" }); - -export type FeishuMsgType = "text" | "image" | "file" | "audio" | "media" | "post" | "interactive"; - -export type FeishuSendOpts = { - msgType?: FeishuMsgType; - receiveIdType?: "open_id" | "user_id" | "union_id" | "email" | "chat_id"; - /** URL of media to upload and send (for image/file/audio/media types) */ - mediaUrl?: string; - /** Max bytes for media download */ - 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 = { - message_id?: string; -}; - -type FeishuMessageContent = ({ text?: string } & Record) | string; - -/** - * Upload an image to Feishu and get image_key - */ -export async function uploadImageFeishu(client: Client, imageBuffer: Buffer): Promise { - const res = await client.im.image.create({ - data: { - image_type: "message", - image: imageBuffer, - }, - }); - - if (!res?.image_key) { - throw new Error(`Feishu image upload failed: no image_key returned`); - } - return res.image_key; -} - -/** - * Upload a file to Feishu and get file_key - * @param fileType - opus (audio), mp4 (video), pdf, doc, xls, ppt, stream (other) - */ -export async function uploadFileFeishu( - client: Client, - fileBuffer: Buffer, - fileName: string, - fileType: "opus" | "mp4" | "pdf" | "doc" | "xls" | "ppt" | "stream", - duration?: number, -): Promise { - logger.info( - `Uploading file to Feishu: name=${fileName}, type=${fileType}, size=${fileBuffer.length}`, - ); - - let res: Awaited>; - try { - res = await client.im.file.create({ - data: { - file_type: fileType, - file_name: fileName, - file: fileBuffer, - ...(duration ? { duration } : {}), - }, - }); - } catch (err) { - const errMsg = formatErrorMessage(err); - // Log the full error details - logger.error(`Feishu file upload exception: ${errMsg}`); - if (err && typeof err === "object") { - const response = (err as { response?: { data?: unknown; status?: number } }).response; - if (response?.data) { - logger.error(`Response data: ${JSON.stringify(response.data)}`); - } - if (response?.status) { - logger.error(`Response status: ${response.status}`); - } - } - throw new Error(`Feishu file upload failed: ${errMsg}`, { cause: err }); - } - - // Log full response for debugging - logger.info(`Feishu file upload response: ${JSON.stringify(res)}`); - - const responseMeta = - res && typeof res === "object" ? (res as { code?: number; msg?: string }) : {}; - // Check for API error code (if provided by SDK) - if (typeof responseMeta.code === "number" && responseMeta.code !== 0) { - const code = responseMeta.code; - const msg = responseMeta.msg || "unknown error"; - logger.error(`Feishu file upload API error: code=${code}, msg=${msg}`); - throw new Error(`Feishu file upload failed: ${msg} (code: ${code})`); - } - - const fileKey = res?.file_key; - if (!fileKey) { - logger.error(`Feishu file upload failed - no file_key in response: ${JSON.stringify(res)}`); - throw new Error(`Feishu file upload failed: no file_key returned`); - } - - logger.info(`Feishu file upload successful: file_key=${fileKey}`); - return fileKey; -} - -/** - * Determine Feishu file_type from content type - */ -function resolveFeishuFileType( - contentType?: string, - fileName?: string, -): "opus" | "mp4" | "pdf" | "doc" | "xls" | "ppt" | "stream" { - const ct = contentType?.toLowerCase() ?? ""; - const fn = fileName?.toLowerCase() ?? ""; - - // Audio - Feishu only supports opus for audio messages - if (ct.includes("audio/") || fn.endsWith(".opus") || fn.endsWith(".ogg")) { - return "opus"; - } - // Video - if (ct.includes("video/") || fn.endsWith(".mp4") || fn.endsWith(".mov")) { - return "mp4"; - } - // Documents - if (ct.includes("pdf") || fn.endsWith(".pdf")) { - return "pdf"; - } - if ( - ct.includes("msword") || - ct.includes("wordprocessingml") || - fn.endsWith(".doc") || - fn.endsWith(".docx") - ) { - return "doc"; - } - if ( - ct.includes("excel") || - ct.includes("spreadsheetml") || - fn.endsWith(".xls") || - fn.endsWith(".xlsx") - ) { - return "xls"; - } - if ( - ct.includes("powerpoint") || - ct.includes("presentationml") || - fn.endsWith(".ppt") || - fn.endsWith(".pptx") - ) { - return "ppt"; - } - - return "stream"; -} - -/** - * Send a message to Feishu - */ -export async function sendMessageFeishu( - client: Client, - receiveId: string, - content: FeishuMessageContent, - opts: FeishuSendOpts = {}, -): Promise { - const receiveIdType = opts.receiveIdType || "chat_id"; - let msgType = opts.msgType || "text"; - let finalContent = content; - const contentText = - typeof content === "object" && content !== null && "text" in content - ? (content as { text?: string }).text - : undefined; - - // Handle media URL - upload first, then send - if (opts.mediaUrl) { - try { - logger.info(`Loading media from: ${opts.mediaUrl}`); - const media = await loadWebMedia(opts.mediaUrl, opts.maxBytes); - const kind = mediaKindFromMime(media.contentType ?? undefined); - const fileName = media.fileName ?? "file"; - logger.info( - `Media loaded: kind=${kind}, contentType=${media.contentType}, fileName=${fileName}, size=${media.buffer.length}`, - ); - - if (kind === "image") { - // Upload image and send as image message - const imageKey = await uploadImageFeishu(client, media.buffer); - msgType = "image"; - finalContent = { image_key: imageKey }; - } else if (kind === "video") { - // Upload video file and send as media message - const fileKey = await uploadFileFeishu(client, media.buffer, fileName, "mp4"); - msgType = "media"; - finalContent = { file_key: fileKey }; - } else if (kind === "audio") { - // Feishu audio messages (msg_type: "audio") only support opus format - // For other audio formats (mp3, wav, etc.), send as file instead - const isOpus = - media.contentType?.includes("opus") || - media.contentType?.includes("ogg") || - fileName.toLowerCase().endsWith(".opus") || - fileName.toLowerCase().endsWith(".ogg"); - - if (isOpus) { - logger.info(`Uploading opus audio: ${fileName}`); - const fileKey = await uploadFileFeishu(client, media.buffer, fileName, "opus"); - logger.info(`Opus upload successful, file_key: ${fileKey}`); - msgType = "audio"; - finalContent = { file_key: fileKey }; - } else { - // Send non-opus audio as file attachment - logger.info(`Uploading non-opus audio as file: ${fileName}`); - const fileKey = await uploadFileFeishu(client, media.buffer, fileName, "stream"); - logger.info(`File upload successful, file_key: ${fileKey}`); - msgType = "file"; - finalContent = { file_key: fileKey }; - } - } else { - // Upload as file - const fileType = resolveFeishuFileType(media.contentType, fileName); - const fileKey = await uploadFileFeishu(client, media.buffer, fileName, fileType); - msgType = "file"; - finalContent = { file_key: fileKey }; - } - - // If there's text alongside media, we need to send two messages - // First send the media, then send text as a follow-up - if (typeof contentText === "string" && contentText.trim()) { - // Send media first - 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}`); - } - } - - // Then send text - const textRes = await client.im.message.create({ - params: { receive_id_type: receiveIdType }, - data: { - receive_id: receiveId, - msg_type: "text", - content: JSON.stringify({ text: contentText }), - }, - }); - - return textRes.data ?? null; - } - } catch (err) { - const errMsg = formatErrorMessage(err); - const errStack = err instanceof Error ? err.stack : undefined; - logger.error(`Feishu media upload/send error: ${errMsg}`); - if (errStack) { - logger.error(`Stack: ${errStack}`); - } - // Re-throw the error instead of falling back to text - // This makes debugging easier and prevents silent failures - throw new Error(`Feishu media upload failed: ${errMsg}`, { cause: err }); - } - } - - // Auto-convert Markdown to rich text if enabled and content is text with Markdown - const autoRichText = opts.autoRichText !== false; - const finalText = - typeof finalContent === "object" && finalContent !== null && "text" in finalContent - ? (finalContent as { text?: string }).text - : undefined; - - if ( - autoRichText && - msgType === "text" && - typeof finalText === "string" && - containsMarkdown(finalText) - ) { - try { - const postContent = markdownToFeishuPost(finalText); - msgType = "post"; - finalContent = postContent; - logger.debug(`Converted Markdown to Feishu post format`); - } catch (err) { - logger.warn( - `Failed to convert Markdown to post, falling back to text: ${formatErrorMessage(err)}`, - ); - // Fall back to plain text - } - } - - 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 }, - data: { - receive_id: receiveId, - msg_type: msgType, - content: contentStr, - }, - }); - - if (res.code !== 0) { - logger.error(`Feishu send failed: ${res.code} - ${res.msg}`); - throw new Error(`Feishu API Error: ${res.msg}`); - } - return res.data ?? null; - } catch (err) { - logger.error(`Feishu send error: ${formatErrorMessage(err)}`); - 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/streaming-card.ts b/src/feishu/streaming-card.ts deleted file mode 100644 index ecc1c9fa37..0000000000 --- a/src/feishu/streaming-card.ts +++ /dev/null @@ -1,404 +0,0 @@ -/** - * Feishu Streaming Card Support - * - * Implements typing indicator and streaming text output for Feishu using - * the Card Kit streaming API. - * - * Flow: - * 1. Create a card entity with streaming_mode: true - * 2. Send the card as a message (shows "[Generating...]" in chat preview) - * 3. Stream text updates to the card using the cardkit API - * 4. Close streaming mode when done - */ - -import type { Client } from "@larksuiteoapi/node-sdk"; -import { getChildLogger } from "../logging.js"; -import { resolveFeishuApiBase, resolveFeishuDomain } from "./domain.js"; - -const logger = getChildLogger({ module: "feishu-streaming" }); - -export type FeishuStreamingCredentials = { - appId: string; - appSecret: string; - domain?: string; -}; - -export type FeishuStreamingCardState = { - cardId: string; - messageId: string; - sequence: number; - elementId: string; - currentText: string; -}; - -// Token cache (keyed by domain + appId) -const tokenCache = new Map(); - -const getTokenCacheKey = (credentials: FeishuStreamingCredentials) => - `${resolveFeishuDomain(credentials.domain)}|${credentials.appId}`; - -/** - * Get tenant access token (with caching) - */ -async function getTenantAccessToken(credentials: FeishuStreamingCredentials): Promise { - const cacheKey = getTokenCacheKey(credentials); - const cached = tokenCache.get(cacheKey); - if (cached && cached.expiresAt > Date.now() + 60000) { - return cached.token; - } - - const apiBase = resolveFeishuApiBase(credentials.domain); - const response = await fetch(`${apiBase}/auth/v3/tenant_access_token/internal`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - app_id: credentials.appId, - app_secret: credentials.appSecret, - }), - }); - - const result = (await response.json()) as { - code: number; - msg: string; - tenant_access_token?: string; - expire?: number; - }; - - if (result.code !== 0 || !result.tenant_access_token) { - throw new Error(`Failed to get tenant access token: ${result.msg}`); - } - - // Cache token (expire 2 hours, we refresh 1 minute early) - tokenCache.set(cacheKey, { - token: result.tenant_access_token, - expiresAt: Date.now() + (result.expire ?? 7200) * 1000, - }); - - return result.tenant_access_token; -} - -/** - * Create a streaming card entity - */ -export async function createStreamingCard( - credentials: FeishuStreamingCredentials, - title?: string, -): Promise<{ cardId: string }> { - const cardJson = { - schema: "2.0", - ...(title - ? { - header: { - title: { - content: title, - tag: "plain_text", - }, - }, - } - : {}), - config: { - streaming_mode: true, - summary: { - content: "[Generating...]", - }, - streaming_config: { - print_frequency_ms: { default: 50 }, - print_step: { default: 2 }, - print_strategy: "fast", - }, - }, - body: { - elements: [ - { - tag: "markdown", - content: "⏳ Thinking...", - element_id: "streaming_content", - }, - ], - }, - }; - - const apiBase = resolveFeishuApiBase(credentials.domain); - const response = await fetch(`${apiBase}/cardkit/v1/cards`, { - method: "POST", - headers: { - Authorization: `Bearer ${await getTenantAccessToken(credentials)}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - type: "card_json", - data: JSON.stringify(cardJson), - }), - }); - - const result = (await response.json()) as { - code: number; - msg: string; - data?: { card_id: string }; - }; - - if (result.code !== 0 || !result.data?.card_id) { - throw new Error(`Failed to create streaming card: ${result.msg}`); - } - - logger.debug(`Created streaming card: ${result.data.card_id}`); - return { cardId: result.data.card_id }; -} - -/** - * Send a streaming card as a message - */ -export async function sendStreamingCard( - client: Client, - receiveId: string, - cardId: string, - receiveIdType: "open_id" | "user_id" | "union_id" | "email" | "chat_id" = "chat_id", -): Promise<{ messageId: string }> { - const content = JSON.stringify({ - type: "card", - data: { card_id: cardId }, - }); - - const res = await client.im.message.create({ - params: { receive_id_type: receiveIdType }, - data: { - receive_id: receiveId, - msg_type: "interactive", - content, - }, - }); - - if (res.code !== 0 || !res.data?.message_id) { - throw new Error(`Failed to send streaming card: ${res.msg}`); - } - - logger.debug(`Sent streaming card message: ${res.data.message_id}`); - return { messageId: res.data.message_id }; -} - -/** - * Update streaming card text content - */ -export async function updateStreamingCardText( - credentials: FeishuStreamingCredentials, - cardId: string, - elementId: string, - text: string, - sequence: number, -): Promise { - const apiBase = resolveFeishuApiBase(credentials.domain); - const response = await fetch( - `${apiBase}/cardkit/v1/cards/${cardId}/elements/${elementId}/content`, - { - method: "PUT", - headers: { - Authorization: `Bearer ${await getTenantAccessToken(credentials)}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - content: text, - sequence, - uuid: `stream_${cardId}_${sequence}`, - }), - }, - ); - - const result = (await response.json()) as { code: number; msg: string }; - - if (result.code !== 0) { - logger.warn(`Failed to update streaming card text: ${result.msg}`); - // Don't throw - streaming updates can fail occasionally - } -} - -/** - * Close streaming mode on a card - */ -export async function closeStreamingMode( - credentials: FeishuStreamingCredentials, - cardId: string, - sequence: number, - finalSummary?: string, -): Promise { - // Build config object - summary must be set to clear "[Generating...]" - const configObj: Record = { - streaming_mode: false, - summary: { content: finalSummary || "" }, - }; - - const settings = { config: configObj }; - - const apiBase = resolveFeishuApiBase(credentials.domain); - const response = await fetch(`${apiBase}/cardkit/v1/cards/${cardId}/settings`, { - method: "PATCH", - headers: { - Authorization: `Bearer ${await getTenantAccessToken(credentials)}`, - "Content-Type": "application/json; charset=utf-8", - }, - body: JSON.stringify({ - settings: JSON.stringify(settings), - sequence, - uuid: `close_${cardId}_${sequence}`, - }), - }); - - // Check response - const result = (await response.json()) as { code: number; msg: string }; - - if (result.code !== 0) { - logger.warn(`Failed to close streaming mode: ${result.msg}`); - } else { - logger.debug(`Closed streaming mode for card: ${cardId}`); - } -} - -/** - * High-level streaming card manager - */ -export class FeishuStreamingSession { - private client: Client; - private credentials: FeishuStreamingCredentials; - private state: FeishuStreamingCardState | null = null; - private updateQueue: Promise = Promise.resolve(); - private closed = false; - - constructor(client: Client, credentials: FeishuStreamingCredentials) { - this.client = client; - this.credentials = credentials; - } - - /** - * Start a streaming session - creates and sends a streaming card - */ - async start( - receiveId: string, - receiveIdType: "open_id" | "user_id" | "union_id" | "email" | "chat_id" = "chat_id", - title?: string, - ): Promise { - if (this.state) { - logger.warn("Streaming session already started"); - return; - } - - try { - const { cardId } = await createStreamingCard(this.credentials, title); - const { messageId } = await sendStreamingCard(this.client, receiveId, cardId, receiveIdType); - - this.state = { - cardId, - messageId, - sequence: 1, - elementId: "streaming_content", - currentText: "", - }; - - logger.info(`Started streaming session: cardId=${cardId}, messageId=${messageId}`); - } catch (err) { - logger.error(`Failed to start streaming session: ${String(err)}`); - throw err; - } - } - - /** - * Update the streaming card with new text (appends to existing) - */ - async update(text: string): Promise { - if (!this.state || this.closed) { - return; - } - - // Queue updates to ensure order - this.updateQueue = this.updateQueue.then(async () => { - if (!this.state || this.closed) { - return; - } - - this.state.currentText = text; - this.state.sequence += 1; - - try { - await updateStreamingCardText( - this.credentials, - this.state.cardId, - this.state.elementId, - text, - this.state.sequence, - ); - } catch (err) { - logger.debug(`Streaming update failed (will retry): ${String(err)}`); - } - }); - - await this.updateQueue; - } - - /** - * Finalize and close the streaming session - */ - async close(finalText?: string, summary?: string): Promise { - if (!this.state || this.closed) { - return; - } - this.closed = true; - - // Wait for pending updates - await this.updateQueue; - - const text = finalText ?? this.state.currentText; - this.state.sequence += 1; - - try { - // Update final text - if (text) { - await updateStreamingCardText( - this.credentials, - this.state.cardId, - this.state.elementId, - text, - this.state.sequence, - ); - } - - // Close streaming mode - this.state.sequence += 1; - await closeStreamingMode( - this.credentials, - this.state.cardId, - this.state.sequence, - summary ?? truncateForSummary(text), - ); - - logger.info(`Closed streaming session: cardId=${this.state.cardId}`); - } catch (err) { - logger.error(`Failed to close streaming session: ${String(err)}`); - } - } - - /** - * Check if session is active - */ - isActive(): boolean { - return this.state !== null && !this.closed; - } - - /** - * Get the message ID of the streaming card - */ - getMessageId(): string | null { - return this.state?.messageId ?? null; - } -} - -/** - * Truncate text to create a summary for chat preview - */ -function truncateForSummary(text: string, maxLength: number = 50): string { - if (!text) { - return ""; - } - const cleaned = text.replace(/\n/g, " ").trim(); - if (cleaned.length <= maxLength) { - return cleaned; - } - return cleaned.slice(0, maxLength - 3) + "..."; -} diff --git a/src/feishu/types.ts b/src/feishu/types.ts deleted file mode 100644 index 32eef75441..0000000000 --- a/src/feishu/types.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { FeishuAccountConfig, FeishuConfig } from "../config/types.feishu.js"; - -export type { FeishuConfig, FeishuAccountConfig }; - -export type FeishuContext = { - appId: string; - chatId?: string; - openId?: string; - userId?: string; - messageId?: string; - messageType?: string; - text?: string; - raw?: unknown; -}; diff --git a/src/feishu/typing.ts b/src/feishu/typing.ts deleted file mode 100644 index 85dd6001ae..0000000000 --- a/src/feishu/typing.ts +++ /dev/null @@ -1,89 +0,0 @@ -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 deleted file mode 100644 index 1598c94431..0000000000 --- a/src/feishu/user.ts +++ /dev/null @@ -1,93 +0,0 @@ -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); - } - } -} diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 05128012e5..cbbbf65aa7 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -370,22 +370,5 @@ export { } from "../line/markdown-to-line.js"; export type { ProcessedLineMessage } from "../line/markdown-to-line.js"; -// Channel: Feishu -export { - listFeishuAccountIds, - resolveDefaultFeishuAccountId, - resolveFeishuAccount, - type ResolvedFeishuAccount, -} from "../feishu/accounts.js"; -export { - resolveFeishuConfig, - resolveFeishuGroupEnabled, - resolveFeishuGroupRequireMention, -} from "../feishu/config.js"; -export { feishuOutbound } from "../channels/plugins/outbound/feishu.js"; -export { normalizeFeishuTarget } from "../channels/plugins/normalize/feishu.js"; -export { probeFeishu, type FeishuProbe } from "../feishu/probe.js"; -export { monitorFeishuProvider } from "../feishu/monitor.js"; - // Media utilities export { loadWebMedia, type WebMediaResult } from "../web/media.js";