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 <noreply@anthropic.com>
This commit is contained in:
Yifeng Wang
2026-02-05 18:26:05 +08:00
committed by cpojer
parent 02842bef91
commit 2267d58afc
66 changed files with 5702 additions and 4486 deletions

View File

@@ -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

View File

@@ -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);
},
};

View File

@@ -1,6 +1,7 @@
{
"id": "feishu",
"channels": ["feishu"],
"skills": ["./skills"],
"configSchema": {
"type": "object",
"additionalProperties": false,

View File

@@ -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"
],

View File

@@ -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`

View File

@@ -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.

View File

@@ -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

View File

@@ -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`

View File

@@ -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`

View File

@@ -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);
}

View File

@@ -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<number, string> = {
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<typeof createFeishuClient>,
nodeToken: string,
): Promise<string> {
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<typeof createFeishuClient>, 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<typeof createFeishuClient>,
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<typeof createFeishuClient>,
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<typeof createFeishuClient>,
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<typeof createFeishuClient>,
appToken: string,
tableId: string,
fields: Record<string, unknown>,
) {
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<typeof createFeishuClient>,
appToken: string,
tableId: string,
recordId: string,
fields: Record<string, unknown>,
) {
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<string, unknown>;
};
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<string, unknown>;
};
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`);
}

View File

@@ -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<string, { name: string; expireAt: number }>();
// Cache permission errors to avoid spamming the user with repeated notifications.
// Key: appId or "default", Value: timestamp of last notification
const permissionErrorNotifiedAt = new Map<string, number>();
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<SenderNameResult> {
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 "<media:image>";
case "file":
return "<media:document>";
case "audio":
return "<media:audio>";
case "video":
return "<media:video>";
case "sticker":
return "<media:sticker>";
default:
return "<media:document>";
}
}
/**
* 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<FeishuMediaInfo[]> {
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: "<media:image>",
});
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<string, HistoryEntry[]>;
}): Promise<void> {
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)}`);
}
}

View File

@@ -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<ResolvedFeishuAccount> = {
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<ResolvedFeishuAccount> = {
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: "<open_id|union_id|chat_id>",
},
},
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<string, unknown>).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<string, { groupPolicy?: string }> | 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: "<chatId|user:openId|chat:chatId>",
},
},
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<ResolvedFeishuAccount> = {
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;
}
},
},
};

View File

@@ -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;
}

View File

@@ -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 "*"',
});
}
}
});

View File

@@ -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<FeishuDirectoryPeer[]> {
const feishuCfg = params.cfg.channels?.feishu as FeishuConfig | undefined;
const q = params.query?.trim().toLowerCase() || "";
const ids = new Set<string>();
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<FeishuDirectoryGroup[]> {
const feishuCfg = params.cfg.channels?.feishu as FeishuConfig | undefined;
const q = params.query?.trim().toLowerCase() || "";
const ids = new Set<string>();
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<FeishuDirectoryPeer[]> {
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<FeishuDirectoryGroup[]> {
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);
}
}

View File

@@ -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<typeof FeishuDocSchema>;

View File

@@ -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<number, string> = {
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<string> {
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<Buffer> {
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<number> {
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<string, number> = {};
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(", ")}`);
}
}

View File

@@ -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<typeof FeishuDriveSchema>;

View File

@@ -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<string> {
// 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`);
}

View File

@@ -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<DownloadImageResult> {
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<DownloadMessageResourceResult> {
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<UploadImageResult> {
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<UploadFileResult> {
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<SendMediaResult> {
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<SendMediaResult> {
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<SendMediaResult> {
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 });
}
}

View File

@@ -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 `<at user_id="${target.openId}">${target.name}</at>`;
}
/**
* Format @everyone for text message
*/
export function formatMentionAllForText(): string {
return `<at user_id="all">Everyone</at>`;
}
/**
* Format @mention for card message (lark_md)
*/
export function formatMentionForCard(target: MentionTarget): string {
return `<at id=${target.openId}></at>`;
}
/**
* Format @everyone for card message
*/
export function formatMentionAllForCard(): string {
return `<at id=all></at>`;
}
/**
* 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}`;
}

View File

@@ -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<string | undefined> {
try {
const result = await probeFeishu(cfg);
return result.ok ? result.botOpenId : undefined;
} catch {
return undefined;
}
}
export async function monitorFeishuProvider(opts: MonitorFeishuOpts = {}): Promise<void> {
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<void> {
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<string, HistoryEntry[]>();
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;
}
}

View File

@@ -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<void> {
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<OpenClawConfig> {
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<ClawdbotConfig> {
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<void> {
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<string, unknown>;
const accounts = feishu.accounts
? { ...(feishu.accounts as Record<string, unknown>) }
: 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<string, unknown>) ?? {};
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 },
},
}),
};

View File

@@ -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 };
},
};

View File

@@ -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<typeof FeishuPermSchema>;

View File

@@ -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`);
}

View File

@@ -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<string | number>;
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<string | number>;
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 };
}

View File

@@ -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<FeishuProbeResult> {
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),
};
}
}

View File

@@ -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<void> {
export async function removeReactionFeishu(params: {
cfg: ClawdbotConfig;
messageId: string;
reactionId: string;
}): Promise<void> {
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<FeishuReaction[]> {
export async function listReactionsFeishu(params: {
cfg: ClawdbotConfig;
messageId: string;
emojiType?: string;
}): Promise<FeishuReaction[]> {
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];

View File

@@ -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,
};
}

View File

@@ -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;
}

View File

@@ -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<FeishuMessageInfo | null> {
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<FeishuSendResult> {
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<string, unknown>;
replyToMessageId?: string;
};
export async function sendCardFeishu(params: SendFeishuCardParams): Promise<FeishuSendResult> {
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<string, unknown>;
}): Promise<void> {
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<string, unknown> {
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<FeishuSendResult> {
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<void> {
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}`}`);
}
}

View File

@@ -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;
}

View File

@@ -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<FeishuToolsConfig> = {
doc: true,
wiki: true,
drive: true,
perm: false,
scopes: true,
};
/**
* Resolve tools config with defaults.
*/
export function resolveToolsConfig(cfg?: FeishuToolsConfig): Required<FeishuToolsConfig> {
return { ...DEFAULT_TOOLS_CONFIG, ...cfg };
}

View File

@@ -0,0 +1,63 @@
import type { FeishuConfigSchema, FeishuGroupSchema, z } from "./config-schema.js";
import type { MentionTarget } from "./mention.js";
export type FeishuConfig = z.infer<typeof FeishuConfigSchema>;
export type FeishuGroupConfig = z.infer<typeof FeishuGroupSchema>;
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;
};

View File

@@ -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<TypingIndicatorState> {
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<void> {
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}`);
}
}

View File

@@ -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<typeof FeishuWikiSchema>;

View File

@@ -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`);
}

View File

@@ -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;
}

View File

@@ -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,
};
},
};

View File

@@ -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;

View File

@@ -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<string | number>;
/** 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<string | number>;
/** Optional allowlist for Feishu group senders. */
groupAllowFrom?: Array<string | number>;
/** 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<string, DmConfig>;
/** Per-group config keyed by chat_id (oc_xxx). */
groups?: Record<string, FeishuGroupConfig>;
/** 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<string, FeishuAccountConfig>;
/** 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<FeishuAccountConfig, "appId" | "appSecret" | "appSecretFile">;

View File

@@ -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";

View File

@@ -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<string | number>): 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<string | number>;
storeAllowFrom?: string[];
}): NormalizedAllowFrom => {
const combined = [...(params.allowFrom ?? []), ...(params.storeAllowFrom ?? [])]
.map((value) => String(value).trim())
.filter(Boolean);
return normalizeAllowFrom(combined);
};
export const firstDefined = <T>(...values: Array<T | undefined>) => {
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 };
};

View File

@@ -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<FeishuTokenSource, "env" | "none">;
} {
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<string>();
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,
};
}

View File

@@ -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<typeof createFeishuBot>) {
logger.info("Starting Feishu bot WS client...");
await bot.wsClient.start({
eventDispatcher: bot.eventDispatcher,
});
}

View File

@@ -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.<id>.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;
}

View File

@@ -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<string, FeishuGroupConfig>;
};
/**
* 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;
}

View File

@@ -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);
});
});

View File

@@ -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<T> = {
code?: number;
msg?: string;
data?: T;
};
type FeishuRequestClient = {
request: <T>(params: {
method: string;
url: string;
params?: Record<string, unknown>;
data?: Record<string, unknown>;
}) => Promise<FeishuApiResponse<T>>;
};
/**
* 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<string>();
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<string>();
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<string | null> {
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");
}

View File

@@ -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`;
}

View File

@@ -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<FeishuMediaRef> {
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 "<media:image>";
case "audio":
return "<media:audio>";
case "video":
return "<media:video>";
default:
return "<media:document>";
}
}
/**
* 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<FeishuMediaRef | null> {
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: "<media:audio>",
};
}
} 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: "<media:video>",
};
}
} 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<string, unknown>;
// Handle locale-wrapped format: { post: { zh_cn: { content: [...] } } }
let postData = obj;
if (obj.post && typeof obj.post === "object") {
const post = obj.post as Record<string, unknown>;
const localeKey = Object.keys(post).find((key) => post[key] && typeof post[key] === "object");
if (localeKey) {
postData = post[localeKey] as Record<string, unknown>;
}
}
// 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<string, unknown>).tag === "img" &&
typeof (element as Record<string, unknown>).image_key === "string"
) {
imageKeys.push((element as Record<string, unknown>).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<FeishuMediaRef[]> {
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;
}

View File

@@ -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();
});
});

View File

@@ -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<number, string> {
const map = new Map<number, string>();
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<number, string>, 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));
}

View File

@@ -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";

View File

@@ -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();
}

View File

@@ -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<void> {
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<void>((resolve) => {
if (opts.abortSignal?.aborted) {
resolve();
return;
}
opts.abortSignal?.addEventListener("abort", () => resolve(), { once: true });
});
} else {
// If no abort signal, wait indefinitely
await new Promise<void>(() => {});
}
} finally {
if (opts.abortSignal) {
opts.abortSignal.removeEventListener("abort", handleAbort);
}
}
}

View File

@@ -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<string[]> {
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<FeishuPairingListEntry[]> {
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 };
}

View File

@@ -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<Response> {
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<FeishuProbe> {
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,
};
}
}

View File

@@ -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, unknown>) | string;
/**
* Upload an image to Feishu and get image_key
*/
export async function uploadImageFeishu(client: Client, imageBuffer: Buffer): Promise<string> {
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<string> {
logger.info(
`Uploading file to Feishu: name=${fileName}, type=${fileType}, size=${fileBuffer.length}`,
);
let res: Awaited<ReturnType<typeof client.im.file.create>>;
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<FeishuSendResult | null> {
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<FeishuSendResult | null> {
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;
}
}

View File

@@ -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<string, { token: string; expiresAt: number }>();
const getTokenCacheKey = (credentials: FeishuStreamingCredentials) =>
`${resolveFeishuDomain(credentials.domain)}|${credentials.appId}`;
/**
* Get tenant access token (with caching)
*/
async function getTenantAccessToken(credentials: FeishuStreamingCredentials): Promise<string> {
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<void> {
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<void> {
// Build config object - summary must be set to clear "[Generating...]"
const configObj: Record<string, unknown> = {
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<void> = 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<void> {
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<void> {
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<void> {
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) + "...";
}

View File

@@ -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;
};

View File

@@ -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<TypingIndicatorState> {
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<void> {
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<void>;
onIdle: () => Promise<void>;
} {
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;
},
};
}

View File

@@ -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<string, { info: FeishuUserInfo; expiresAt: number }>();
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<FeishuUserInfo | null> {
// 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<string> {
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);
}
}
}

View File

@@ -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";