重构主控编排与运行时预热链路,统一工作区提示词/专家调度协议并补齐 wiki 记忆注入与写回闭环。

同时收敛启动与运维脚本默认行为(含 wiki worker)、更新 Admin 可观测性与相关测试,降低首轮时延并提高运行稳定性。

Made-with: Cursor
This commit is contained in:
oliver 2026-04-26 08:34:33 +08:00
parent 4a23b715a2
commit dbbe3add6a
14438 changed files with 2693620 additions and 2546 deletions

View file

@ -0,0 +1,20 @@
export { feishuPlugin } from "./src/channel.js";
export { registerFeishuDocTools } from "./src/docx.js";
export { registerFeishuChatTools } from "./src/chat.js";
export { registerFeishuWikiTools } from "./src/wiki.js";
export { registerFeishuDriveTools } from "./src/drive.js";
export { registerFeishuPermTools } from "./src/perm.js";
export { registerFeishuBitableTools } from "./src/bitable.js";
export {
handleFeishuSubagentDeliveryTarget,
handleFeishuSubagentEnded,
handleFeishuSubagentSpawning,
} from "./src/subagent-hooks.js";
export * from "./src/conversation-id.js";
export * from "./src/setup-core.js";
export * from "./src/setup-surface.js";
export * from "./src/thread-bindings.js";
export { __testing as feishuThreadBindingTesting } from "./src/thread-bindings.js";
export { createClackPrompter } from "openclaw/plugin-sdk/setup-runtime";
export const feishuSessionBindingAdapterChannels = ["feishu"] as const;

View file

@ -0,0 +1,20 @@
import { defineBundledChannelEntry } from "openclaw/plugin-sdk/channel-entry-contract";
export default defineBundledChannelEntry({
id: "feishu",
name: "Feishu",
description: "Feishu/Lark channel plugin",
importMetaUrl: import.meta.url,
plugin: {
specifier: "./api.js",
exportName: "feishuPlugin",
},
secrets: {
specifier: "./secret-contract-api.js",
exportName: "channelSecrets",
},
runtime: {
specifier: "./runtime-api.js",
exportName: "setFeishuRuntime",
},
});

View file

@ -0,0 +1,16 @@
export { createFeishuThreadBindingManager } from "./src/thread-bindings.js";
export { __testing as feishuThreadBindingTesting } from "./src/thread-bindings.js";
export {
collectRuntimeConfigAssignments,
secretTargetRegistryEntries,
} from "./src/secret-contract.js";
export { collectFeishuSecurityAuditFindings } from "./src/security-audit.js";
export { messageActionTargetAliases } from "./src/message-action-contract.js";
export {
buildFeishuConversationId,
parseFeishuConversationId,
parseFeishuDirectConversationId,
parseFeishuTargetId,
} from "./src/conversation-id.js";
export const feishuSessionBindingAdapterChannels = ["feishu"] as const;

View file

@ -0,0 +1,101 @@
import {
defineBundledChannelEntry,
loadBundledEntryExportSync,
} from "openclaw/plugin-sdk/channel-entry-contract";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/channel-entry-contract";
type FeishuSubagentHooksModule = typeof import("./api.js");
let feishuSubagentHooksPromise: Promise<FeishuSubagentHooksModule> | null = null;
function loadFeishuSubagentHooksModule() {
feishuSubagentHooksPromise ??= import("./api.js");
return feishuSubagentHooksPromise;
}
function registerFeishuDocTools(api: OpenClawPluginApi) {
const register = loadBundledEntryExportSync<(api: OpenClawPluginApi) => void>(import.meta.url, {
specifier: "./api.js",
exportName: "registerFeishuDocTools",
});
register(api);
}
function registerFeishuChatTools(api: OpenClawPluginApi) {
const register = loadBundledEntryExportSync<(api: OpenClawPluginApi) => void>(import.meta.url, {
specifier: "./api.js",
exportName: "registerFeishuChatTools",
});
register(api);
}
function registerFeishuWikiTools(api: OpenClawPluginApi) {
const register = loadBundledEntryExportSync<(api: OpenClawPluginApi) => void>(import.meta.url, {
specifier: "./api.js",
exportName: "registerFeishuWikiTools",
});
register(api);
}
function registerFeishuDriveTools(api: OpenClawPluginApi) {
const register = loadBundledEntryExportSync<(api: OpenClawPluginApi) => void>(import.meta.url, {
specifier: "./api.js",
exportName: "registerFeishuDriveTools",
});
register(api);
}
function registerFeishuPermTools(api: OpenClawPluginApi) {
const register = loadBundledEntryExportSync<(api: OpenClawPluginApi) => void>(import.meta.url, {
specifier: "./api.js",
exportName: "registerFeishuPermTools",
});
register(api);
}
function registerFeishuBitableTools(api: OpenClawPluginApi) {
const register = loadBundledEntryExportSync<(api: OpenClawPluginApi) => void>(import.meta.url, {
specifier: "./api.js",
exportName: "registerFeishuBitableTools",
});
register(api);
}
export default defineBundledChannelEntry({
id: "feishu",
name: "Feishu",
description: "Feishu/Lark channel plugin",
importMetaUrl: import.meta.url,
plugin: {
specifier: "./api.js",
exportName: "feishuPlugin",
},
secrets: {
specifier: "./secret-contract-api.js",
exportName: "channelSecrets",
},
runtime: {
specifier: "./runtime-api.js",
exportName: "setFeishuRuntime",
},
registerFull(api) {
api.on("subagent_spawning", async (event, ctx) => {
const { handleFeishuSubagentSpawning } = await loadFeishuSubagentHooksModule();
return await handleFeishuSubagentSpawning(event, ctx);
});
api.on("subagent_delivery_target", async (event) => {
const { handleFeishuSubagentDeliveryTarget } = await loadFeishuSubagentHooksModule();
return handleFeishuSubagentDeliveryTarget(event);
});
api.on("subagent_ended", async (event) => {
const { handleFeishuSubagentEnded } = await loadFeishuSubagentHooksModule();
handleFeishuSubagentEnded(event);
});
registerFeishuDocTools(api);
registerFeishuChatTools(api);
registerFeishuWikiTools(api);
registerFeishuDriveTools(api);
registerFeishuPermTools(api);
registerFeishuBitableTools(api);
},
});

View file

@ -0,0 +1,18 @@
{
"id": "feishu",
"channels": ["feishu"],
"channelEnvVars": {
"feishu": [
"FEISHU_APP_ID",
"FEISHU_APP_SECRET",
"FEISHU_VERIFICATION_TOKEN",
"FEISHU_ENCRYPT_KEY"
]
},
"skills": ["./skills"],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View file

@ -0,0 +1,60 @@
{
"name": "@openclaw/feishu",
"version": "2026.4.20",
"description": "OpenClaw Feishu/Lark channel plugin (community maintained by @m1heng)",
"type": "module",
"dependencies": {
"@larksuiteoapi/node-sdk": "^1.60.0",
"@sinclair/typebox": "0.34.49",
"qrcode-terminal": "^0.12.0"
},
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*",
"openclaw": "workspace:*"
},
"peerDependencies": {
"openclaw": ">=2026.4.20"
},
"peerDependenciesMeta": {
"openclaw": {
"optional": true
}
},
"openclaw": {
"extensions": [
"./index.ts"
],
"setupEntry": "./setup-entry.ts",
"channel": {
"id": "feishu",
"label": "Feishu",
"selectionLabel": "Feishu/Lark (飞书)",
"docsPath": "/channels/feishu",
"docsLabel": "feishu",
"blurb": "飞书/Lark enterprise messaging with doc/wiki/drive tools.",
"aliases": [
"lark"
],
"order": 35,
"quickstartAllowFrom": true
},
"install": {
"npmSpec": "@openclaw/feishu",
"defaultChoice": "npm",
"minHostVersion": ">=2026.4.10"
},
"compat": {
"pluginApi": ">=2026.4.20"
},
"build": {
"openclawVersion": "2026.4.20"
},
"bundle": {
"stageRuntimeDependencies": true
},
"release": {
"publishToClawHub": true,
"publishToNpm": true
}
}
}

View file

@ -0,0 +1,52 @@
// Private runtime barrel for the bundled Feishu extension.
// Keep this barrel thin and generic-only.
export type {
AllowlistMatch,
AnyAgentTool,
BaseProbeResult,
ChannelGroupContext,
ChannelMessageActionName,
ChannelMeta,
ChannelOutboundAdapter,
ChannelPlugin,
HistoryEntry,
OpenClawConfig,
OpenClawPluginApi,
OutboundIdentity,
PluginRuntime,
ReplyPayload,
} from "openclaw/plugin-sdk/core";
export type { OpenClawConfig as ClawdbotConfig } from "openclaw/plugin-sdk/core";
export type { RuntimeEnv } from "openclaw/plugin-sdk/runtime";
export type { GroupToolPolicyConfig } from "openclaw/plugin-sdk/config-runtime";
export {
DEFAULT_ACCOUNT_ID,
buildChannelConfigSchema,
createActionGate,
createDedupeCache,
} from "openclaw/plugin-sdk/core";
export {
PAIRING_APPROVED_MESSAGE,
buildProbeChannelStatusSummary,
createDefaultChannelRuntimeState,
} from "openclaw/plugin-sdk/channel-status";
export { buildAgentMediaPayload } from "openclaw/plugin-sdk/agent-media-payload";
export { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing";
export { createReplyPrefixContext } from "openclaw/plugin-sdk/channel-reply-pipeline";
export {
evaluateSupplementalContextVisibility,
filterSupplementalContextItems,
resolveChannelContextVisibilityMode,
} from "openclaw/plugin-sdk/config-runtime";
export { loadSessionStore, resolveSessionStoreEntry } from "openclaw/plugin-sdk/config-runtime";
export { readJsonFileWithFallback } from "openclaw/plugin-sdk/json-store";
export { createPersistentDedupe } from "openclaw/plugin-sdk/persistent-dedupe";
export { normalizeAgentId } from "openclaw/plugin-sdk/routing";
export { chunkTextForOutbound } from "openclaw/plugin-sdk/text-chunking";
export {
isRequestBodyLimitError,
readRequestBodyWithLimit,
requestBodyErrorToText,
} from "openclaw/plugin-sdk/webhook-ingress";
export { setFeishuRuntime } from "./src/runtime.js";

View file

@ -0,0 +1,5 @@
export {
channelSecrets,
collectRuntimeConfigAssignments,
secretTargetRegistryEntries,
} from "./src/secret-contract.js";

View file

@ -0,0 +1,81 @@
import type { OpenClawConfig } from "./runtime-api.js";
function asRecord(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}
function hasNonEmptyString(value: unknown): boolean {
return typeof value === "string" && value.trim().length > 0;
}
function hasConfiguredSecretInput(value: unknown): boolean {
if (hasNonEmptyString(value)) {
return true;
}
const record = asRecord(value);
return (
Boolean(record) &&
hasNonEmptyString(record?.source) &&
hasNonEmptyString(record?.provider) &&
hasNonEmptyString(record?.id)
);
}
function isFeishuDocToolEnabled(cfg: OpenClawConfig): boolean {
const channels = asRecord(cfg.channels);
const feishu = asRecord(channels?.feishu);
if (!feishu || feishu.enabled === false) {
return false;
}
const baseTools = asRecord(feishu.tools);
const baseDocEnabled = baseTools?.doc !== false;
const baseAppId = hasNonEmptyString(feishu.appId);
const baseAppSecret = hasConfiguredSecretInput(feishu.appSecret);
const baseConfigured = baseAppId && baseAppSecret;
const accounts = asRecord(feishu.accounts);
if (!accounts || Object.keys(accounts).length === 0) {
return baseDocEnabled && baseConfigured;
}
for (const accountValue of Object.values(accounts)) {
const account = asRecord(accountValue) ?? {};
if (account.enabled === false) {
continue;
}
const accountTools = asRecord(account.tools);
const effectiveTools = accountTools ?? baseTools;
const docEnabled = effectiveTools?.doc !== false;
if (!docEnabled) {
continue;
}
const accountConfigured =
(hasNonEmptyString(account.appId) || baseAppId) &&
(hasConfiguredSecretInput(account.appSecret) || baseAppSecret);
if (accountConfigured) {
return true;
}
}
return false;
}
export function collectFeishuSecurityAuditFindings(params: { cfg: OpenClawConfig }) {
if (!isFeishuDocToolEnabled(params.cfg)) {
return [];
}
return [
{
checkId: "channels.feishu.doc_owner_open_id",
severity: "warn" as const,
title: "Feishu doc create can grant requester permissions",
detail:
'channels.feishu tools include "doc"; feishu_doc action "create" can grant document access to the trusted requesting Feishu user.',
remediation:
"Disable channels.feishu.tools.doc when not needed, and restrict tool access for untrusted prompts.",
},
];
}

View file

@ -0,0 +1 @@
export { resolveFeishuSessionConversation as resolveSessionConversation } from "./src/session-conversation.js";

View file

@ -0,0 +1,2 @@
export { feishuSetupAdapter } from "./src/setup-core.js";
export { feishuSetupWizard } from "./src/setup-surface.js";

View file

@ -0,0 +1,13 @@
import { defineBundledChannelSetupEntry } from "openclaw/plugin-sdk/channel-entry-contract";
export default defineBundledChannelSetupEntry({
importMetaUrl: import.meta.url,
plugin: {
specifier: "./api.js",
exportName: "feishuPlugin",
},
secrets: {
specifier: "./secret-contract-api.js",
exportName: "channelSecrets",
},
});

View file

@ -0,0 +1,211 @@
---
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, including table creation for Docx.
## 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", "owner_open_id": "ou_xxx" }
```
With folder:
```json
{
"action": "create",
"title": "New Document",
"folder_token": "fldcnXXX",
"owner_open_id": "ou_xxx"
}
```
**Important:** Always pass `owner_open_id` with the requesting user's `open_id` (from inbound metadata `sender_id`) so the user automatically gets `full_access` permission on the created document. Without this, only the bot app has access.
### 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" }
```
### Create Table (Docx Table Block)
```json
{
"action": "create_table",
"doc_token": "ABC123def",
"row_size": 2,
"column_size": 2,
"column_width": [200, 200]
}
```
Optional: `parent_block_id` to insert under a specific block.
### Write Table Cells
```json
{
"action": "write_table_cells",
"doc_token": "ABC123def",
"table_block_id": "doxcnTABLE",
"values": [
["A1", "B1"],
["A2", "B2"]
]
}
```
### Create Table With Values (One-step)
```json
{
"action": "create_table_with_values",
"doc_token": "ABC123def",
"row_size": 2,
"column_size": 2,
"column_width": [200, 200],
"values": [
["A1", "B1"],
["A2", "B2"]
]
}
```
Optional: `parent_block_id` to insert under a specific block.
### Upload Image to Docx (from URL or local file)
```json
{
"action": "upload_image",
"doc_token": "ABC123def",
"url": "https://example.com/image.png"
}
```
Or local path with position control:
```json
{
"action": "upload_image",
"doc_token": "ABC123def",
"file_path": "/tmp/image.png",
"parent_block_id": "doxcnParent",
"index": 5
}
```
Optional `index` (0-based) inserts the image at a specific position among sibling blocks. Omit to append at end.
**Note:** Image display size is determined by the uploaded image's pixel dimensions. For small images (e.g. 480x270 GIFs), scale to 800px+ width before uploading to ensure proper display.
### Upload File Attachment to Docx (from URL or local file)
```json
{
"action": "upload_file",
"doc_token": "ABC123def",
"url": "https://example.com/report.pdf"
}
```
Or local path:
```json
{
"action": "upload_file",
"doc_token": "ABC123def",
"file_path": "/tmp/report.pdf",
"filename": "Q1-report.pdf"
}
```
Rules:
- exactly one of `url` / `file_path`
- optional `filename` override
- optional `parent_block_id`
## 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,459 @@
import { describe, expect, it } from "vitest";
import {
FeishuSecretRefUnavailableError,
inspectFeishuCredentials,
resolveDefaultFeishuAccountId,
resolveDefaultFeishuAccountSelection,
resolveFeishuAccount,
resolveFeishuCredentials,
resolveFeishuRuntimeAccount,
} from "./accounts.js";
import type { FeishuConfig } from "./types.js";
function makeDefaultAndRouterAccounts() {
return {
default: { appId: "cli_default", appSecret: "secret_default" }, // pragma: allowlist secret
"router-d": { appId: "cli_router", appSecret: "secret_router" }, // pragma: allowlist secret
};
}
function expectExplicitDefaultAccountSelection(
account: ReturnType<typeof resolveFeishuAccount>,
appId: string,
) {
expect(account.accountId).toBe("router-d");
expect(account.selectionSource).toBe("explicit-default");
expect(account.configured).toBe(true);
expect(account.appId).toBe(appId);
}
function withEnvVar(key: string, value: string | undefined, run: () => void) {
const prev = process.env[key];
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
try {
run();
} finally {
if (prev === undefined) {
delete process.env[key];
} else {
process.env[key] = prev;
}
}
}
function asConfig(config: Partial<FeishuConfig>): FeishuConfig {
return config as unknown as FeishuConfig;
}
function expectUnresolvedEnvSecretRefError(key: string) {
expect(() =>
resolveFeishuCredentials(
asConfig({
appId: "cli_123",
appSecret: { source: "env", provider: "default", id: key } as never,
}),
),
).toThrow(/unresolved SecretRef/i);
}
describe("resolveDefaultFeishuAccountId", () => {
it("prefers channels.feishu.defaultAccount when configured", () => {
const cfg = {
channels: {
feishu: {
defaultAccount: "router-d",
accounts: makeDefaultAndRouterAccounts(),
},
},
};
expect(resolveDefaultFeishuAccountId(cfg as never)).toBe("router-d");
});
it("normalizes configured defaultAccount before lookup", () => {
const cfg = {
channels: {
feishu: {
defaultAccount: "Router D",
accounts: {
"router-d": { appId: "cli_router", appSecret: "secret_router" }, // pragma: allowlist secret
},
},
},
};
expect(resolveDefaultFeishuAccountId(cfg as never)).toBe("router-d");
});
it("keeps configured defaultAccount even when not present in accounts map", () => {
const cfg = {
channels: {
feishu: {
defaultAccount: "router-d",
accounts: {
default: { appId: "cli_default", appSecret: "secret_default" }, // pragma: allowlist secret
zeta: { appId: "cli_zeta", appSecret: "secret_zeta" }, // pragma: allowlist secret
},
},
},
};
expect(resolveDefaultFeishuAccountId(cfg as never)).toBe("router-d");
});
it("falls back to literal default account id when present", () => {
const cfg = {
channels: {
feishu: {
accounts: {
default: { appId: "cli_default", appSecret: "secret_default" }, // pragma: allowlist secret
zeta: { appId: "cli_zeta", appSecret: "secret_zeta" }, // pragma: allowlist secret
},
},
},
};
expect(resolveDefaultFeishuAccountId(cfg as never)).toBe("default");
});
it("reports selection source for configured defaults and mapped defaults", () => {
const explicitDefaultCfg = {
channels: {
feishu: {
defaultAccount: "router-d",
accounts: {},
},
},
};
expect(resolveDefaultFeishuAccountSelection(explicitDefaultCfg as never)).toEqual({
accountId: "router-d",
source: "explicit-default",
});
const mappedDefaultCfg = {
channels: {
feishu: {
accounts: {
default: { appId: "cli_default", appSecret: "secret_default" }, // pragma: allowlist secret
},
},
},
};
expect(resolveDefaultFeishuAccountSelection(mappedDefaultCfg as never)).toEqual({
accountId: "default",
source: "mapped-default",
});
});
});
describe("resolveFeishuCredentials", () => {
it("throws unresolved SecretRef errors by default for unsupported secret sources", () => {
expect(() =>
resolveFeishuCredentials(
asConfig({
appId: "cli_123",
appSecret: { source: "file", provider: "default", id: "path/to/secret" } as never,
}),
),
).toThrow(/unresolved SecretRef/i);
});
it("returns null (without throwing) when unresolved SecretRef is allowed", () => {
const creds = resolveFeishuCredentials(
asConfig({
appId: "cli_123",
appSecret: { source: "file", provider: "default", id: "path/to/secret" } as never,
}),
{ allowUnresolvedSecretRef: true },
);
expect(creds).toBeNull();
});
it("supports explicit inspect mode for unresolved SecretRefs", () => {
const creds = resolveFeishuCredentials(
asConfig({
appId: "cli_123",
appSecret: { source: "file", provider: "default", id: "path/to/secret" } as never,
}),
{ mode: "inspect" },
);
expect(creds).toBeNull();
});
it("throws unresolved SecretRef error when env SecretRef points to missing env var", () => {
const key = "FEISHU_APP_SECRET_MISSING_TEST";
withEnvVar(key, undefined, () => {
expectUnresolvedEnvSecretRefError(key);
});
});
it("resolves env SecretRef objects when unresolved refs are allowed", () => {
const key = "FEISHU_APP_SECRET_TEST";
const prev = process.env[key];
process.env[key] = " secret_from_env ";
try {
const creds = resolveFeishuCredentials(
asConfig({
appId: "cli_123",
appSecret: { source: "env", provider: "default", id: key } as never,
}),
{ allowUnresolvedSecretRef: true },
);
expect(creds).toEqual({
appId: "cli_123",
appSecret: "secret_from_env", // pragma: allowlist secret
encryptKey: undefined,
verificationToken: undefined,
domain: "feishu",
});
} finally {
if (prev === undefined) {
delete process.env[key];
} else {
process.env[key] = prev;
}
}
});
it("resolves env SecretRef with custom provider alias when unresolved refs are allowed", () => {
const key = "FEISHU_APP_SECRET_CUSTOM_PROVIDER_TEST";
const prev = process.env[key];
process.env[key] = " secret_from_env_alias ";
try {
const creds = resolveFeishuCredentials(
asConfig({
appId: "cli_123",
appSecret: { source: "env", provider: "corp-env", id: key } as never,
}),
{ allowUnresolvedSecretRef: true },
);
expect(creds?.appSecret).toBe("secret_from_env_alias");
} finally {
if (prev === undefined) {
delete process.env[key];
} else {
process.env[key] = prev;
}
}
});
it("preserves unresolved SecretRef diagnostics for env refs in default mode", () => {
const key = "FEISHU_APP_SECRET_POLICY_TEST";
withEnvVar(key, "secret_from_env", () => {
expectUnresolvedEnvSecretRefError(key);
});
});
it("trims and returns credentials when values are valid strings", () => {
const creds = resolveFeishuCredentials(
asConfig({
appId: " cli_123 ",
appSecret: " secret_456 ",
encryptKey: " enc ",
verificationToken: " vt ",
}),
);
expect(creds).toEqual({
appId: "cli_123",
appSecret: "secret_456", // pragma: allowlist secret
encryptKey: "enc",
verificationToken: "vt",
domain: "feishu",
});
});
it("does not resolve encryptKey SecretRefs outside webhook mode", () => {
const creds = resolveFeishuCredentials(
asConfig({
connectionMode: "websocket",
appId: "cli_123",
appSecret: "secret_456",
encryptKey: { source: "file", provider: "default", id: "path/to/secret" } as never,
}),
);
expect(creds).toEqual({
appId: "cli_123",
appSecret: "secret_456", // pragma: allowlist secret
encryptKey: undefined,
verificationToken: undefined,
domain: "feishu",
});
});
it("keeps required credentials when optional event SecretRefs are unresolved in inspect mode", () => {
const creds = inspectFeishuCredentials(
asConfig({
appId: "cli_123",
appSecret: "secret_456",
verificationToken: { source: "file", provider: "default", id: "path/to/token" } as never,
}),
);
expect(creds).toEqual({
appId: "cli_123",
appSecret: "secret_456", // pragma: allowlist secret
encryptKey: undefined,
verificationToken: undefined,
domain: "feishu",
});
});
});
describe("resolveFeishuAccount", () => {
it("uses top-level credentials with configured default account id even without account map entry", () => {
const cfg = {
channels: {
feishu: {
defaultAccount: "router-d",
appId: "top_level_app",
appSecret: "top_level_secret", // pragma: allowlist secret
accounts: {
default: { appId: "cli_default", appSecret: "secret_default" }, // pragma: allowlist secret
},
},
},
};
const account = resolveFeishuAccount({ cfg: cfg as never, accountId: undefined });
expectExplicitDefaultAccountSelection(account, "top_level_app");
});
it("uses configured default account when accountId is omitted", () => {
const cfg = {
channels: {
feishu: {
defaultAccount: "router-d",
accounts: {
default: { enabled: true },
"router-d": { appId: "cli_router", appSecret: "secret_router", enabled: true }, // pragma: allowlist secret
},
},
},
};
const account = resolveFeishuAccount({ cfg: cfg as never, accountId: undefined });
expectExplicitDefaultAccountSelection(account, "cli_router");
});
it("keeps explicit accountId selection", () => {
const cfg = {
channels: {
feishu: {
defaultAccount: "router-d",
accounts: makeDefaultAndRouterAccounts(),
},
},
};
const account = resolveFeishuAccount({ cfg: cfg as never, accountId: "default" });
expect(account.accountId).toBe("default");
expect(account.selectionSource).toBe("explicit");
expect(account.appId).toBe("cli_default");
});
it("treats unresolved SecretRef as not configured in account resolution", () => {
const account = resolveFeishuAccount({
cfg: {
channels: {
feishu: {
accounts: {
main: {
appId: "cli_123",
appSecret: { source: "file", provider: "default", id: "path/to/secret" },
} as never,
},
},
},
} as never,
accountId: "main",
});
expect(account.configured).toBe(false);
expect(account.appSecret).toBeUndefined();
});
it("keeps account configured when optional event SecretRefs are unresolved in inspect mode", () => {
const account = resolveFeishuAccount({
cfg: {
channels: {
feishu: {
accounts: {
main: {
appId: "cli_123",
appSecret: "secret_456",
verificationToken: {
source: "file",
provider: "default",
id: "path/to/token",
},
} as never,
},
},
},
} as never,
accountId: "main",
});
expect(account.configured).toBe(true);
expect(account.appSecret).toBe("secret_456");
expect(account.verificationToken).toBeUndefined();
});
it("throws typed SecretRef errors in runtime account resolution", () => {
let caught: unknown;
try {
resolveFeishuRuntimeAccount({
cfg: {
channels: {
feishu: {
accounts: {
main: {
appId: "cli_123",
appSecret: { source: "file", provider: "default", id: "path/to/secret" },
} as never,
},
},
},
} as never,
accountId: "main",
});
} catch (error) {
caught = error;
}
expect(caught).toBeInstanceOf(FeishuSecretRefUnavailableError);
expect((caught as Error).message).toMatch(/channels\.feishu\.appSecret: unresolved SecretRef/i);
});
it("does not throw when account name is non-string", () => {
expect(() =>
resolveFeishuAccount({
cfg: {
channels: {
feishu: {
accounts: {
main: {
name: { bad: true },
appId: "cli_123",
appSecret: "secret_456", // pragma: allowlist secret
} as never,
},
},
},
} as never,
accountId: "main",
}),
).not.toThrow();
});
});

View file

@ -0,0 +1,326 @@
import {
DEFAULT_ACCOUNT_ID,
type OpenClawConfig as ClawdbotConfig,
createAccountListHelpers,
normalizeAccountId,
normalizeOptionalAccountId,
resolveMergedAccountConfig,
} from "openclaw/plugin-sdk/account-resolution";
import { coerceSecretRef } from "openclaw/plugin-sdk/provider-auth";
import { normalizeString } from "./comment-shared.js";
import type {
FeishuConfig,
FeishuAccountConfig,
FeishuDefaultAccountSelectionSource,
FeishuDomain,
ResolvedFeishuAccount,
} from "./types.js";
const { listAccountIds: listFeishuAccountIds, resolveDefaultAccountId } = createAccountListHelpers(
"feishu",
{
allowUnlistedDefaultAccount: true,
},
);
export { listFeishuAccountIds };
type FeishuCredentialResolutionMode = "inspect" | "strict";
type FeishuResolvedSecretRef = NonNullable<ReturnType<typeof coerceSecretRef>>;
function formatSecretRefLabel(ref: FeishuResolvedSecretRef): string {
return `${ref.source}:${ref.provider}:${ref.id}`;
}
export class FeishuSecretRefUnavailableError extends Error {
path: string;
constructor(path: string, ref: FeishuResolvedSecretRef) {
super(
`${path}: unresolved SecretRef "${formatSecretRefLabel(ref)}". ` +
"Resolve this command against an active gateway runtime snapshot before reading it.",
);
this.name = "FeishuSecretRefUnavailableError";
this.path = path;
}
}
export function isFeishuSecretRefUnavailableError(
error: unknown,
): error is FeishuSecretRefUnavailableError {
return error instanceof FeishuSecretRefUnavailableError;
}
function resolveFeishuSecretLike(params: {
value: unknown;
path: string;
mode: FeishuCredentialResolutionMode;
allowEnvSecretRefRead?: boolean;
}): string | undefined {
const asString = normalizeString(params.value);
if (asString) {
return asString;
}
const ref = coerceSecretRef(params.value);
if (!ref) {
return undefined;
}
if (params.mode === "inspect") {
if (params.allowEnvSecretRefRead && ref.source === "env") {
const envValue = normalizeString(process.env[ref.id]);
if (envValue) {
return envValue;
}
}
return undefined;
}
throw new FeishuSecretRefUnavailableError(params.path, ref);
}
function resolveFeishuBaseCredentials(
cfg: FeishuConfig | undefined,
mode: FeishuCredentialResolutionMode,
): {
appId: string;
appSecret: string;
domain: FeishuDomain;
} | null {
const appId = resolveFeishuSecretLike({
value: cfg?.appId,
path: "channels.feishu.appId",
mode,
allowEnvSecretRefRead: true,
});
const appSecret = resolveFeishuSecretLike({
value: cfg?.appSecret,
path: "channels.feishu.appSecret",
mode,
allowEnvSecretRefRead: true,
});
if (!appId || !appSecret) {
return null;
}
return {
appId,
appSecret,
domain: cfg?.domain ?? "feishu",
};
}
function resolveFeishuEventSecrets(
cfg: FeishuConfig | undefined,
mode: FeishuCredentialResolutionMode,
): {
encryptKey?: string;
verificationToken?: string;
} {
return {
encryptKey:
(cfg?.connectionMode ?? "websocket") === "webhook"
? resolveFeishuSecretLike({
value: cfg?.encryptKey,
path: "channels.feishu.encryptKey",
mode,
allowEnvSecretRefRead: true,
})
: normalizeString(cfg?.encryptKey),
verificationToken: resolveFeishuSecretLike({
value: cfg?.verificationToken,
path: "channels.feishu.verificationToken",
mode,
allowEnvSecretRefRead: true,
}),
};
}
/**
* Resolve the default account selection and its source.
*/
export function resolveDefaultFeishuAccountSelection(cfg: ClawdbotConfig): {
accountId: string;
source: FeishuDefaultAccountSelectionSource;
} {
const preferred = normalizeOptionalAccountId(
(cfg.channels?.feishu as FeishuConfig | undefined)?.defaultAccount,
);
if (preferred) {
return {
accountId: preferred,
source: "explicit-default",
};
}
const ids = listFeishuAccountIds(cfg);
if (ids.includes(DEFAULT_ACCOUNT_ID)) {
return {
accountId: DEFAULT_ACCOUNT_ID,
source: "mapped-default",
};
}
return {
accountId: ids[0] ?? DEFAULT_ACCOUNT_ID,
source: "fallback",
};
}
/**
* Resolve the default account ID.
*/
export function resolveDefaultFeishuAccountId(cfg: ClawdbotConfig): string {
return resolveDefaultAccountId(cfg);
}
/**
* Merge top-level config with account-specific config.
* Account-specific fields override top-level fields.
*/
function mergeFeishuAccountConfig(cfg: ClawdbotConfig, accountId: string): FeishuConfig {
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
return resolveMergedAccountConfig<FeishuConfig>({
channelConfig: feishuCfg,
accounts: feishuCfg?.accounts as Record<string, Partial<FeishuConfig>> | undefined,
accountId,
omitKeys: ["defaultAccount"],
});
}
/**
* Resolve Feishu credentials from a config.
*/
export function resolveFeishuCredentials(cfg?: FeishuConfig): {
appId: string;
appSecret: string;
encryptKey?: string;
verificationToken?: string;
domain: FeishuDomain;
} | null;
export function resolveFeishuCredentials(
cfg: FeishuConfig | undefined,
options: {
mode?: FeishuCredentialResolutionMode;
allowUnresolvedSecretRef?: boolean;
},
): {
appId: string;
appSecret: string;
encryptKey?: string;
verificationToken?: string;
domain: FeishuDomain;
} | null;
export function resolveFeishuCredentials(
cfg?: FeishuConfig,
options?: {
mode?: FeishuCredentialResolutionMode;
allowUnresolvedSecretRef?: boolean;
},
): {
appId: string;
appSecret: string;
encryptKey?: string;
verificationToken?: string;
domain: FeishuDomain;
} | null {
const mode = options?.mode ?? (options?.allowUnresolvedSecretRef ? "inspect" : "strict");
const base = resolveFeishuBaseCredentials(cfg, mode);
if (!base) {
return null;
}
const eventSecrets = resolveFeishuEventSecrets(cfg, mode);
return {
...base,
...eventSecrets,
};
}
export function inspectFeishuCredentials(cfg?: FeishuConfig) {
return resolveFeishuCredentials(cfg, { mode: "inspect" });
}
function buildResolvedFeishuAccount(params: {
cfg: ClawdbotConfig;
accountId?: string | null;
baseMode: FeishuCredentialResolutionMode;
eventSecretMode: FeishuCredentialResolutionMode;
}): ResolvedFeishuAccount {
const hasExplicitAccountId =
typeof params.accountId === "string" && params.accountId.trim() !== "";
const defaultSelection = hasExplicitAccountId
? null
: resolveDefaultFeishuAccountSelection(params.cfg);
const accountId = hasExplicitAccountId
? normalizeAccountId(params.accountId)
: (defaultSelection?.accountId ?? DEFAULT_ACCOUNT_ID);
const selectionSource = hasExplicitAccountId
? "explicit"
: (defaultSelection?.source ?? "fallback");
const feishuCfg = params.cfg.channels?.feishu as FeishuConfig | undefined;
const baseEnabled = feishuCfg?.enabled !== false;
const merged = mergeFeishuAccountConfig(params.cfg, accountId);
const accountEnabled = merged.enabled !== false;
const enabled = baseEnabled && accountEnabled;
const baseCreds = resolveFeishuBaseCredentials(merged, params.baseMode);
const eventSecrets = resolveFeishuEventSecrets(merged, params.eventSecretMode);
const accountName = (merged as FeishuAccountConfig).name;
return {
accountId,
selectionSource,
enabled,
configured: Boolean(baseCreds),
name: typeof accountName === "string" ? accountName.trim() || undefined : undefined,
appId: baseCreds?.appId,
appSecret: baseCreds?.appSecret,
encryptKey: eventSecrets.encryptKey,
verificationToken: eventSecrets.verificationToken,
domain: baseCreds?.domain ?? "feishu",
config: merged,
};
}
/**
* Resolve a read-only Feishu account snapshot for CLI/config surfaces.
* Unresolved SecretRefs are treated as unavailable instead of throwing.
*/
export function resolveFeishuAccount(params: {
cfg: ClawdbotConfig;
accountId?: string | null;
}): ResolvedFeishuAccount {
return buildResolvedFeishuAccount({
...params,
baseMode: "inspect",
eventSecretMode: "inspect",
});
}
/**
* Resolve a runtime Feishu account.
* Required app credentials stay strict; event-only secrets can be required by callers.
*/
export function resolveFeishuRuntimeAccount(
params: {
cfg: ClawdbotConfig;
accountId?: string | null;
},
options?: { requireEventSecrets?: boolean },
): ResolvedFeishuAccount {
return buildResolvedFeishuAccount({
...params,
baseMode: "strict",
eventSecretMode: options?.requireEventSecrets ? "strict" : "inspect",
});
}
/**
* List all enabled and configured accounts.
*/
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,332 @@
/**
* Feishu app registration via OAuth device-code flow.
*
* Migrated from feishu-plugin-cli's `feishu-auth.ts` and `install-prompts.ts`.
* Replaces axios with native fetch, removes inquirer/ora/chalk in favor of
* the openclaw WizardPrompter surface.
*/
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import type { FeishuDomain } from "./types.js";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const FEISHU_ACCOUNTS_URL = "https://accounts.feishu.cn";
const LARK_ACCOUNTS_URL = "https://accounts.larksuite.com";
const REGISTRATION_PATH = "/oauth/v1/app/registration";
const REQUEST_TIMEOUT_MS = 10_000;
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface AppRegistrationResult {
appId: string;
appSecret: string;
domain: FeishuDomain;
openId?: string;
}
interface InitResponse {
nonce: string;
supported_auth_methods: string[];
}
export interface BeginResult {
deviceCode: string;
qrUrl: string;
userCode: string;
interval: number;
expireIn: number;
}
interface RawBeginResponse {
device_code: string;
verification_uri: string;
user_code: string;
verification_uri_complete: string;
interval: number;
expire_in: number;
}
interface PollResponse {
client_id?: string;
client_secret?: string;
user_info?: {
open_id?: string;
tenant_brand?: "feishu" | "lark";
};
error?: string;
error_description?: string;
}
export type PollOutcome =
| { status: "success"; result: AppRegistrationResult }
| { status: "access_denied" }
| { status: "expired" }
| { status: "timeout" }
| { status: "error"; message: string };
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function accountsBaseUrl(domain: FeishuDomain): string {
return domain === "lark" ? LARK_ACCOUNTS_URL : FEISHU_ACCOUNTS_URL;
}
async function postRegistration<T>(baseUrl: string, body: Record<string, string>): Promise<T> {
return await fetchFeishuJson<T>({
url: `${baseUrl}${REGISTRATION_PATH}`,
init: {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams(body).toString(),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
},
auditContext: "feishu.app-registration.post",
});
}
async function fetchFeishuJson<T>(params: {
url: string;
init: RequestInit;
auditContext: string;
}): Promise<T> {
const { response, release } = await fetchWithSsrFGuard({
url: params.url,
init: params.init,
policy: { allowedHostnames: [new URL(params.url).hostname] },
auditContext: params.auditContext,
});
try {
// Registration poll returns 4xx for pending/error states with a JSON body.
return (await response.json()) as T;
} finally {
await release();
}
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Step 1: Initialize registration and verify the environment supports
* `client_secret` auth.
*
* @throws If the environment does not support `client_secret`.
*/
export async function initAppRegistration(domain: FeishuDomain = "feishu"): Promise<void> {
const baseUrl = accountsBaseUrl(domain);
const res = await postRegistration<InitResponse>(baseUrl, { action: "init" });
if (!res.supported_auth_methods?.includes("client_secret")) {
throw new Error("Current environment does not support client_secret auth method");
}
}
/**
* Step 2: Begin the device-code flow. Returns a device code and a QR URL
* that the user should scan with Feishu/Lark mobile app.
*/
export async function beginAppRegistration(domain: FeishuDomain = "feishu"): Promise<BeginResult> {
const baseUrl = accountsBaseUrl(domain);
const res = await postRegistration<RawBeginResponse>(baseUrl, {
action: "begin",
archetype: "PersonalAgent",
auth_method: "client_secret",
request_user_info: "open_id",
});
const qrUrl = new URL(res.verification_uri_complete);
qrUrl.searchParams.set("from", "oc_onboard");
qrUrl.searchParams.set("tp", "ob_cli_app");
return {
deviceCode: res.device_code,
qrUrl: qrUrl.toString(),
userCode: res.user_code,
interval: res.interval || 5,
expireIn: res.expire_in || 600,
};
}
/**
* Step 3: Poll for authorization result until success, denial, expiry, or
* timeout. Automatically handles domain switching when `tenant_brand` is
* detected as "lark".
*/
export async function pollAppRegistration(params: {
deviceCode: string;
interval: number;
expireIn: number;
initialDomain?: FeishuDomain;
abortSignal?: AbortSignal;
/** Registration type parameter: "ob_user" for user mode, "ob_app" for bot mode. */
tp?: string;
}): Promise<PollOutcome> {
const { deviceCode, expireIn, initialDomain = "feishu", abortSignal, tp } = params;
let currentInterval = params.interval;
let domain: FeishuDomain = initialDomain;
let domainSwitched = false;
const deadline = Date.now() + expireIn * 1000;
while (Date.now() < deadline) {
if (abortSignal?.aborted) {
return { status: "timeout" };
}
const baseUrl = accountsBaseUrl(domain);
let pollRes: PollResponse;
try {
pollRes = await postRegistration<PollResponse>(baseUrl, {
action: "poll",
device_code: deviceCode,
...(tp ? { tp } : {}),
});
} catch {
// Transient network error — keep polling.
await sleep(currentInterval * 1000);
continue;
}
// Domain auto-detection: switch to lark if tenant_brand says so.
if (pollRes.user_info?.tenant_brand) {
const isLark = pollRes.user_info.tenant_brand === "lark";
if (!domainSwitched && isLark) {
domain = "lark";
domainSwitched = true;
// Retry poll immediately with the correct domain.
continue;
}
}
// Success.
if (pollRes.client_id && pollRes.client_secret) {
return {
status: "success",
result: {
appId: pollRes.client_id,
appSecret: pollRes.client_secret,
domain,
openId: pollRes.user_info?.open_id,
},
};
}
// Error handling.
if (pollRes.error) {
if (pollRes.error === "authorization_pending") {
// Continue waiting.
} else if (pollRes.error === "slow_down") {
currentInterval += 5;
} else if (pollRes.error === "access_denied") {
return { status: "access_denied" };
} else if (pollRes.error === "expired_token") {
return { status: "expired" };
} else {
return {
status: "error",
message: `${pollRes.error}: ${pollRes.error_description ?? "unknown"}`,
};
}
}
await sleep(currentInterval * 1000);
}
return { status: "timeout" };
}
/**
* Print QR code directly to stdout.
*
* QR codes must be printed without any surrounding box/border decoration,
* otherwise the pattern is corrupted and cannot be scanned.
*/
export async function printQrCode(url: string): Promise<void> {
const mod = await import("qrcode-terminal");
const qrcode = mod.default ?? mod;
qrcode.generate(url, { small: true });
}
/**
* Fetch the app owner's open_id using the application.v6.application.get API.
*
* Used during setup to auto-populate security policy allowlists.
* Returns undefined on any failure (fail-open).
*/
export async function getAppOwnerOpenId(params: {
appId: string;
appSecret: string;
domain?: FeishuDomain;
}): Promise<string | undefined> {
const baseUrl =
params.domain === "lark" ? "https://open.larksuite.com" : "https://open.feishu.cn";
try {
// First, get a tenant_access_token.
const tokenData = await fetchFeishuJson<{
code?: number;
tenant_access_token?: string;
}>({
url: `${baseUrl}/open-apis/auth/v3/tenant_access_token/internal`,
init: {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ app_id: params.appId, app_secret: params.appSecret }),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
},
auditContext: "feishu.app-registration.owner-token",
});
if (!tokenData.tenant_access_token) {
return undefined;
}
// Query app info for the owner's open_id.
const appData = await fetchFeishuJson<{
code?: number;
data?: {
app?: {
owner?: { owner_id?: string; owner_type?: number; type?: number };
creator_id?: string;
};
};
}>({
url: `${baseUrl}/open-apis/application/v6/applications/${params.appId}?user_id_type=open_id`,
init: {
method: "GET",
headers: {
Authorization: `Bearer ${tokenData.tenant_access_token}`,
"Content-Type": "application/json",
},
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
},
auditContext: "feishu.app-registration.owner-app",
});
if (appData.code !== 0) {
return undefined;
}
const app = appData.data?.app;
const owner = app?.owner;
const ownerType = owner?.owner_type ?? owner?.type;
// owner_type=2 means enterprise member; use owner_id. Otherwise fallback to creator_id.
return ownerType === 2 && owner?.owner_id
? owner.owner_id
: (app?.creator_id ?? owner?.owner_id);
} catch {
return undefined;
}
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

View file

@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { feishuApprovalAuth } from "./approval-auth.js";
describe("feishuApprovalAuth", () => {
it("authorizes open_id approvers and ignores user_id-only allowlists", () => {
expect(
feishuApprovalAuth.authorizeActorAction({
cfg: { channels: { feishu: { allowFrom: ["ou_owner"] } } },
senderId: "ou_owner",
action: "approve",
approvalKind: "exec",
}),
).toEqual({ authorized: true });
expect(
feishuApprovalAuth.authorizeActorAction({
cfg: { channels: { feishu: { allowFrom: ["user_123"] } } },
senderId: "ou_attacker",
action: "approve",
approvalKind: "exec",
}),
).toEqual({ authorized: true });
});
});

View file

@ -0,0 +1,25 @@
import {
createResolvedApproverActionAuthAdapter,
resolveApprovalApprovers,
} from "openclaw/plugin-sdk/approval-auth-runtime";
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/text-runtime";
import { resolveFeishuAccount } from "./accounts.js";
import { normalizeFeishuTarget } from "./targets.js";
function normalizeFeishuApproverId(value: string | number): string | undefined {
const normalized = normalizeFeishuTarget(String(value));
const trimmed = normalizeOptionalLowercaseString(normalized);
return trimmed?.startsWith("ou_") ? trimmed : undefined;
}
export const feishuApprovalAuth = createResolvedApproverActionAuthAdapter({
channelLabel: "Feishu",
resolveApprovers: ({ cfg, accountId }) => {
const account = resolveFeishuAccount({ cfg, accountId }).config;
return resolveApprovalApprovers({
allowFrom: account.allowFrom,
normalizeApprover: normalizeFeishuApproverId,
});
},
normalizeSenderId: (value) => normalizeFeishuApproverId(value),
});

View file

@ -0,0 +1,27 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { waitForAbortableDelay } from "./async.js";
afterEach(() => {
vi.useRealTimers();
});
describe("waitForAbortableDelay", () => {
it("resolves false immediately when aborted during backoff", async () => {
vi.useFakeTimers();
const abortController = new AbortController();
const delay = waitForAbortableDelay(60_000, abortController.signal);
abortController.abort();
await expect(delay).resolves.toBe(false);
});
it("resolves true after the full delay when not aborted", async () => {
vi.useFakeTimers();
const delay = waitForAbortableDelay(500);
await vi.advanceTimersByTimeAsync(500);
await expect(delay).resolves.toBe(true);
});
});

View file

@ -0,0 +1,86 @@
const RACE_TIMEOUT = Symbol("race-timeout");
const RACE_ABORT = Symbol("race-abort");
export type RaceWithTimeoutAndAbortResult<T> =
| { status: "resolved"; value: T }
| { status: "timeout" }
| { status: "aborted" };
export async function raceWithTimeoutAndAbort<T>(
promise: Promise<T>,
options: {
timeoutMs?: number;
abortSignal?: AbortSignal;
} = {},
): Promise<RaceWithTimeoutAndAbortResult<T>> {
if (options.abortSignal?.aborted) {
return { status: "aborted" };
}
if (options.timeoutMs === undefined && !options.abortSignal) {
return { status: "resolved", value: await promise };
}
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
let abortHandler: (() => void) | undefined;
const contenders: Array<Promise<T | typeof RACE_TIMEOUT | typeof RACE_ABORT>> = [promise];
if (options.timeoutMs !== undefined) {
contenders.push(
new Promise((resolve) => {
timeoutHandle = setTimeout(() => resolve(RACE_TIMEOUT), options.timeoutMs);
}),
);
}
if (options.abortSignal) {
contenders.push(
new Promise((resolve) => {
abortHandler = () => resolve(RACE_ABORT);
options.abortSignal?.addEventListener("abort", abortHandler, { once: true });
}),
);
}
try {
const result = await Promise.race(contenders);
if (result === RACE_TIMEOUT) {
return { status: "timeout" };
}
if (result === RACE_ABORT) {
return { status: "aborted" };
}
return { status: "resolved", value: result };
} finally {
if (timeoutHandle) {
clearTimeout(timeoutHandle);
}
if (abortHandler) {
options.abortSignal?.removeEventListener("abort", abortHandler);
}
}
}
export function waitForAbortableDelay(
delayMs: number,
abortSignal?: AbortSignal,
): Promise<boolean> {
if (abortSignal?.aborted) {
return Promise.resolve(false);
}
return new Promise((resolve) => {
const handleAbort = () => {
clearTimeout(timer);
resolve(false);
};
const timer = setTimeout(() => {
abortSignal?.removeEventListener("abort", handleAbort);
resolve(true);
}, delayMs);
timer.unref?.();
abortSignal?.addEventListener("abort", handleAbort, { once: true });
});
}

View file

@ -0,0 +1,737 @@
import type * as Lark from "@larksuiteoapi/node-sdk";
import { Type } from "@sinclair/typebox";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import type { OpenClawPluginApi } from "../runtime-api.js";
import { listEnabledFeishuAccounts } from "./accounts.js";
import { createFeishuToolClient } from "./tool-account.js";
// ============ Helpers ============
function json(data: unknown) {
return {
content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }],
details: data,
};
}
type LarkResponse<T = unknown> = { code?: number; msg?: string; data?: T };
type BitableRecordCreatePayload = NonNullable<
Parameters<Lark.Client["bitable"]["appTableRecord"]["create"]>[0]
>;
type BitableRecordUpdatePayload = NonNullable<
Parameters<Lark.Client["bitable"]["appTableRecord"]["update"]>[0]
>;
type BitableRecordFields = NonNullable<NonNullable<BitableRecordCreatePayload["data"]>["fields"]>;
type BitableRecordUpdateFields = NonNullable<
NonNullable<BitableRecordUpdatePayload["data"]>["fields"]
>;
export class LarkApiError extends Error {
readonly code: number;
readonly api: string;
readonly context?: Record<string, unknown>;
constructor(code: number, message: string, api: string, context?: Record<string, unknown>) {
super(`[${api}] code=${code} message=${message}`);
this.name = "LarkApiError";
this.code = code;
this.api = api;
this.context = context;
}
}
function ensureLarkSuccess<T>(
res: LarkResponse<T>,
api: string,
context?: Record<string, unknown>,
): asserts res is LarkResponse<T> & { code: 0 } {
if (res.code !== 0) {
throw new LarkApiError(res.code ?? -1, res.msg ?? "unknown error", api, context);
}
}
/** 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: Lark.Client, nodeToken: string): Promise<string> {
const res = await client.wiki.space.getNode({
params: { token: nodeToken },
});
ensureLarkSuccess(res, "wiki.space.getNode", { nodeToken });
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: Lark.Client, 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 },
});
ensureLarkSuccess(res, "bitable.app.get", { appToken });
// 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: Lark.Client, appToken: string, tableId: string) {
const res = await client.bitable.appTableField.list({
path: { app_token: appToken, table_id: tableId },
});
ensureLarkSuccess(res, "bitable.appTableField.list", { appToken, tableId });
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: Lark.Client,
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 }),
},
});
ensureLarkSuccess(res, "bitable.appTableRecord.list", { appToken, tableId, pageSize });
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: Lark.Client, appToken: string, tableId: string, recordId: string) {
const res = await client.bitable.appTableRecord.get({
path: { app_token: appToken, table_id: tableId, record_id: recordId },
});
ensureLarkSuccess(res, "bitable.appTableRecord.get", { appToken, tableId, recordId });
return {
record: res.data?.record,
};
}
async function createRecord(
client: Lark.Client,
appToken: string,
tableId: string,
fields: BitableRecordFields,
) {
const res = await client.bitable.appTableRecord.create({
path: { app_token: appToken, table_id: tableId },
data: { fields },
});
ensureLarkSuccess(res, "bitable.appTableRecord.create", { appToken, tableId });
return {
record: res.data?.record,
};
}
/** Logger interface for cleanup operations */
type CleanupLogger = {
debug: (msg: string) => void;
warn: (msg: string) => void;
};
/** Default field types created for new Bitable tables (to be cleaned up) */
const DEFAULT_CLEANUP_FIELD_TYPES = new Set([3, 5, 17]); // SingleSelect, DateTime, Attachment
/** Clean up default placeholder rows and fields in a newly created Bitable table */
async function cleanupNewBitable(
client: Lark.Client,
appToken: string,
tableId: string,
tableName: string,
logger: CleanupLogger,
): Promise<{ cleanedRows: number; cleanedFields: number }> {
let cleanedRows = 0;
let cleanedFields = 0;
// Step 1: Clean up default fields
const fieldsRes = await client.bitable.appTableField.list({
path: { app_token: appToken, table_id: tableId },
});
if (fieldsRes.code === 0 && fieldsRes.data?.items) {
// Step 1a: Rename primary field to the table name (works for both Feishu and Lark)
const primaryField = fieldsRes.data.items.find((f) => f.is_primary);
if (primaryField?.field_id) {
try {
const newFieldName = tableName.length <= 20 ? tableName : "Name";
await client.bitable.appTableField.update({
path: {
app_token: appToken,
table_id: tableId,
field_id: primaryField.field_id,
},
data: {
field_name: newFieldName,
type: 1,
},
});
cleanedFields++;
} catch (err) {
logger.debug(`Failed to rename primary field: ${String(err)}`);
}
}
// Step 1b: Delete default placeholder fields by type (works for both Feishu and Lark)
const defaultFieldsToDelete = fieldsRes.data.items.filter(
(f) => !f.is_primary && DEFAULT_CLEANUP_FIELD_TYPES.has(f.type ?? 0),
);
for (const field of defaultFieldsToDelete) {
if (field.field_id) {
try {
await client.bitable.appTableField.delete({
path: {
app_token: appToken,
table_id: tableId,
field_id: field.field_id,
},
});
cleanedFields++;
} catch (err) {
logger.debug(`Failed to delete default field ${field.field_name}: ${String(err)}`);
}
}
}
}
// Step 2: Delete empty placeholder rows (batch when possible)
const recordsRes = await client.bitable.appTableRecord.list({
path: { app_token: appToken, table_id: tableId },
params: { page_size: 100 },
});
if (recordsRes.code === 0 && recordsRes.data?.items) {
const emptyRecordIds = recordsRes.data.items
.filter((r) => !r.fields || Object.keys(r.fields).length === 0)
.map((r) => r.record_id)
.filter((id): id is string => Boolean(id));
if (emptyRecordIds.length > 0) {
try {
await client.bitable.appTableRecord.batchDelete({
path: { app_token: appToken, table_id: tableId },
data: { records: emptyRecordIds },
});
cleanedRows = emptyRecordIds.length;
} catch {
// Fallback: delete one by one if batch API is unavailable
for (const recordId of emptyRecordIds) {
try {
await client.bitable.appTableRecord.delete({
path: { app_token: appToken, table_id: tableId, record_id: recordId },
});
cleanedRows++;
} catch (err) {
logger.debug(`Failed to delete empty row ${recordId}: ${String(err)}`);
}
}
}
}
}
return { cleanedRows, cleanedFields };
}
async function createApp(
client: Lark.Client,
name: string,
folderToken?: string,
logger?: CleanupLogger,
) {
const res = await client.bitable.app.create({
data: {
name,
...(folderToken && { folder_token: folderToken }),
},
});
ensureLarkSuccess(res, "bitable.app.create", { name, folderToken });
const appToken = res.data?.app?.app_token;
if (!appToken) {
throw new Error("Failed to create Bitable: no app_token returned");
}
const log: CleanupLogger = logger ?? { debug: () => {}, warn: () => {} };
let tableId: string | undefined;
let cleanedRows = 0;
let cleanedFields = 0;
try {
const tablesRes = await client.bitable.appTable.list({
path: { app_token: appToken },
});
if (tablesRes.code === 0 && tablesRes.data?.items && tablesRes.data.items.length > 0) {
tableId = tablesRes.data.items[0].table_id ?? undefined;
if (tableId) {
const cleanup = await cleanupNewBitable(client, appToken, tableId, name, log);
cleanedRows = cleanup.cleanedRows;
cleanedFields = cleanup.cleanedFields;
}
}
} catch (err) {
log.debug(`Cleanup failed (non-critical): ${String(err)}`);
}
return {
app_token: appToken,
table_id: tableId,
name: res.data?.app?.name,
url: res.data?.app?.url,
cleaned_placeholder_rows: cleanedRows,
cleaned_default_fields: cleanedFields,
hint: tableId
? `Table created. Use app_token="${appToken}" and table_id="${tableId}" for other bitable tools.`
: "Table created. Use feishu_bitable_get_meta to get table_id and field details.",
};
}
async function createField(
client: Lark.Client,
appToken: string,
tableId: string,
fieldName: string,
fieldType: number,
property?: Record<string, unknown>,
) {
const res = await client.bitable.appTableField.create({
path: { app_token: appToken, table_id: tableId },
data: {
field_name: fieldName,
type: fieldType,
...(property && { property }),
},
});
ensureLarkSuccess(res, "bitable.appTableField.create", {
appToken,
tableId,
fieldName,
fieldType,
});
return {
field_id: res.data?.field?.field_id,
field_name: res.data?.field?.field_name,
type: res.data?.field?.type,
type_name: FIELD_TYPE_NAMES[res.data?.field?.type ?? 0] || `type_${res.data?.field?.type}`,
};
}
async function updateRecord(
client: Lark.Client,
appToken: string,
tableId: string,
recordId: string,
fields: NonNullable<NonNullable<BitableRecordUpdatePayload["data"]>["fields"]>,
) {
const res = await client.bitable.appTableRecord.update({
path: { app_token: appToken, table_id: tableId, record_id: recordId },
data: { fields },
});
ensureLarkSuccess(res, "bitable.appTableRecord.update", { appToken, tableId, recordId });
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 CreateAppSchema = Type.Object({
name: Type.String({
description: "Name for the new Bitable application",
}),
folder_token: Type.Optional(
Type.String({
description: "Optional folder token to place the Bitable in a specific folder",
}),
),
});
const CreateFieldSchema = Type.Object({
app_token: Type.String({
description:
"Bitable app token (use feishu_bitable_get_meta to get from URL, or feishu_bitable_create_app to create new)",
}),
table_id: Type.String({ description: "Table ID (from URL: ?table=YYY)" }),
field_name: Type.String({ description: "Name for the new field" }),
field_type: Type.Number({
description:
"Field type ID: 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",
minimum: 1,
}),
property: Type.Optional(
Type.Record(Type.String(), Type.Any(), {
description: "Field-specific properties (e.g., options for SingleSelect, format for Number)",
}),
),
});
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) {
if (!api.config) {
api.logger.debug?.("feishu_bitable: No config available, skipping bitable tools");
return;
}
const accounts = listEnabledFeishuAccounts(api.config);
if (accounts.length === 0) {
api.logger.debug?.("feishu_bitable: No Feishu accounts configured, skipping bitable tools");
return;
}
type AccountAwareParams = { accountId?: string };
const getClient = (params: AccountAwareParams | undefined, defaultAccountId?: string) =>
createFeishuToolClient({ api, executeParams: params, defaultAccountId });
const registerBitableTool = <
// oxlint-disable-next-line typescript/no-unnecessary-type-parameters -- Tool params bind each schema-specific executor to its registered tool.
TParams extends AccountAwareParams,
>(params: {
name: string;
label: string;
description: string;
parameters: unknown;
execute: (args: { params: TParams; defaultAccountId?: string }) => Promise<unknown>;
}) => {
api.registerTool(
(ctx) => ({
name: params.name,
label: params.label,
description: params.description,
parameters: params.parameters,
async execute(_toolCallId, rawParams) {
try {
return json(
await params.execute({
params: rawParams as TParams,
defaultAccountId: ctx.agentAccountId,
}),
);
} catch (err) {
return json({ error: formatErrorMessage(err) });
}
},
}),
{ name: params.name },
);
};
registerBitableTool<{ url: string; accountId?: string }>({
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({ params, defaultAccountId }) {
return getBitableMeta(getClient(params, defaultAccountId), params.url);
},
});
registerBitableTool<{ app_token: string; table_id: string; accountId?: string }>({
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({ params, defaultAccountId }) {
return listFields(getClient(params, defaultAccountId), params.app_token, params.table_id);
},
});
registerBitableTool<{
app_token: string;
table_id: string;
page_size?: number;
page_token?: string;
accountId?: string;
}>({
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({ params, defaultAccountId }) {
return listRecords(
getClient(params, defaultAccountId),
params.app_token,
params.table_id,
params.page_size,
params.page_token,
);
},
});
registerBitableTool<{
app_token: string;
table_id: string;
record_id: string;
accountId?: string;
}>({
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({ params, defaultAccountId }) {
return getRecord(
getClient(params, defaultAccountId),
params.app_token,
params.table_id,
params.record_id,
);
},
});
registerBitableTool<{
app_token: string;
table_id: string;
fields: BitableRecordFields;
accountId?: string;
}>({
name: "feishu_bitable_create_record",
label: "Feishu Bitable Create Record",
description: "Create a new record (row) in a Bitable table",
parameters: CreateRecordSchema,
async execute({ params, defaultAccountId }) {
return createRecord(
getClient(params, defaultAccountId),
params.app_token,
params.table_id,
params.fields,
);
},
});
registerBitableTool<{
app_token: string;
table_id: string;
record_id: string;
fields: BitableRecordUpdateFields;
accountId?: string;
}>({
name: "feishu_bitable_update_record",
label: "Feishu Bitable Update Record",
description: "Update an existing record (row) in a Bitable table",
parameters: UpdateRecordSchema,
async execute({ params, defaultAccountId }) {
return updateRecord(
getClient(params, defaultAccountId),
params.app_token,
params.table_id,
params.record_id,
params.fields,
);
},
});
registerBitableTool<{ name: string; folder_token?: string; accountId?: string }>({
name: "feishu_bitable_create_app",
label: "Feishu Bitable Create App",
description: "Create a new Bitable (multidimensional table) application",
parameters: CreateAppSchema,
async execute({ params, defaultAccountId }) {
return createApp(getClient(params, defaultAccountId), params.name, params.folder_token, {
debug: (msg) => api.logger.debug?.(msg),
warn: (msg) => api.logger.warn?.(msg),
});
},
});
registerBitableTool<{
app_token: string;
table_id: string;
field_name: string;
field_type: number;
property?: Record<string, unknown>;
accountId?: string;
}>({
name: "feishu_bitable_create_field",
label: "Feishu Bitable Create Field",
description: "Create a new field (column) in a Bitable table",
parameters: CreateFieldSchema,
async execute({ params, defaultAccountId }) {
return createField(
getClient(params, defaultAccountId),
params.app_token,
params.table_id,
params.field_name,
params.field_type,
params.property,
);
},
});
api.logger.debug?.("feishu_bitable: Registered bitable tools");
}

View file

@ -0,0 +1,455 @@
import type { ClawdbotConfig } from "../runtime-api.js";
import { buildFeishuConversationId } from "./conversation-id.js";
import { normalizeFeishuExternalKey } from "./external-keys.js";
import { downloadMessageResourceFeishu } from "./media.js";
import { parsePostContent } from "./post.js";
import { getFeishuRuntime } from "./runtime.js";
import type { FeishuMediaInfo } from "./types.js";
export type FeishuMention = {
key: string;
id: {
open_id?: string;
user_id?: string;
union_id?: string;
};
name: string;
tenant_key?: string;
};
type FeishuMessageLike = {
message: {
content: string;
message_type: string;
mentions?: FeishuMention[];
chat_id: string;
root_id?: string;
parent_id?: string;
thread_id?: string;
message_id: string;
};
sender: {
sender_id: {
open_id?: string;
user_id?: string;
};
};
};
export type GroupSessionScope = "group" | "group_sender" | "group_topic" | "group_topic_sender";
type FeishuLogger = {
(...args: unknown[]): void;
};
export type ResolvedFeishuGroupSession = {
peerId: string;
parentPeer: { kind: "group"; id: string } | null;
groupSessionScope: GroupSessionScope;
replyInThread: boolean;
threadReply: boolean;
};
export function resolveFeishuGroupSession(params: {
chatId: string;
senderOpenId: string;
messageId: string;
rootId?: string;
threadId?: string;
groupConfig?: {
groupSessionScope?: GroupSessionScope;
topicSessionMode?: "enabled" | "disabled";
replyInThread?: "enabled" | "disabled";
};
feishuCfg?: {
groupSessionScope?: GroupSessionScope;
topicSessionMode?: "enabled" | "disabled";
replyInThread?: "enabled" | "disabled";
};
}): ResolvedFeishuGroupSession {
const { chatId, senderOpenId, messageId, rootId, threadId, groupConfig, feishuCfg } = params;
const normalizedThreadId = threadId?.trim();
const normalizedRootId = rootId?.trim();
const threadReply = Boolean(normalizedThreadId || normalizedRootId);
const replyInThread =
(groupConfig?.replyInThread ?? feishuCfg?.replyInThread ?? "disabled") === "enabled" ||
threadReply;
const legacyTopicSessionMode =
groupConfig?.topicSessionMode ?? feishuCfg?.topicSessionMode ?? "disabled";
const groupSessionScope: GroupSessionScope =
groupConfig?.groupSessionScope ??
feishuCfg?.groupSessionScope ??
(legacyTopicSessionMode === "enabled" ? "group_topic" : "group");
const topicScope =
groupSessionScope === "group_topic" || groupSessionScope === "group_topic_sender"
? (normalizedRootId ?? normalizedThreadId ?? (replyInThread ? messageId : null))
: null;
let peerId = chatId;
switch (groupSessionScope) {
case "group_sender":
peerId = buildFeishuConversationId({ chatId, scope: "group_sender", senderOpenId });
break;
case "group_topic":
peerId = topicScope
? buildFeishuConversationId({ chatId, scope: "group_topic", topicId: topicScope })
: chatId;
break;
case "group_topic_sender":
peerId = topicScope
? buildFeishuConversationId({
chatId,
scope: "group_topic_sender",
topicId: topicScope,
senderOpenId,
})
: buildFeishuConversationId({ chatId, scope: "group_sender", senderOpenId });
break;
case "group":
default:
peerId = chatId;
break;
}
return {
peerId,
parentPeer:
topicScope &&
(groupSessionScope === "group_topic" || groupSessionScope === "group_topic_sender")
? { kind: "group", id: chatId }
: null,
groupSessionScope,
replyInThread,
threadReply,
};
}
export function parseMessageContent(content: string, messageType: string): string {
if (messageType === "post") {
return parsePostContent(content).textContent;
}
try {
const parsed = JSON.parse(content);
if (messageType === "text") {
return parsed.text || "";
}
if (messageType === "share_chat") {
if (parsed && typeof parsed === "object") {
const share = parsed as { body?: unknown; summary?: unknown; share_chat_id?: unknown };
if (typeof share.body === "string" && share.body.trim()) {
return share.body.trim();
}
if (typeof share.summary === "string" && share.summary.trim()) {
return share.summary.trim();
}
if (typeof share.share_chat_id === "string" && share.share_chat_id.trim()) {
return `[Forwarded message: ${share.share_chat_id.trim()}]`;
}
}
return "[Forwarded message]";
}
if (messageType === "merge_forward") {
return "[Merged and Forwarded Message - loading...]";
}
return content;
} catch {
return content;
}
}
function formatSubMessageContent(content: string, contentType: string): string {
try {
const parsed = JSON.parse(content);
switch (contentType) {
case "text":
return parsed.text || content;
case "post":
return parsePostContent(content).textContent;
case "image":
return "[Image]";
case "file":
return `[File: ${parsed.file_name || "unknown"}]`;
case "audio":
return "[Audio]";
case "video":
return "[Video]";
case "sticker":
return "[Sticker]";
case "merge_forward":
return "[Nested Merged Forward]";
default:
return `[${contentType}]`;
}
} catch {
return content;
}
}
export function parseMergeForwardContent(params: { content: string; log?: FeishuLogger }): string {
const { content, log } = params;
const maxMessages = 50;
log?.("feishu: parsing merge_forward sub-messages from API response");
let items: Array<{
message_id?: string;
msg_type?: string;
body?: { content?: string };
sender?: { id?: string };
upper_message_id?: string;
create_time?: string;
}>;
try {
items = JSON.parse(content);
} catch {
log?.("feishu: merge_forward items parse failed");
return "[Merged and Forwarded Message - parse error]";
}
if (!Array.isArray(items) || items.length === 0) {
return "[Merged and Forwarded Message - no sub-messages]";
}
const subMessages = items.filter((item) => item.upper_message_id);
if (subMessages.length === 0) {
return "[Merged and Forwarded Message - no sub-messages found]";
}
log?.(`feishu: merge_forward contains ${subMessages.length} sub-messages`);
subMessages.sort(
(a, b) => parseInt(a.create_time || "0", 10) - parseInt(b.create_time || "0", 10),
);
const lines = ["[Merged and Forwarded Messages]"];
for (const item of subMessages.slice(0, maxMessages)) {
lines.push(`- ${formatSubMessageContent(item.body?.content || "", item.msg_type || "text")}`);
}
if (subMessages.length > maxMessages) {
lines.push(`... and ${subMessages.length - maxMessages} more messages`);
}
return lines.join("\n");
}
export function checkBotMentioned(event: FeishuMessageLike, botOpenId?: string): boolean {
if (!botOpenId) {
return false;
}
if ((event.message.content ?? "").includes("@_all")) {
return true;
}
const mentions = event.message.mentions ?? [];
if (mentions.length > 0) {
return mentions.some((mention) => mention.id.open_id === botOpenId);
}
if (event.message.message_type === "post") {
return parsePostContent(event.message.content).mentionedOpenIds.some((id) => id === botOpenId);
}
return false;
}
export function normalizeMentions(
text: string,
mentions?: FeishuMention[],
botStripId?: string,
): string {
if (!mentions || mentions.length === 0) {
return text;
}
const escaped = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const escapeName = (value: string) => value.replace(/</g, "&lt;").replace(/>/g, "&gt;");
let result = text;
for (const mention of mentions) {
const mentionId = mention.id.open_id;
const replacement =
botStripId && mentionId === botStripId
? ""
: mentionId
? `<at user_id="${mentionId}">${escapeName(mention.name)}</at>`
: `@${mention.name}`;
result = result.replace(new RegExp(escaped(mention.key), "g"), () => replacement).trim();
}
return result;
}
export function normalizeFeishuCommandProbeBody(text: string): string {
if (!text) {
return "";
}
return text
.replace(/<at\b[^>]*>[^<]*<\/at>/giu, " ")
.replace(/(^|\s)@[^/\s]+(?=\s|$|\/)/gu, "$1")
.replace(/\s+/g, " ")
.trim();
}
export function parseMediaKeys(
content: string,
messageType: string,
): { imageKey?: string; fileKey?: string; fileName?: string } {
try {
const parsed = JSON.parse(content);
const imageKey = normalizeFeishuExternalKey(parsed.image_key);
const fileKey = normalizeFeishuExternalKey(parsed.file_key);
switch (messageType) {
case "image":
return { imageKey, fileName: parsed.file_name };
case "file":
case "audio":
case "sticker":
return { fileKey, fileName: parsed.file_name };
case "video":
case "media":
return { fileKey, imageKey, fileName: parsed.file_name };
default:
return {};
}
} catch {
return {};
}
}
export function toMessageResourceType(messageType: string): "image" | "file" {
return messageType === "image" ? "image" : "file";
}
function inferPlaceholder(messageType: string): string {
switch (messageType) {
case "image":
return "<media:image>";
case "file":
return "<media:document>";
case "audio":
return "<media:audio>";
case "video":
case "media":
return "<media:video>";
case "sticker":
return "<media:sticker>";
default:
return "<media:document>";
}
}
export async function resolveFeishuMediaList(params: {
cfg: ClawdbotConfig;
messageId: string;
messageType: string;
content: string;
maxBytes: number;
log?: (msg: string) => void;
accountId?: string;
}): Promise<FeishuMediaInfo[]> {
const { cfg, messageId, messageType, content, maxBytes, log, accountId } = params;
const mediaTypes = ["image", "file", "audio", "video", "media", "sticker", "post"];
if (!mediaTypes.includes(messageType)) {
return [];
}
const out: FeishuMediaInfo[] = [];
const core = getFeishuRuntime();
if (messageType === "post") {
const { imageKeys, mediaKeys } = parsePostContent(content);
if (imageKeys.length === 0 && mediaKeys.length === 0) {
return [];
}
if (imageKeys.length > 0) {
log?.(`feishu: post message contains ${imageKeys.length} embedded image(s)`);
}
if (mediaKeys.length > 0) {
log?.(`feishu: post message contains ${mediaKeys.length} embedded media file(s)`);
}
for (const imageKey of imageKeys) {
try {
const result = await downloadMessageResourceFeishu({
cfg,
messageId,
fileKey: imageKey,
type: "image",
accountId,
});
const contentType =
result.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)}`);
}
}
for (const media of mediaKeys) {
try {
const result = await downloadMessageResourceFeishu({
cfg,
messageId,
fileKey: media.fileKey,
type: "file",
accountId,
});
const contentType =
result.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:video>",
});
log?.(`feishu: downloaded embedded media ${media.fileKey}, saved to ${saved.path}`);
} catch (err) {
log?.(`feishu: failed to download embedded media ${media.fileKey}: ${String(err)}`);
}
}
return out;
}
const mediaKeys = parseMediaKeys(content, messageType);
if (!mediaKeys.imageKey && !mediaKeys.fileKey) {
return [];
}
try {
const fileKey = mediaKeys.fileKey || mediaKeys.imageKey;
if (!fileKey) {
return [];
}
const result = await downloadMessageResourceFeishu({
cfg,
messageId,
fileKey,
type: toMessageResourceType(messageType),
accountId,
});
const contentType =
result.contentType ?? (await core.media.detectMime({ buffer: result.buffer }));
const saved = await core.channel.media.saveMediaBuffer(
result.buffer,
contentType,
"inbound",
maxBytes,
result.fileName || mediaKeys.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;
}

View file

@ -0,0 +1,12 @@
export {
buildAgentMediaPayload,
resolveChannelContextVisibilityMode,
type ClawdbotConfig,
type RuntimeEnv,
} from "../runtime-api.js";
export {
evaluateSupplementalContextVisibility,
filterSupplementalContextItems,
normalizeAgentId,
} from "../runtime-api.js";
export { loadSessionStore, resolveSessionStoreEntry } from "../runtime-api.js";

View file

@ -0,0 +1,127 @@
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import { createFeishuClient } from "./client.js";
import type { ResolvedFeishuAccount } from "./types.js";
export type FeishuPermissionError = {
code: number;
message: string;
grantUrl?: string;
};
type SenderNameResult = {
name?: string;
permissionError?: FeishuPermissionError;
};
type FeishuContactUserGetResponse = Awaited<
ReturnType<ReturnType<typeof createFeishuClient>["contact"]["user"]["get"]>
>;
type FeishuLogger = {
(...args: unknown[]): void;
};
const IGNORED_PERMISSION_SCOPE_TOKENS = ["contact:contact.base:readonly"];
const FEISHU_SCOPE_CORRECTIONS: Record<string, string> = {
"contact:contact.base:readonly": "contact:user.base:readonly",
};
const SENDER_NAME_TTL_MS = 10 * 60 * 1000;
const senderNameCache = new Map<string, { name: string; expireAt: number }>();
function correctFeishuScopeInUrl(url: string): string {
let corrected = url;
for (const [wrong, right] of Object.entries(FEISHU_SCOPE_CORRECTIONS)) {
corrected = corrected.replaceAll(encodeURIComponent(wrong), encodeURIComponent(right));
corrected = corrected.replaceAll(wrong, right);
}
return corrected;
}
function shouldSuppressPermissionErrorNotice(permissionError: FeishuPermissionError): boolean {
const message = normalizeLowercaseStringOrEmpty(permissionError.message);
return IGNORED_PERMISSION_SCOPE_TOKENS.some((token) => message.includes(token));
}
function extractPermissionError(err: unknown): FeishuPermissionError | null {
if (!err || typeof err !== "object") {
return null;
}
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 };
if (feishuErr.code !== 99991672) {
return null;
}
const msg = feishuErr.msg ?? "";
const urlMatch = msg.match(/https:\/\/[^\s,]+\/app\/[^\s,]+/);
return {
code: feishuErr.code,
message: msg,
grantUrl: urlMatch?.[0] ? correctFeishuScopeInUrl(urlMatch[0]) : undefined,
};
}
function resolveSenderLookupIdType(senderId: string): "open_id" | "user_id" | "union_id" {
const trimmed = senderId.trim();
if (trimmed.startsWith("ou_")) {
return "open_id";
}
if (trimmed.startsWith("on_")) {
return "union_id";
}
return "user_id";
}
export async function resolveFeishuSenderName(params: {
account: ResolvedFeishuAccount;
senderId: string;
log: FeishuLogger;
}): Promise<SenderNameResult> {
const { account, senderId, log } = params;
if (!account.configured) {
return {};
}
const normalizedSenderId = senderId.trim();
if (!normalizedSenderId) {
return {};
}
const cached = senderNameCache.get(normalizedSenderId);
const now = Date.now();
if (cached && cached.expireAt > now) {
return { name: cached.name };
}
try {
const client = createFeishuClient(account);
const userIdType = resolveSenderLookupIdType(normalizedSenderId);
const res: FeishuContactUserGetResponse = await client.contact.user.get({
path: { user_id: normalizedSenderId },
params: { user_id_type: userIdType },
});
const user = res.data?.user;
const name = user?.name ?? user?.nickname ?? user?.en_name;
if (name) {
senderNameCache.set(normalizedSenderId, { name, expireAt: now + SENDER_NAME_TTL_MS });
return { name };
}
return {};
} catch (err) {
const permErr = extractPermissionError(err);
if (permErr) {
if (shouldSuppressPermissionErrorNotice(permErr)) {
log(`feishu: ignoring stale permission scope error: ${permErr.message}`);
return {};
}
log(`feishu: permission error resolving sender name: code=${permErr.code}`);
return { permissionError: permErr };
}
log(`feishu: failed to resolve sender name for ${normalizedSenderId}: ${String(err)}`);
return {};
}
}

View file

@ -0,0 +1,373 @@
import type { EnvelopeFormatOptions } from "openclaw/plugin-sdk/channel-inbound";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig, PluginRuntime } from "../runtime-api.js";
import type { FeishuMessageEvent } from "./bot.js";
import { handleFeishuMessage } from "./bot.js";
import { setFeishuRuntime } from "./runtime.js";
const { mockCreateFeishuReplyDispatcher, mockCreateFeishuClient, mockResolveAgentRoute } =
vi.hoisted(() => ({
mockCreateFeishuReplyDispatcher: vi.fn(() => ({
dispatcher: {
sendToolResult: vi.fn(),
sendBlockReply: vi.fn(),
sendFinalReply: vi.fn(),
waitForIdle: vi.fn(),
getQueuedCounts: vi.fn(() => ({ tool: 0, block: 0, final: 0 })),
getFailedCounts: vi.fn(() => ({ tool: 0, block: 0, final: 0 })),
markComplete: vi.fn(),
},
replyOptions: {},
markDispatchIdle: vi.fn(),
})),
mockCreateFeishuClient: vi.fn(),
mockResolveAgentRoute: vi.fn(),
}));
vi.mock("./reply-dispatcher.js", () => ({
createFeishuReplyDispatcher: mockCreateFeishuReplyDispatcher,
}));
vi.mock("./client.js", () => ({
createFeishuClient: mockCreateFeishuClient,
}));
function createRuntimeEnv() {
return {
log: vi.fn(),
error: vi.fn(),
writeStdout: vi.fn(),
writeJson: vi.fn(),
exit: vi.fn((code: number): never => {
throw new Error(`exit ${code}`);
}),
};
}
describe("broadcast dispatch", () => {
const finalizeInboundContextCalls: Array<Record<string, unknown>> = [];
const mockFinalizeInboundContext: PluginRuntime["channel"]["reply"]["finalizeInboundContext"] = (
ctx,
) => {
finalizeInboundContextCalls.push(ctx);
return {
...ctx,
CommandAuthorized: typeof ctx.CommandAuthorized === "boolean" ? ctx.CommandAuthorized : false,
};
};
const mockDispatchReplyFromConfig = vi
.fn()
.mockResolvedValue({ queuedFinal: false, counts: { final: 1 } });
const mockWithReplyDispatcher: PluginRuntime["channel"]["reply"]["withReplyDispatcher"] = async ({
dispatcher,
run,
onSettled,
}) => {
try {
return await run();
} finally {
dispatcher.markComplete();
try {
await dispatcher.waitForIdle();
} finally {
await onSettled?.();
}
}
};
const resolveEnvelopeFormatOptionsMock: PluginRuntime["channel"]["reply"]["resolveEnvelopeFormatOptions"] =
() => ({}) satisfies EnvelopeFormatOptions;
const mockShouldComputeCommandAuthorized = vi.fn(() => false);
const mockSaveMediaBuffer = vi.fn().mockResolvedValue({
path: "/tmp/inbound-clip.mp4",
contentType: "video/mp4",
});
const runtimeStub = {
system: {
enqueueSystemEvent: vi.fn(),
},
channel: {
routing: {
resolveAgentRoute: (params: unknown) => mockResolveAgentRoute(params),
},
session: {
resolveStorePath: vi.fn(() => "/tmp/feishu-session-store.json"),
},
reply: {
resolveEnvelopeFormatOptions: resolveEnvelopeFormatOptionsMock,
formatAgentEnvelope: vi.fn((params: { body: string }) => params.body),
finalizeInboundContext:
mockFinalizeInboundContext as unknown as PluginRuntime["channel"]["reply"]["finalizeInboundContext"],
dispatchReplyFromConfig: mockDispatchReplyFromConfig,
withReplyDispatcher:
mockWithReplyDispatcher as unknown as PluginRuntime["channel"]["reply"]["withReplyDispatcher"],
},
commands: {
shouldComputeCommandAuthorized: mockShouldComputeCommandAuthorized,
resolveCommandAuthorizedFromAuthorizers: vi.fn(() => false),
},
media: {
saveMediaBuffer: mockSaveMediaBuffer,
},
pairing: {
readAllowFromStore: vi.fn().mockResolvedValue([]),
upsertPairingRequest: vi.fn().mockResolvedValue({ code: "ABCDEFGH", created: false }),
buildPairingReply: vi.fn(() => "Pairing response"),
},
},
media: {
detectMime: vi.fn(async () => "application/octet-stream"),
},
} as unknown as PluginRuntime;
function createBroadcastConfig(): ClawdbotConfig {
return {
broadcast: { "oc-broadcast-group": ["susan", "main"] },
agents: { list: [{ id: "main" }, { id: "susan" }] },
channels: {
feishu: {
groups: {
"oc-broadcast-group": {
requireMention: true,
},
},
},
},
};
}
function createBroadcastEvent(options: {
messageId: string;
text: string;
botMentioned?: boolean;
}): FeishuMessageEvent {
return {
sender: { sender_id: { open_id: "ou-sender" } },
message: {
message_id: options.messageId,
chat_id: "oc-broadcast-group",
chat_type: "group",
message_type: "text",
content: JSON.stringify({ text: options.text }),
...(options.botMentioned
? {
mentions: [
{
key: "@_user_1",
id: { open_id: "bot-open-id" },
name: "Bot",
tenant_key: "",
},
],
}
: {}),
},
};
}
beforeEach(() => {
vi.clearAllMocks();
finalizeInboundContextCalls.length = 0;
mockResolveAgentRoute.mockReturnValue({
agentId: "main",
channel: "feishu",
accountId: "default",
sessionKey: "agent:main:feishu:group:oc-broadcast-group",
mainSessionKey: "agent:main:main",
lastRoutePolicy: "session",
matchedBy: "default",
});
mockCreateFeishuClient.mockReturnValue({
contact: {
user: {
get: vi.fn().mockResolvedValue({ data: { user: { name: "Sender" } } }),
},
},
});
setFeishuRuntime(runtimeStub);
});
it("dispatches to all broadcast agents when bot is mentioned", async () => {
const cfg = createBroadcastConfig();
const event = createBroadcastEvent({
messageId: "msg-broadcast-mentioned",
text: "hello @bot",
botMentioned: true,
});
await handleFeishuMessage({
cfg,
event,
botOpenId: "bot-open-id",
runtime: createRuntimeEnv(),
});
expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(2);
const sessionKeys = finalizeInboundContextCalls.map((call) => call.SessionKey);
expect(sessionKeys).toContain("agent:susan:feishu:group:oc-broadcast-group");
expect(sessionKeys).toContain("agent:main:feishu:group:oc-broadcast-group");
expect(mockCreateFeishuReplyDispatcher).toHaveBeenCalledTimes(1);
expect(mockCreateFeishuReplyDispatcher).toHaveBeenCalledWith(
expect.objectContaining({ agentId: "main" }),
);
});
it("skips broadcast dispatch when bot is NOT mentioned (requireMention=true)", async () => {
const cfg = createBroadcastConfig();
const event = createBroadcastEvent({
messageId: "msg-broadcast-not-mentioned",
text: "hello everyone",
});
await handleFeishuMessage({
cfg,
event,
botOpenId: "ou_known_bot",
runtime: createRuntimeEnv(),
});
expect(mockDispatchReplyFromConfig).not.toHaveBeenCalled();
expect(mockCreateFeishuReplyDispatcher).not.toHaveBeenCalled();
});
it("skips broadcast dispatch when bot identity is unknown (requireMention=true)", async () => {
const cfg = createBroadcastConfig();
const event = createBroadcastEvent({
messageId: "msg-broadcast-unknown-bot-id",
text: "hello everyone",
});
await handleFeishuMessage({
cfg,
event,
runtime: createRuntimeEnv(),
});
expect(mockDispatchReplyFromConfig).not.toHaveBeenCalled();
expect(mockCreateFeishuReplyDispatcher).not.toHaveBeenCalled();
});
it("preserves single-agent dispatch when no broadcast config", async () => {
const cfg: ClawdbotConfig = {
channels: {
feishu: {
groups: {
"oc-broadcast-group": {
requireMention: false,
},
},
},
},
};
const event: FeishuMessageEvent = {
sender: { sender_id: { open_id: "ou-sender" } },
message: {
message_id: "msg-no-broadcast",
chat_id: "oc-broadcast-group",
chat_type: "group",
message_type: "text",
content: JSON.stringify({ text: "hello" }),
},
};
await handleFeishuMessage({
cfg,
event,
runtime: createRuntimeEnv(),
});
expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(1);
expect(mockCreateFeishuReplyDispatcher).toHaveBeenCalledTimes(1);
expect(finalizeInboundContextCalls).toContainEqual(
expect.objectContaining({
SessionKey: "agent:main:feishu:group:oc-broadcast-group",
}),
);
});
it("cross-account broadcast dedup: second account skips dispatch", async () => {
const cfg: ClawdbotConfig = {
broadcast: { "oc-broadcast-group": ["susan", "main"] },
agents: { list: [{ id: "main" }, { id: "susan" }] },
channels: {
feishu: {
groups: {
"oc-broadcast-group": {
requireMention: false,
},
},
},
},
};
const event: FeishuMessageEvent = {
sender: { sender_id: { open_id: "ou-sender" } },
message: {
message_id: "msg-multi-account-dedup",
chat_id: "oc-broadcast-group",
chat_type: "group",
message_type: "text",
content: JSON.stringify({ text: "hello" }),
},
};
await handleFeishuMessage({
cfg,
event,
runtime: createRuntimeEnv(),
accountId: "account-A",
});
expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(2);
mockDispatchReplyFromConfig.mockClear();
finalizeInboundContextCalls.length = 0;
await handleFeishuMessage({
cfg,
event,
runtime: createRuntimeEnv(),
accountId: "account-B",
});
expect(mockDispatchReplyFromConfig).not.toHaveBeenCalled();
});
it("skips unknown agents not in agents.list", async () => {
const cfg: ClawdbotConfig = {
broadcast: { "oc-broadcast-group": ["susan", "unknown-agent"] },
agents: { list: [{ id: "main" }, { id: "susan" }] },
channels: {
feishu: {
groups: {
"oc-broadcast-group": {
requireMention: false,
},
},
},
},
};
const event: FeishuMessageEvent = {
sender: { sender_id: { open_id: "ou-sender" } },
message: {
message_id: "msg-broadcast-unknown-agent",
chat_id: "oc-broadcast-group",
chat_type: "group",
message_type: "text",
content: JSON.stringify({ text: "hello" }),
},
};
await handleFeishuMessage({
cfg,
event,
runtime: createRuntimeEnv(),
});
expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(1);
const sessionKey =
typeof finalizeInboundContextCalls[0]?.SessionKey === "string"
? finalizeInboundContextCalls[0].SessionKey
: "";
expect(sessionKey).toBe("agent:susan:feishu:group:oc-broadcast-group");
});
});

View file

@ -0,0 +1,608 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { createRuntimeEnv } from "../../../test/helpers/plugins/runtime-env.js";
import type { ClawdbotConfig, RuntimeEnv } from "../runtime-api.js";
import {
FeishuRetryableCardActionError,
handleFeishuCardAction,
resetProcessedFeishuCardActionTokensForTests,
type FeishuCardActionEvent,
} from "./card-action.js";
import { createFeishuCardInteractionEnvelope } from "./card-interaction.js";
import {
FEISHU_APPROVAL_CANCEL_ACTION,
FEISHU_APPROVAL_CONFIRM_ACTION,
FEISHU_APPROVAL_REQUEST_ACTION,
} from "./card-ux-approval.js";
// Mock account resolution
vi.mock("./accounts.js", () => ({
resolveFeishuAccount: vi.fn().mockReturnValue({ accountId: "mock-account" }),
resolveFeishuRuntimeAccount: vi.fn().mockReturnValue({ accountId: "mock-account" }),
}));
// Mock bot.js to verify handleFeishuMessage call
vi.mock("./bot.js", () => ({
handleFeishuMessage: vi.fn(),
}));
const createFeishuClientMock = vi.hoisted(() => vi.fn());
const sendCardFeishuMock = vi.hoisted(() => vi.fn());
const sendMessageFeishuMock = vi.hoisted(() => vi.fn());
vi.mock("./client.js", () => ({
createFeishuClient: createFeishuClientMock,
}));
vi.mock("./send.js", () => ({
sendCardFeishu: sendCardFeishuMock,
sendMessageFeishu: sendMessageFeishuMock,
}));
import { handleFeishuMessage } from "./bot.js";
describe("Feishu Card Action Handler", () => {
const cfg: ClawdbotConfig = {};
const runtime: RuntimeEnv = createRuntimeEnv();
function createCardActionEvent(params: {
token: string;
actionValue: Record<string, unknown>;
chatId?: string;
openId?: string;
userId?: string;
unionId?: string;
}): FeishuCardActionEvent {
const openId = params.openId ?? "u123";
const userId = params.userId ?? "uid1";
return {
operator: { open_id: openId, user_id: userId, union_id: params.unionId ?? "un1" },
token: params.token,
action: {
value: params.actionValue,
tag: "button",
},
context: { open_id: openId, user_id: userId, chat_id: params.chatId ?? "chat1" },
};
}
function createStructuredQuickActionEvent(params: {
token: string;
action: string;
command?: string;
chatId?: string;
chatType?: "group" | "p2p";
operatorOpenId?: string;
actionOpenId?: string;
}): FeishuCardActionEvent {
return createCardActionEvent({
token: params.token,
chatId: params.chatId,
openId: params.operatorOpenId,
actionValue: createFeishuCardInteractionEnvelope({
k: "quick",
a: params.action,
...(params.command ? { q: params.command } : {}),
c: {
u: params.actionOpenId ?? params.operatorOpenId ?? "u123",
h: params.chatId ?? "chat1",
t: params.chatType ?? "group",
e: Date.now() + 60_000,
},
}),
});
}
beforeEach(() => {
vi.clearAllMocks();
createFeishuClientMock.mockReset().mockReturnValue({
im: {
chat: {
get: vi.fn().mockResolvedValue({ code: 0, data: { chat_type: "group" } }),
},
},
});
vi.mocked(handleFeishuMessage)
.mockReset()
.mockResolvedValue(undefined as never);
resetProcessedFeishuCardActionTokensForTests();
});
it("handles card action with text payload", async () => {
const event: FeishuCardActionEvent = {
operator: { open_id: "u123", user_id: "uid1", union_id: "un1" },
token: "tok1",
action: { value: { text: "/ping" }, tag: "button" },
context: { open_id: "u123", user_id: "uid1", chat_id: "chat1" },
};
await handleFeishuCardAction({ cfg, event, runtime });
expect(handleFeishuMessage).toHaveBeenCalledWith(
expect.objectContaining({
event: expect.objectContaining({
message: expect.objectContaining({
content: '{"text":"/ping"}',
chat_id: "chat1",
}),
}),
}),
);
});
it("handles card action with JSON object payload", async () => {
const event: FeishuCardActionEvent = {
operator: { open_id: "u123", user_id: "uid1", union_id: "un1" },
token: "tok2",
action: { value: { key: "val" }, tag: "button" },
context: { open_id: "u123", user_id: "uid1", chat_id: "" },
};
await handleFeishuCardAction({ cfg, event, runtime });
expect(handleFeishuMessage).toHaveBeenCalledWith(
expect.objectContaining({
event: expect.objectContaining({
message: expect.objectContaining({
content: '{"text":"{\\"key\\":\\"val\\"}"}',
chat_id: "u123", // Fallback to open_id
}),
}),
}),
);
});
it("routes quick command actions with operator and conversation context", async () => {
const event = createStructuredQuickActionEvent({
token: "tok3",
action: "feishu.quick_actions.help",
command: "/help",
});
await handleFeishuCardAction({ cfg, event, runtime });
expect(handleFeishuMessage).toHaveBeenCalledWith(
expect.objectContaining({
event: expect.objectContaining({
sender: expect.objectContaining({
sender_id: expect.objectContaining({
open_id: "u123",
user_id: "uid1",
union_id: "un1",
}),
}),
message: expect.objectContaining({
chat_id: "chat1",
content: '{"text":"/help"}',
}),
}),
}),
);
});
it("opens an approval card for metadata actions", async () => {
const event: FeishuCardActionEvent = {
operator: { open_id: "u123", user_id: "uid1", union_id: "un1" },
token: "tok4",
action: {
value: createFeishuCardInteractionEnvelope({
k: "meta",
a: FEISHU_APPROVAL_REQUEST_ACTION,
m: {
command: "/new",
prompt: "Start a fresh session?",
},
c: {
u: "u123",
h: "chat1",
t: "group",
s: "agent:codex:feishu:chat:chat1",
e: Date.now() + 60_000,
},
}),
tag: "button",
},
context: { open_id: "u123", user_id: "uid1", chat_id: "chat1" },
};
await handleFeishuCardAction({ cfg, event, runtime, accountId: "main" });
expect(sendCardFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
to: "chat:chat1",
accountId: "main",
card: expect.objectContaining({
config: expect.objectContaining({
width_mode: "fill",
}),
header: expect.objectContaining({
title: expect.objectContaining({ content: "Confirm action" }),
}),
body: expect.objectContaining({
elements: expect.arrayContaining([
expect.objectContaining({
tag: "action",
actions: expect.arrayContaining([
expect.objectContaining({
value: expect.objectContaining({
c: expect.objectContaining({
u: "u123",
h: "chat1",
t: "group",
s: "agent:codex:feishu:chat:chat1",
}),
}),
}),
]),
}),
]),
}),
}),
}),
);
const firstSendArg = (sendCardFeishuMock.mock.calls as unknown[][]).at(0)?.[0] as
| {
card?: {
config?: {
width_mode?: string;
wide_screen_mode?: boolean;
enable_forward?: boolean;
};
};
}
| undefined;
const sentCard = firstSendArg?.card;
expect(sentCard).toBeDefined();
expect(sentCard?.config?.wide_screen_mode).toBeUndefined();
expect(sentCard?.config?.enable_forward).toBeUndefined();
expect(handleFeishuMessage).not.toHaveBeenCalled();
});
it("runs approval confirmation through the normal message path", async () => {
const event = createStructuredQuickActionEvent({
token: "tok5",
action: FEISHU_APPROVAL_CONFIRM_ACTION,
command: "/new",
});
await handleFeishuCardAction({ cfg, event, runtime });
expect(handleFeishuMessage).toHaveBeenCalledWith(
expect.objectContaining({
event: expect.objectContaining({
message: expect.objectContaining({
content: '{"text":"/new"}',
}),
}),
}),
);
});
it("safely rejects stale structured actions", async () => {
const event = createCardActionEvent({
token: "tok6",
actionValue: createFeishuCardInteractionEnvelope({
k: "quick",
a: "feishu.quick_actions.help",
q: "/help",
c: { u: "u123", h: "chat1", t: "group", e: Date.now() - 1 },
}),
});
await handleFeishuCardAction({ cfg, event, runtime });
expect(sendMessageFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
to: "chat:chat1",
text: expect.stringContaining("expired"),
}),
);
expect(handleFeishuMessage).not.toHaveBeenCalled();
});
it("safely rejects wrong-user structured actions", async () => {
const event = createStructuredQuickActionEvent({
token: "tok7",
action: "feishu.quick_actions.help",
command: "/help",
operatorOpenId: "u999",
actionOpenId: "u123",
});
await handleFeishuCardAction({ cfg, event, runtime });
expect(sendMessageFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
text: expect.stringContaining("different user"),
}),
);
expect(handleFeishuMessage).not.toHaveBeenCalled();
});
it("sends a lightweight cancellation notice", async () => {
const event: FeishuCardActionEvent = {
operator: { open_id: "u123", user_id: "uid1", union_id: "un1" },
token: "tok8",
action: {
value: createFeishuCardInteractionEnvelope({
k: "button",
a: FEISHU_APPROVAL_CANCEL_ACTION,
c: { u: "u123", h: "chat1", t: "group", e: Date.now() + 60_000 },
}),
tag: "button",
},
context: { open_id: "u123", user_id: "uid1", chat_id: "chat1" },
};
await handleFeishuCardAction({ cfg, event, runtime });
expect(sendMessageFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
to: "chat:chat1",
text: "Cancelled.",
}),
);
});
it("preserves p2p callbacks for DM quick actions", async () => {
const event = createStructuredQuickActionEvent({
token: "tok9",
action: "feishu.quick_actions.help",
command: "/help",
chatId: "p2p-chat-1",
chatType: "p2p",
});
await handleFeishuCardAction({ cfg, event, runtime });
expect(handleFeishuMessage).toHaveBeenCalledWith(
expect.objectContaining({
event: expect.objectContaining({
message: expect.objectContaining({
chat_id: "p2p-chat-1",
chat_type: "p2p",
}),
}),
}),
);
});
it("resolves DM chat type from the Feishu chat API when card context omits it", async () => {
createFeishuClientMock.mockReturnValueOnce({
im: {
chat: {
get: vi.fn().mockResolvedValue({ code: 0, data: { chat_type: "p2p" } }),
},
},
});
const event = createCardActionEvent({
token: "tok9b",
chatId: "oc_dm_chat_123",
actionValue: { text: "/help" },
});
await handleFeishuCardAction({ cfg, event, runtime });
expect(handleFeishuMessage).toHaveBeenCalledWith(
expect.objectContaining({
event: expect.objectContaining({
message: expect.objectContaining({
chat_id: "oc_dm_chat_123",
chat_type: "p2p",
}),
}),
}),
);
expect(createFeishuClientMock).toHaveBeenCalledTimes(1);
});
it("uses resolved DM chat type when building approval cards without stored context", async () => {
createFeishuClientMock.mockReturnValueOnce({
im: {
chat: {
get: vi.fn().mockResolvedValue({ code: 0, data: { chat_mode: "p2p" } }),
},
},
});
const event = createCardActionEvent({
token: "tok9c",
chatId: "oc_dm_chat_234",
actionValue: createFeishuCardInteractionEnvelope({
k: "meta",
a: FEISHU_APPROVAL_REQUEST_ACTION,
m: {
command: "/new",
prompt: "Start a fresh session?",
},
c: {
u: "u123",
h: "oc_dm_chat_234",
e: Date.now() + 60_000,
},
}),
});
await handleFeishuCardAction({ cfg, event, runtime, accountId: "main" });
expect(sendCardFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
card: expect.objectContaining({
body: expect.objectContaining({
elements: expect.arrayContaining([
expect.objectContaining({
tag: "action",
actions: expect.arrayContaining([
expect.objectContaining({
value: expect.objectContaining({
c: expect.objectContaining({
t: "p2p",
}),
}),
}),
]),
}),
]),
}),
}),
}),
);
expect(createFeishuClientMock).toHaveBeenCalledTimes(1);
});
it("falls back to p2p when Feishu chat API returns an error", async () => {
createFeishuClientMock.mockReturnValueOnce({
im: {
chat: {
get: vi.fn().mockResolvedValue({ code: 99, msg: "not found" }),
},
},
});
const event = createCardActionEvent({
token: "tok9d",
chatId: "oc_unknown_chat_456",
actionValue: { text: "/help" },
});
await handleFeishuCardAction({ cfg, event, runtime });
expect(handleFeishuMessage).toHaveBeenCalledWith(
expect.objectContaining({
event: expect.objectContaining({
message: expect.objectContaining({
chat_type: "p2p",
}),
}),
}),
);
});
it("falls back to p2p when Feishu chat API throws", async () => {
createFeishuClientMock.mockReturnValueOnce({
im: {
chat: {
get: vi.fn().mockRejectedValue(new Error("network failure")),
},
},
});
const event = createCardActionEvent({
token: "tok9e",
chatId: "oc_broken_chat_789",
actionValue: { text: "/help" },
});
await handleFeishuCardAction({ cfg, event, runtime });
expect(handleFeishuMessage).toHaveBeenCalledWith(
expect.objectContaining({
event: expect.objectContaining({
message: expect.objectContaining({
chat_type: "p2p",
}),
}),
}),
);
});
it("drops duplicate structured callback tokens", async () => {
const event = createStructuredQuickActionEvent({
token: "tok10",
action: "feishu.quick_actions.help",
command: "/help",
});
await handleFeishuCardAction({ cfg, event, runtime });
await handleFeishuCardAction({ cfg, event, runtime });
expect(handleFeishuMessage).toHaveBeenCalledTimes(1);
});
it("rejects empty callback tokens before dispatch", async () => {
const log = vi.fn();
const event = createStructuredQuickActionEvent({
token: " ",
action: "feishu.quick_actions.help",
command: "/help",
});
await handleFeishuCardAction({
cfg,
event,
runtime: {
...runtime,
log,
},
});
expect(handleFeishuMessage).not.toHaveBeenCalled();
expect(log).toHaveBeenCalledWith(
"feishu[mock-account]: rejected card action from u123: missing token",
);
});
it("keeps a claimed token completed after a non-retryable dispatch failure", async () => {
const event = createStructuredQuickActionEvent({
token: "tok11",
action: "feishu.quick_actions.help",
command: "/help",
});
vi.mocked(handleFeishuMessage)
.mockRejectedValueOnce(new Error("transient"))
.mockResolvedValueOnce(undefined as never);
await expect(handleFeishuCardAction({ cfg, event, runtime })).rejects.toThrow("transient");
await handleFeishuCardAction({ cfg, event, runtime });
expect(handleFeishuMessage).toHaveBeenCalledTimes(1);
});
it("releases a claimed token for explicit retryable dispatch failures", async () => {
const event = createStructuredQuickActionEvent({
token: "tok11-retryable",
action: "feishu.quick_actions.help",
command: "/help",
});
vi.mocked(handleFeishuMessage)
.mockRejectedValueOnce(new FeishuRetryableCardActionError("retry me"))
.mockResolvedValueOnce(undefined as never);
await expect(handleFeishuCardAction({ cfg, event, runtime })).rejects.toThrow("retry me");
await handleFeishuCardAction({ cfg, event, runtime });
expect(handleFeishuMessage).toHaveBeenCalledTimes(2);
});
it("keeps an in-flight token claimed while a slow dispatch is still running", async () => {
vi.useFakeTimers();
const event: FeishuCardActionEvent = {
operator: { open_id: "u123", user_id: "uid1", union_id: "un1" },
token: "tok12",
action: {
value: createFeishuCardInteractionEnvelope({
k: "quick",
a: "feishu.quick_actions.help",
q: "/help",
c: { u: "u123", h: "chat1", t: "group", e: Date.now() + 60_000 },
}),
tag: "button",
},
context: { open_id: "u123", user_id: "uid1", chat_id: "chat1" },
};
let resolveDispatch: (() => void) | undefined;
vi.mocked(handleFeishuMessage).mockImplementation(
() =>
new Promise<void>((resolve) => {
resolveDispatch = resolve;
}) as never,
);
const first = handleFeishuCardAction({ cfg, event, runtime });
await vi.advanceTimersByTimeAsync(61_000);
await handleFeishuCardAction({ cfg, event, runtime });
expect(handleFeishuMessage).toHaveBeenCalledTimes(1);
resolveDispatch?.();
await first;
vi.useRealTimers();
});
});

View file

@ -0,0 +1,193 @@
import { describe, it, expect } from "vitest";
import { parseFeishuMessageEvent, type FeishuMessageEvent } from "./bot.js";
// Helper to build a minimal FeishuMessageEvent for testing
function makeEvent(
chatType: "p2p" | "group" | "private",
mentions?: Array<{ key: string; name: string; id: { open_id?: string } }>,
text = "hello",
): FeishuMessageEvent {
return {
sender: {
sender_id: { user_id: "u1", open_id: "ou_sender" },
},
message: {
message_id: "msg_1",
chat_id: "oc_chat1",
chat_type: chatType,
message_type: "text",
content: JSON.stringify({ text }),
mentions,
},
};
}
function makePostEvent(content: unknown): FeishuMessageEvent {
return {
sender: { sender_id: { user_id: "u1", open_id: "ou_sender" } },
message: {
message_id: "msg_1",
chat_id: "oc_chat1",
chat_type: "group",
message_type: "post",
content: JSON.stringify(content),
mentions: [],
},
};
}
function makeShareChatEvent(content: unknown): FeishuMessageEvent {
return {
sender: { sender_id: { user_id: "u1", open_id: "ou_sender" } },
message: {
message_id: "msg_1",
chat_id: "oc_chat1",
chat_type: "group",
message_type: "share_chat",
content: JSON.stringify(content),
mentions: [],
},
};
}
describe("parseFeishuMessageEvent – mentionedBot", () => {
const BOT_OPEN_ID = "ou_bot_123";
it("returns mentionedBot=false when there are no mentions", () => {
const event = makeEvent("group", []);
const ctx = parseFeishuMessageEvent(event, BOT_OPEN_ID);
expect(ctx.mentionedBot).toBe(false);
});
it("falls back to sender user_id when open_id is missing", () => {
const event = makeEvent("p2p", []);
event.sender.sender_id = { user_id: "u_mobile_only" };
const ctx = parseFeishuMessageEvent(event, BOT_OPEN_ID);
expect(ctx.senderOpenId).toBe("u_mobile_only");
expect(ctx.senderId).toBe("u_mobile_only");
});
it("returns mentionedBot=true when bot is mentioned", () => {
const event = makeEvent("group", [
{ key: "@_user_1", name: "Bot", id: { open_id: BOT_OPEN_ID } },
]);
const ctx = parseFeishuMessageEvent(event, BOT_OPEN_ID);
expect(ctx.mentionedBot).toBe(true);
});
it("returns mentionedBot=true when bot mention name differs from configured botName", () => {
const event = makeEvent("group", [
{ key: "@_user_1", name: "OpenClaw Bot (Alias)", id: { open_id: BOT_OPEN_ID } },
]);
const ctx = parseFeishuMessageEvent(event, BOT_OPEN_ID, "OpenClaw Bot");
expect(ctx.mentionedBot).toBe(true);
});
it("returns mentionedBot=false when only other users are mentioned", () => {
const event = makeEvent("group", [
{ key: "@_user_1", name: "Alice", id: { open_id: "ou_alice" } },
]);
const ctx = parseFeishuMessageEvent(event, BOT_OPEN_ID);
expect(ctx.mentionedBot).toBe(false);
});
it("returns mentionedBot=false when botOpenId is undefined (unknown bot)", () => {
const event = makeEvent("group", [
{ key: "@_user_1", name: "Alice", id: { open_id: "ou_alice" } },
]);
const ctx = parseFeishuMessageEvent(event, undefined);
expect(ctx.mentionedBot).toBe(false);
});
it("returns mentionedBot=false when botOpenId is empty string (probe failed)", () => {
const event = makeEvent("group", [
{ key: "@_user_1", name: "Alice", id: { open_id: "ou_alice" } },
]);
const ctx = parseFeishuMessageEvent(event, "");
expect(ctx.mentionedBot).toBe(false);
});
it("treats mention.name regex metacharacters as literals when stripping", () => {
const event = makeEvent(
"group",
[{ key: "@_bot_1", name: ".*", id: { open_id: BOT_OPEN_ID } }],
"@NotBot hello",
);
const ctx = parseFeishuMessageEvent(event, BOT_OPEN_ID);
expect(ctx.content).toBe("@NotBot hello");
});
it("treats mention.key regex metacharacters as literals when stripping", () => {
const event = makeEvent(
"group",
[{ key: ".*", name: "Bot", id: { open_id: BOT_OPEN_ID } }],
"hello world",
);
const ctx = parseFeishuMessageEvent(event, BOT_OPEN_ID);
expect(ctx.content).toBe("hello world");
});
it("returns mentionedBot=true for post message with at (no top-level mentions)", () => {
const BOT_OPEN_ID = "ou_bot_123";
const event = makePostEvent({
content: [
[{ tag: "at", user_id: BOT_OPEN_ID, user_name: "claw" }],
[{ tag: "text", text: "What does this document say" }],
],
});
const ctx = parseFeishuMessageEvent(event, BOT_OPEN_ID);
expect(ctx.mentionedBot).toBe(true);
});
it("returns mentionedBot=false for post message with no at", () => {
const event = makePostEvent({
content: [[{ tag: "text", text: "hello" }]],
});
const ctx = parseFeishuMessageEvent(event, "ou_bot_123");
expect(ctx.mentionedBot).toBe(false);
});
it("returns mentionedBot=false for post message with at for another user", () => {
const event = makePostEvent({
content: [
[{ tag: "at", user_id: "ou_other", user_name: "other" }],
[{ tag: "text", text: "hello" }],
],
});
const ctx = parseFeishuMessageEvent(event, "ou_bot_123");
expect(ctx.mentionedBot).toBe(false);
});
it("preserves post code and code_block content", () => {
const event = makePostEvent({
content: [
[
{ tag: "text", text: "before " },
{ tag: "code", text: "inline()" },
],
[{ tag: "code_block", language: "ts", text: "const x = 1;" }],
],
});
const ctx = parseFeishuMessageEvent(event, "ou_bot_123");
expect(ctx.content).toContain("before `inline()`");
expect(ctx.content).toContain("```ts\nconst x = 1;\n```");
});
it("uses share_chat body when available", () => {
const event = makeShareChatEvent({
body: "Merged and Forwarded Message",
share_chat_id: "sc_abc123",
});
const ctx = parseFeishuMessageEvent(event, "ou_bot_123");
expect(ctx.content).toBe("Merged and Forwarded Message");
});
it("falls back to share_chat identifier when body is unavailable", () => {
const event = makeShareChatEvent({
share_chat_id: "sc_abc123",
});
const ctx = parseFeishuMessageEvent(event, "ou_bot_123");
expect(ctx.content).toBe("[Forwarded message: sc_abc123]");
});
});

View file

@ -0,0 +1,94 @@
import { describe, expect, it } from "vitest";
import type { ClawdbotConfig } from "../runtime-api.js";
import {
buildBroadcastSessionKey,
buildFeishuAgentBody,
resolveBroadcastAgents,
toMessageResourceType,
} from "./bot.js";
describe("buildFeishuAgentBody", () => {
it("builds message id, speaker, quoted content, mentions, and permission notice in order", () => {
const body = buildFeishuAgentBody({
ctx: {
content: "hello world",
senderName: "Sender Name",
senderOpenId: "ou-sender",
messageId: "msg-42",
mentionTargets: [{ openId: "ou-target", name: "Target User", key: "@_user_1" }],
},
quotedContent: "previous message",
permissionErrorForAgent: {
code: 99991672,
message: "permission denied",
grantUrl: "https://open.feishu.cn/app/cli_test",
},
});
expect(body).toBe(
'[message_id: msg-42]\nSender Name: [Replying to: "previous message"]\n\nhello world\n\n[System: Your reply will automatically @mention: Target User. Do not write @xxx yourself.]\n\n[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: https://open.feishu.cn/app/cli_test]',
);
});
});
describe("toMessageResourceType", () => {
it("maps image to image", () => {
expect(toMessageResourceType("image")).toBe("image");
});
it("maps audio to file", () => {
expect(toMessageResourceType("audio")).toBe("file");
});
it("maps video/file/sticker to file", () => {
expect(toMessageResourceType("video")).toBe("file");
expect(toMessageResourceType("file")).toBe("file");
expect(toMessageResourceType("sticker")).toBe("file");
});
});
describe("resolveBroadcastAgents", () => {
it("returns agent list when broadcast config has the peerId", () => {
const cfg: ClawdbotConfig = { broadcast: { oc_group123: ["susan", "main"] } };
expect(resolveBroadcastAgents(cfg, "oc_group123")).toEqual(["susan", "main"]);
});
it("returns null when no broadcast config", () => {
const cfg = {} as ClawdbotConfig;
expect(resolveBroadcastAgents(cfg, "oc_group123")).toBeNull();
});
it("returns null when peerId not in broadcast", () => {
const cfg: ClawdbotConfig = { broadcast: { oc_other: ["susan"] } };
expect(resolveBroadcastAgents(cfg, "oc_group123")).toBeNull();
});
it("returns null when agent list is empty", () => {
const cfg: ClawdbotConfig = { broadcast: { oc_group123: [] } };
expect(resolveBroadcastAgents(cfg, "oc_group123")).toBeNull();
});
});
describe("buildBroadcastSessionKey", () => {
it("replaces agent ID prefix in session key", () => {
expect(buildBroadcastSessionKey("agent:main:feishu:group:oc_group123", "main", "susan")).toBe(
"agent:susan:feishu:group:oc_group123",
);
});
it("handles compound peer IDs", () => {
expect(
buildBroadcastSessionKey(
"agent:main:feishu:group:oc_group123:sender:ou_user1",
"main",
"susan",
),
).toBe("agent:susan:feishu:group:oc_group123:sender:ou_user1");
});
it("returns base key unchanged when prefix does not match", () => {
expect(buildBroadcastSessionKey("custom:key:format", "main", "susan")).toBe(
"custom:key:format",
);
});
});

View file

@ -0,0 +1,126 @@
import { describe, expect, it } from "vitest";
import { parseFeishuMessageEvent, type FeishuMessageEvent } from "./bot.js";
function makeEvent(
text: string,
mentions?: Array<{ key: string; name: string; id: { open_id?: string; user_id?: string } }>,
chatType: "p2p" | "group" = "p2p",
): FeishuMessageEvent {
return {
sender: { sender_id: { user_id: "u1", open_id: "ou_sender" } },
message: {
message_id: "msg_1",
chat_id: "oc_chat1",
chat_type: chatType,
message_type: "text",
content: JSON.stringify({ text }),
mentions,
},
};
}
const BOT_OPEN_ID = "ou_bot";
describe("normalizeMentions (via parseFeishuMessageEvent)", () => {
it("returns original text when mentions are missing", () => {
const ctx = parseFeishuMessageEvent(makeEvent("hello world", undefined), BOT_OPEN_ID);
expect(ctx.content).toBe("hello world");
});
it("strips bot mention in p2p (addressing prefix, not semantic content)", () => {
const ctx = parseFeishuMessageEvent(
makeEvent("@_bot_1 hello", [{ key: "@_bot_1", name: "Bot", id: { open_id: "ou_bot" } }]),
BOT_OPEN_ID,
);
expect(ctx.content).toBe("hello");
});
it("strips bot mention in group so slash commands work (#35994)", () => {
const ctx = parseFeishuMessageEvent(
makeEvent(
"@_bot_1 hello",
[{ key: "@_bot_1", name: "Bot", id: { open_id: "ou_bot" } }],
"group",
),
BOT_OPEN_ID,
);
expect(ctx.content).toBe("hello");
});
it("strips bot mention in group preserving slash command prefix (#35994)", () => {
const ctx = parseFeishuMessageEvent(
makeEvent(
"@_bot_1 /model",
[{ key: "@_bot_1", name: "Bot", id: { open_id: "ou_bot" } }],
"group",
),
BOT_OPEN_ID,
);
expect(ctx.content).toBe("/model");
});
it("strips bot mention but normalizes other mentions in p2p (mention-forward)", () => {
const ctx = parseFeishuMessageEvent(
makeEvent("@_bot_1 @_user_alice hello", [
{ key: "@_bot_1", name: "Bot", id: { open_id: "ou_bot" } },
{ key: "@_user_alice", name: "Alice", id: { open_id: "ou_alice" } },
]),
BOT_OPEN_ID,
);
expect(ctx.content).toBe('<at user_id="ou_alice">Alice</at> hello');
});
it("falls back to @name when open_id is absent", () => {
const ctx = parseFeishuMessageEvent(
makeEvent("@_user_1 hi", [{ key: "@_user_1", name: "Alice", id: { user_id: "uid_alice" } }]),
BOT_OPEN_ID,
);
expect(ctx.content).toBe("@Alice hi");
});
it("falls back to plain @name when no id is present", () => {
const ctx = parseFeishuMessageEvent(
makeEvent("@_unknown hey", [{ key: "@_unknown", name: "Nobody", id: {} }]),
BOT_OPEN_ID,
);
expect(ctx.content).toBe("@Nobody hey");
});
it("treats mention key regex metacharacters as literal text", () => {
const ctx = parseFeishuMessageEvent(
makeEvent("hello world", [{ key: ".*", name: "Bot", id: { open_id: "ou_bot" } }]),
BOT_OPEN_ID,
);
expect(ctx.content).toBe("hello world");
});
it("normalizes multiple mentions in one pass", () => {
const ctx = parseFeishuMessageEvent(
makeEvent("@_bot_1 hi @_user_2", [
{ key: "@_bot_1", name: "Bot One", id: { open_id: "ou_bot_1" } },
{ key: "@_user_2", name: "User Two", id: { open_id: "ou_user_2" } },
]),
BOT_OPEN_ID,
);
expect(ctx.content).toBe(
'<at user_id="ou_bot_1">Bot One</at> hi <at user_id="ou_user_2">User Two</at>',
);
});
it("treats $ in display name as literal (no replacement-pattern interpolation)", () => {
const ctx = parseFeishuMessageEvent(
makeEvent("@_user_1 hi", [{ key: "@_user_1", name: "$& the user", id: { open_id: "ou_x" } }]),
BOT_OPEN_ID,
);
// $ is preserved literally (no $& pattern substitution); & is not escaped in tag body
expect(ctx.content).toBe('<at user_id="ou_x">$& the user</at> hi');
});
it("escapes < and > in mention name to protect tag structure", () => {
const ctx = parseFeishuMessageEvent(
makeEvent("@_user_1 test", [{ key: "@_user_1", name: "<script>", id: { open_id: "ou_x" } }]),
BOT_OPEN_ID,
);
expect(ctx.content).toBe('<at user_id="ou_x">&lt;script&gt;</at> test');
});
});

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,442 @@
import type { ClawdbotConfig, RuntimeEnv } from "../runtime-api.js";
import { resolveFeishuRuntimeAccount } from "./accounts.js";
import { handleFeishuMessage, type FeishuMessageEvent } from "./bot.js";
import { decodeFeishuCardAction, buildFeishuCardActionTextFallback } from "./card-interaction.js";
import {
createApprovalCard,
FEISHU_APPROVAL_CANCEL_ACTION,
FEISHU_APPROVAL_CONFIRM_ACTION,
FEISHU_APPROVAL_REQUEST_ACTION,
} from "./card-ux-approval.js";
import { createFeishuClient } from "./client.js";
import { sendCardFeishu, sendMessageFeishu } from "./send.js";
export type FeishuCardActionEvent = {
operator: {
open_id: string;
user_id: string;
union_id: string;
};
token: string;
action: {
value: Record<string, unknown>;
tag: string;
};
context: {
open_id: string;
user_id: string;
chat_id: string;
};
};
const FEISHU_APPROVAL_CARD_TTL_MS = 5 * 60_000;
const FEISHU_CARD_ACTION_TOKEN_TTL_MS = 15 * 60_000;
const processedCardActionTokens = new Map<
string,
{ status: "inflight" | "completed"; expiresAt: number }
>();
export class FeishuRetryableCardActionError extends Error {
constructor(message: string, options?: ErrorOptions) {
super(message, options);
this.name = "FeishuRetryableCardActionError";
}
}
export function resetProcessedFeishuCardActionTokensForTests(): void {
processedCardActionTokens.clear();
}
function pruneProcessedCardActionTokens(now: number): void {
for (const [key, entry] of processedCardActionTokens.entries()) {
if (entry.expiresAt <= now) {
processedCardActionTokens.delete(key);
}
}
}
function beginFeishuCardActionToken(params: {
token: string;
accountId: string;
now?: number;
}): boolean {
const now = params.now ?? Date.now();
pruneProcessedCardActionTokens(now);
const normalizedToken = params.token.trim();
if (!normalizedToken) {
return false;
}
const key = `${params.accountId}:${normalizedToken}`;
const existing = processedCardActionTokens.get(key);
if (existing && existing.expiresAt > now) {
return false;
}
processedCardActionTokens.set(key, {
status: "inflight",
expiresAt: now + FEISHU_CARD_ACTION_TOKEN_TTL_MS,
});
return true;
}
function completeFeishuCardActionToken(params: {
token: string;
accountId: string;
now?: number;
}): void {
const now = params.now ?? Date.now();
const normalizedToken = params.token.trim();
if (!normalizedToken) {
return;
}
processedCardActionTokens.set(`${params.accountId}:${normalizedToken}`, {
status: "completed",
expiresAt: now + FEISHU_CARD_ACTION_TOKEN_TTL_MS,
});
}
function releaseFeishuCardActionToken(params: { token: string; accountId: string }): void {
const normalizedToken = params.token.trim();
if (!normalizedToken) {
return;
}
processedCardActionTokens.delete(`${params.accountId}:${normalizedToken}`);
}
function buildSyntheticMessageEvent(
event: FeishuCardActionEvent,
content: string,
chatType: "p2p" | "group",
): FeishuMessageEvent {
return {
sender: {
sender_id: {
open_id: event.operator.open_id,
user_id: event.operator.user_id,
union_id: event.operator.union_id,
},
},
message: {
message_id: `card-action-${event.token}`,
chat_id: event.context.chat_id || event.operator.open_id,
chat_type: chatType,
message_type: "text",
content: JSON.stringify({ text: content }),
},
};
}
function resolveCallbackTarget(event: FeishuCardActionEvent): string {
const chatId = event.context.chat_id?.trim();
if (chatId) {
return `chat:${chatId}`;
}
return `user:${event.operator.open_id}`;
}
async function dispatchSyntheticCommand(params: {
cfg: ClawdbotConfig;
event: FeishuCardActionEvent;
command: string;
account: ReturnType<typeof resolveFeishuRuntimeAccount>;
botOpenId?: string;
runtime?: RuntimeEnv;
accountId?: string;
chatType?: "p2p" | "group";
}): Promise<void> {
const resolvedChatType = await resolveCardActionChatType({
event: params.event,
account: params.account,
chatType: params.chatType,
log: params.runtime?.log ?? console.log,
});
await handleFeishuMessage({
cfg: params.cfg,
event: buildSyntheticMessageEvent(params.event, params.command, resolvedChatType),
botOpenId: params.botOpenId,
runtime: params.runtime,
accountId: params.accountId,
});
}
// Feishu's im.chat.get returns two fields:
// chat_mode: conversation type — "p2p" | "group" | "topic"
// chat_type: privacy classification — "private" | "public"
// We check chat_mode first because it directly indicates conversation type.
// "private" maps to "p2p" as the safe-failure direction (restrictive DM
// policy) — a private group chat misclassified as p2p is safer than the
// reverse. "topic" and "public" are treated as group semantics.
function normalizeResolvedCardActionChatType(value: unknown): "p2p" | "group" | undefined {
if (value === "group" || value === "topic" || value === "public") {
return "group";
}
if (value === "p2p" || value === "private") {
return "p2p";
}
return undefined;
}
const resolvedChatTypeCache = new Map<string, { value: "p2p" | "group"; expiresAt: number }>();
const CHAT_TYPE_CACHE_TTL_MS = 30 * 60_000;
const CHAT_TYPE_CACHE_MAX_SIZE = 5_000;
function pruneChatTypeCache(now: number): void {
for (const [key, entry] of resolvedChatTypeCache.entries()) {
if (entry.expiresAt <= now) {
resolvedChatTypeCache.delete(key);
}
}
if (resolvedChatTypeCache.size > CHAT_TYPE_CACHE_MAX_SIZE) {
const excess = resolvedChatTypeCache.size - CHAT_TYPE_CACHE_MAX_SIZE;
const iter = resolvedChatTypeCache.keys();
for (let i = 0; i < excess; i++) {
const key = iter.next().value;
if (key !== undefined) {
resolvedChatTypeCache.delete(key);
}
}
}
}
function sanitizeLogValue(v: string): string {
return v.replace(/[\r\n]/g, " ").slice(0, 500);
}
async function resolveCardActionChatType(params: {
event: FeishuCardActionEvent;
account: ReturnType<typeof resolveFeishuRuntimeAccount>;
chatType?: "p2p" | "group";
log: (message: string) => void;
}): Promise<"p2p" | "group"> {
const explicitChatType = normalizeResolvedCardActionChatType(params.chatType);
if (explicitChatType) {
return explicitChatType;
}
const chatId = params.event.context.chat_id?.trim();
if (!chatId) {
return "p2p";
}
const cacheKey = `${params.account.accountId}:${chatId}`;
const now = Date.now();
pruneChatTypeCache(now);
const cached = resolvedChatTypeCache.get(cacheKey);
if (cached) {
return cached.value;
}
try {
const response = (await createFeishuClient(params.account).im.chat.get({
path: { chat_id: chatId },
})) as { code?: number; msg?: string; data?: { chat_type?: unknown; chat_mode?: unknown } };
if (response.code === 0) {
const resolvedChatType =
normalizeResolvedCardActionChatType(response.data?.chat_mode) ??
normalizeResolvedCardActionChatType(response.data?.chat_type);
if (resolvedChatType) {
resolvedChatTypeCache.set(cacheKey, {
value: resolvedChatType,
expiresAt: now + CHAT_TYPE_CACHE_TTL_MS,
});
return resolvedChatType;
}
params.log(
`feishu[${params.account.accountId}]: card action missing chat type for chat; defaulting to p2p`,
);
} else {
params.log(
`feishu[${params.account.accountId}]: failed to resolve chat type: ${sanitizeLogValue(response.msg ?? "unknown error")}; defaulting to p2p`,
);
}
} catch (err) {
const message = err instanceof Error ? err.message : "unknown";
params.log(
`feishu[${params.account.accountId}]: failed to resolve chat type: ${sanitizeLogValue(message)}; defaulting to p2p`,
);
}
return "p2p";
}
async function sendInvalidInteractionNotice(params: {
cfg: ClawdbotConfig;
event: FeishuCardActionEvent;
reason: "malformed" | "stale" | "wrong_user" | "wrong_conversation";
accountId?: string;
}): Promise<void> {
const reasonText =
params.reason === "stale"
? "This card action has expired. Open a fresh launcher card and try again."
: params.reason === "wrong_user"
? "This card action belongs to a different user."
: params.reason === "wrong_conversation"
? "This card action belongs to a different conversation."
: "This card action payload is invalid.";
await sendMessageFeishu({
cfg: params.cfg,
to: resolveCallbackTarget(params.event),
text: `⚠️ ${reasonText}`,
accountId: params.accountId,
});
}
export async function handleFeishuCardAction(params: {
cfg: ClawdbotConfig;
event: FeishuCardActionEvent;
botOpenId?: string;
runtime?: RuntimeEnv;
accountId?: string;
}): Promise<void> {
const { cfg, event, runtime, accountId } = params;
const account = resolveFeishuRuntimeAccount({ cfg, accountId });
const log = runtime?.log ?? console.log;
if (!event.token.trim()) {
log(
`feishu[${account.accountId}]: rejected card action from ${event.operator.open_id}: missing token`,
);
return;
}
const decoded = decodeFeishuCardAction({ event });
const claimedToken = beginFeishuCardActionToken({
token: event.token,
accountId: account.accountId,
});
if (!claimedToken) {
log(`feishu[${account.accountId}]: skipping duplicate card action token ${event.token}`);
return;
}
try {
if (decoded.kind === "invalid") {
log(
`feishu[${account.accountId}]: rejected card action from ${event.operator.open_id}: ${decoded.reason}`,
);
await sendInvalidInteractionNotice({
cfg,
event,
reason: decoded.reason,
accountId,
});
completeFeishuCardActionToken({ token: event.token, accountId: account.accountId });
return;
}
if (decoded.kind === "structured") {
const { envelope } = decoded;
log(
`feishu[${account.accountId}]: handling structured card action ${envelope.a} from ${event.operator.open_id}`,
);
if (envelope.a === FEISHU_APPROVAL_REQUEST_ACTION) {
const command = typeof envelope.m?.command === "string" ? envelope.m.command.trim() : "";
if (!command) {
await sendInvalidInteractionNotice({
cfg,
event,
reason: "malformed",
accountId,
});
completeFeishuCardActionToken({ token: event.token, accountId: account.accountId });
return;
}
const prompt =
typeof envelope.m?.prompt === "string" && envelope.m.prompt.trim()
? envelope.m.prompt
: `Run \`${command}\` in this Feishu conversation?`;
await sendCardFeishu({
cfg,
to: resolveCallbackTarget(event),
card: createApprovalCard({
operatorOpenId: event.operator.open_id,
chatId: event.context.chat_id || undefined,
command,
prompt,
sessionKey: envelope.c?.s,
expiresAt: Date.now() + FEISHU_APPROVAL_CARD_TTL_MS,
chatType: await resolveCardActionChatType({
event,
account,
chatType: envelope.c?.t,
log,
}),
confirmLabel: command === "/reset" ? "Reset" : "Confirm",
}),
accountId,
});
completeFeishuCardActionToken({ token: event.token, accountId: account.accountId });
return;
}
if (envelope.a === FEISHU_APPROVAL_CANCEL_ACTION) {
await sendMessageFeishu({
cfg,
to: resolveCallbackTarget(event),
text: "Cancelled.",
accountId,
});
completeFeishuCardActionToken({ token: event.token, accountId: account.accountId });
return;
}
if (envelope.a === FEISHU_APPROVAL_CONFIRM_ACTION || envelope.k === "quick") {
const command = envelope.q?.trim();
if (!command) {
await sendInvalidInteractionNotice({
cfg,
event,
reason: "malformed",
accountId,
});
completeFeishuCardActionToken({ token: event.token, accountId: account.accountId });
return;
}
await dispatchSyntheticCommand({
cfg,
event,
command,
account,
botOpenId: params.botOpenId,
runtime,
accountId,
chatType: envelope.c?.t,
});
completeFeishuCardActionToken({ token: event.token, accountId: account.accountId });
return;
}
await sendInvalidInteractionNotice({
cfg,
event,
reason: "malformed",
accountId,
});
completeFeishuCardActionToken({ token: event.token, accountId: account.accountId });
return;
}
const content = buildFeishuCardActionTextFallback(event);
log(
`feishu[${account.accountId}]: handling card action from ${event.operator.open_id}: ${content}`,
);
await dispatchSyntheticCommand({
cfg,
event,
command: content,
account,
botOpenId: params.botOpenId,
runtime,
accountId,
});
completeFeishuCardActionToken({ token: event.token, accountId: account.accountId });
} catch (err) {
if (err instanceof FeishuRetryableCardActionError) {
releaseFeishuCardActionToken({ token: event.token, accountId: account.accountId });
} else {
completeFeishuCardActionToken({ token: event.token, accountId: account.accountId });
}
throw err;
}
}

View file

@ -0,0 +1,129 @@
import { describe, expect, it } from "vitest";
import {
buildFeishuCardActionTextFallback,
createFeishuCardInteractionEnvelope,
decodeFeishuCardAction,
} from "./card-interaction.js";
describe("feishu card interaction decoder", () => {
it("decodes valid structured payloads", () => {
const result = decodeFeishuCardAction({
now: 1_700_000_000_000,
event: {
operator: { open_id: "u123" },
context: { chat_id: "chat1" },
action: {
value: createFeishuCardInteractionEnvelope({
k: "quick",
a: "feishu.quick_actions.help",
q: "/help",
c: { u: "u123", h: "chat1", t: "group", e: 1_700_000_060_000 },
}),
},
},
});
expect(result).toEqual(
expect.objectContaining({
kind: "structured",
envelope: expect.objectContaining({
q: "/help",
}),
}),
);
});
it("falls back for legacy text-like payloads", () => {
const result = decodeFeishuCardAction({
event: {
operator: { open_id: "u123" },
context: { chat_id: "chat1" },
action: { value: { text: "/ping" } },
},
});
expect(result).toEqual({ kind: "legacy", text: "/ping" });
expect(
buildFeishuCardActionTextFallback({
operator: { open_id: "u123" },
context: { chat_id: "chat1" },
action: { value: { command: "/new" } },
}),
).toBe("/new");
});
it("rejects malformed structured payloads", () => {
const result = decodeFeishuCardAction({
event: {
operator: { open_id: "u123" },
context: { chat_id: "chat1" },
action: {
value: {
oc: "ocf1",
k: "quick",
a: "broken",
m: { bad: { nested: true } },
},
},
},
});
expect(result).toEqual({ kind: "invalid", reason: "malformed" });
});
it("rejects stale payloads", () => {
const result = decodeFeishuCardAction({
now: 100,
event: {
operator: { open_id: "u123" },
context: { chat_id: "chat1" },
action: {
value: createFeishuCardInteractionEnvelope({
k: "button",
a: "stale",
c: { e: 99, t: "group" },
}),
},
},
});
expect(result).toEqual({ kind: "invalid", reason: "stale" });
});
it("rejects wrong-conversation payloads when chat context is enforced", () => {
const result = decodeFeishuCardAction({
event: {
operator: { open_id: "u123" },
context: { chat_id: "chat2" },
action: {
value: createFeishuCardInteractionEnvelope({
k: "button",
a: "scoped",
c: { u: "u123", h: "chat1", t: "group", e: Date.now() + 60_000 },
}),
},
},
});
expect(result).toEqual({ kind: "invalid", reason: "wrong_conversation" });
});
it("rejects malformed chat-type context", () => {
const result = decodeFeishuCardAction({
event: {
operator: { open_id: "u123" },
context: { chat_id: "chat1" },
action: {
value: {
oc: "ocf1",
k: "button",
a: "bad",
c: { t: "private" },
},
},
},
});
expect(result).toEqual({ kind: "invalid", reason: "malformed" });
});
});

View file

@ -0,0 +1,166 @@
import { isRecord } from "./comment-shared.js";
export const FEISHU_CARD_INTERACTION_VERSION = "ocf1";
export type FeishuCardInteractionKind = "button" | "quick" | "meta";
export type FeishuCardInteractionReason =
| "malformed"
| "stale"
| "wrong_user"
| "wrong_conversation";
export type FeishuCardInteractionMetadata = Record<
string,
string | number | boolean | null | undefined
>;
export type FeishuCardInteractionEnvelope = {
oc: typeof FEISHU_CARD_INTERACTION_VERSION;
k: FeishuCardInteractionKind;
a: string;
q?: string;
m?: FeishuCardInteractionMetadata;
c?: {
u?: string;
h?: string;
s?: string;
e?: number;
t?: "p2p" | "group";
};
};
export type FeishuCardActionEventLike = {
operator: {
open_id?: string;
};
action: {
value: unknown;
};
context: {
chat_id?: string;
};
};
export type DecodedFeishuCardAction =
| {
kind: "structured";
envelope: FeishuCardInteractionEnvelope;
}
| {
kind: "legacy";
text: string;
}
| {
kind: "invalid";
reason: FeishuCardInteractionReason;
};
function isInteractionKind(value: unknown): value is FeishuCardInteractionKind {
return value === "button" || value === "quick" || value === "meta";
}
function isMetadataValue(value: unknown): value is string | number | boolean | null | undefined {
return (
value === null ||
value === undefined ||
typeof value === "string" ||
typeof value === "number" ||
typeof value === "boolean"
);
}
export function createFeishuCardInteractionEnvelope(
envelope: Omit<FeishuCardInteractionEnvelope, "oc">,
): FeishuCardInteractionEnvelope {
return {
oc: FEISHU_CARD_INTERACTION_VERSION,
...envelope,
};
}
export function buildFeishuCardActionTextFallback(event: FeishuCardActionEventLike): string {
const actionValue = event.action.value;
if (isRecord(actionValue)) {
if (typeof actionValue.text === "string") {
return actionValue.text;
}
if (typeof actionValue.command === "string") {
return actionValue.command;
}
return JSON.stringify(actionValue);
}
return String(actionValue);
}
export function decodeFeishuCardAction(params: {
event: FeishuCardActionEventLike;
now?: number;
}): DecodedFeishuCardAction {
const { event, now = Date.now() } = params;
const actionValue = event.action.value;
if (!isRecord(actionValue) || actionValue.oc !== FEISHU_CARD_INTERACTION_VERSION) {
return {
kind: "legacy",
text: buildFeishuCardActionTextFallback(event),
};
}
if (!isInteractionKind(actionValue.k) || typeof actionValue.a !== "string" || !actionValue.a) {
return { kind: "invalid", reason: "malformed" };
}
if (actionValue.q !== undefined && typeof actionValue.q !== "string") {
return { kind: "invalid", reason: "malformed" };
}
if (actionValue.m !== undefined) {
if (!isRecord(actionValue.m)) {
return { kind: "invalid", reason: "malformed" };
}
for (const value of Object.values(actionValue.m)) {
if (!isMetadataValue(value)) {
return { kind: "invalid", reason: "malformed" };
}
}
}
if (actionValue.c !== undefined) {
if (!isRecord(actionValue.c)) {
return { kind: "invalid", reason: "malformed" };
}
if (actionValue.c.u !== undefined && typeof actionValue.c.u !== "string") {
return { kind: "invalid", reason: "malformed" };
}
if (actionValue.c.h !== undefined && typeof actionValue.c.h !== "string") {
return { kind: "invalid", reason: "malformed" };
}
if (actionValue.c.s !== undefined && typeof actionValue.c.s !== "string") {
return { kind: "invalid", reason: "malformed" };
}
if (actionValue.c.e !== undefined && !Number.isFinite(actionValue.c.e)) {
return { kind: "invalid", reason: "malformed" };
}
if (actionValue.c.t !== undefined && actionValue.c.t !== "p2p" && actionValue.c.t !== "group") {
return { kind: "invalid", reason: "malformed" };
}
if (typeof actionValue.c.e === "number" && actionValue.c.e < now) {
return { kind: "invalid", reason: "stale" };
}
const expectedUser = actionValue.c.u?.trim();
if (expectedUser && expectedUser !== (event.operator.open_id ?? "").trim()) {
return { kind: "invalid", reason: "wrong_user" };
}
const expectedChat = actionValue.c.h?.trim();
if (expectedChat && expectedChat !== (event.context.chat_id ?? "").trim()) {
return { kind: "invalid", reason: "wrong_conversation" };
}
}
return {
kind: "structured",
envelope: actionValue as FeishuCardInteractionEnvelope,
};
}

View file

@ -0,0 +1,65 @@
import { createFeishuCardInteractionEnvelope } from "./card-interaction.js";
import { buildFeishuCardButton, buildFeishuCardInteractionContext } from "./card-ux-shared.js";
export const FEISHU_APPROVAL_REQUEST_ACTION = "feishu.quick_actions.request_approval";
export const FEISHU_APPROVAL_CONFIRM_ACTION = "feishu.approval.confirm";
export const FEISHU_APPROVAL_CANCEL_ACTION = "feishu.approval.cancel";
export function createApprovalCard(params: {
operatorOpenId: string;
chatId?: string;
command: string;
prompt: string;
expiresAt: number;
chatType?: "p2p" | "group";
sessionKey?: string;
confirmLabel?: string;
cancelLabel?: string;
}): Record<string, unknown> {
const context = buildFeishuCardInteractionContext(params);
return {
schema: "2.0",
config: {
width_mode: "fill",
},
header: {
title: {
tag: "plain_text",
content: "Confirm action",
},
template: "orange",
},
body: {
elements: [
{
tag: "markdown",
content: params.prompt,
},
{
tag: "action",
actions: [
buildFeishuCardButton({
label: params.confirmLabel ?? "Confirm",
type: "primary",
value: createFeishuCardInteractionEnvelope({
k: "quick",
a: FEISHU_APPROVAL_CONFIRM_ACTION,
q: params.command,
c: context,
}),
}),
buildFeishuCardButton({
label: params.cancelLabel ?? "Cancel",
value: createFeishuCardInteractionEnvelope({
k: "button",
a: FEISHU_APPROVAL_CANCEL_ACTION,
c: context,
}),
}),
],
},
],
},
};
}

View file

@ -0,0 +1,129 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { createRuntimeEnv } from "../../../test/helpers/plugins/runtime-env.js";
import type { ClawdbotConfig, RuntimeEnv } from "../runtime-api.js";
import {
createQuickActionLauncherCard,
isFeishuQuickActionMenuEventKey,
maybeHandleFeishuQuickActionMenu,
} from "./card-ux-launcher.js";
const sendCardFeishuMock = vi.hoisted(() => vi.fn());
vi.mock("./send.js", () => ({
sendCardFeishu: sendCardFeishuMock,
}));
describe("feishu quick-action launcher", () => {
const cfg: ClawdbotConfig = {};
beforeEach(() => {
vi.clearAllMocks();
});
it("recognizes the quick-actions bot menu key", () => {
expect(isFeishuQuickActionMenuEventKey("quick-actions")).toBe(true);
expect(isFeishuQuickActionMenuEventKey("other")).toBe(false);
});
it("builds a launcher card with interactive actions", () => {
const card = createQuickActionLauncherCard({
operatorOpenId: "u123",
chatId: "chat1",
expiresAt: 123,
sessionKey: "agent:codex:feishu:chat:chat1",
}) as {
config: {
width_mode?: string;
enable_forward?: boolean;
wide_screen_mode?: boolean;
};
body: {
elements: Array<{
tag: string;
actions?: Array<{ value?: { oc?: string; c?: { s?: string; t?: string } } }>;
}>;
};
};
expect(card.config.width_mode).toBe("fill");
expect(card.config.enable_forward).toBeUndefined();
expect(card.config.wide_screen_mode).toBeUndefined();
const actionBlock = card.body.elements.find((entry) => entry.tag === "action");
expect(actionBlock?.actions).toHaveLength(3);
expect(actionBlock?.actions?.[0]?.value?.oc).toBe("ocf1");
expect(actionBlock?.actions?.[0]?.value?.c?.s).toBe("agent:codex:feishu:chat:chat1");
expect(actionBlock?.actions?.[0]?.value?.c?.t).toBeUndefined();
});
it("opens the launcher from a supported bot menu event", async () => {
sendCardFeishuMock.mockResolvedValue({ messageId: "m1", chatId: "c1" });
const handled = await maybeHandleFeishuQuickActionMenu({
cfg,
eventKey: "quick-actions",
operatorOpenId: "u123",
accountId: "main",
now: 100,
});
expect(handled).toBe(true);
expect(sendCardFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
to: "user:u123",
accountId: "main",
card: expect.objectContaining({
config: expect.objectContaining({
width_mode: "fill",
}),
body: expect.objectContaining({
elements: expect.arrayContaining([
expect.objectContaining({
tag: "action",
actions: expect.arrayContaining([
expect.objectContaining({
value: expect.objectContaining({
c: expect.objectContaining({
t: "p2p",
}),
}),
}),
]),
}),
]),
}),
}),
}),
);
const firstSendArg = (sendCardFeishuMock.mock.calls as unknown[][]).at(0)?.[0] as
| {
card?: {
config?: {
width_mode?: string;
wide_screen_mode?: boolean;
enable_forward?: boolean;
};
};
}
| undefined;
const sentCard = firstSendArg?.card;
expect(sentCard).toBeDefined();
expect(sentCard?.config?.wide_screen_mode).toBeUndefined();
expect(sentCard?.config?.enable_forward).toBeUndefined();
});
it("falls back to legacy menu handling when launcher send fails", async () => {
sendCardFeishuMock.mockRejectedValueOnce(new Error("network"));
const runtime: RuntimeEnv = createRuntimeEnv();
const handled = await maybeHandleFeishuQuickActionMenu({
cfg,
eventKey: "quick-actions",
operatorOpenId: "u123",
accountId: "main",
runtime,
now: 100,
});
expect(handled).toBe(false);
});
});

View file

@ -0,0 +1,121 @@
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/text-runtime";
import type { ClawdbotConfig, RuntimeEnv } from "../runtime-api.js";
import { createFeishuCardInteractionEnvelope } from "./card-interaction.js";
import { FEISHU_APPROVAL_REQUEST_ACTION } from "./card-ux-approval.js";
import { buildFeishuCardButton, buildFeishuCardInteractionContext } from "./card-ux-shared.js";
import { sendCardFeishu } from "./send.js";
export const FEISHU_QUICK_ACTION_CARD_TTL_MS = 10 * 60_000;
const QUICK_ACTION_MENU_KEYS = new Set(["quick-actions", "quick_actions", "launcher"]);
export function isFeishuQuickActionMenuEventKey(eventKey: string): boolean {
return QUICK_ACTION_MENU_KEYS.has(normalizeOptionalLowercaseString(eventKey) ?? "");
}
export function createQuickActionLauncherCard(params: {
operatorOpenId: string;
chatId?: string;
expiresAt: number;
chatType?: "p2p" | "group";
sessionKey?: string;
}): Record<string, unknown> {
const context = buildFeishuCardInteractionContext(params);
return {
schema: "2.0",
config: {
width_mode: "fill",
},
header: {
title: {
tag: "plain_text",
content: "Quick actions",
},
template: "indigo",
},
body: {
elements: [
{
tag: "markdown",
content: "Run common actions without typing raw commands.",
},
{
tag: "action",
actions: [
buildFeishuCardButton({
label: "Help",
value: createFeishuCardInteractionEnvelope({
k: "quick",
a: "feishu.quick_actions.help",
q: "/help",
c: context,
}),
}),
buildFeishuCardButton({
label: "New session",
type: "primary",
value: createFeishuCardInteractionEnvelope({
k: "meta",
a: FEISHU_APPROVAL_REQUEST_ACTION,
m: {
command: "/new",
prompt: "Start a fresh session? This will reset the current chat context.",
},
c: context,
}),
}),
buildFeishuCardButton({
label: "Reset",
type: "danger",
value: createFeishuCardInteractionEnvelope({
k: "meta",
a: FEISHU_APPROVAL_REQUEST_ACTION,
m: {
command: "/reset",
prompt: "Reset this session now? Any active conversation state will be cleared.",
},
c: context,
}),
}),
],
},
],
},
};
}
export async function maybeHandleFeishuQuickActionMenu(params: {
cfg: ClawdbotConfig;
eventKey: string;
operatorOpenId: string;
runtime?: RuntimeEnv;
accountId?: string;
now?: number;
}): Promise<boolean> {
if (!isFeishuQuickActionMenuEventKey(params.eventKey)) {
return false;
}
const expiresAt = (params.now ?? Date.now()) + FEISHU_QUICK_ACTION_CARD_TTL_MS;
try {
await sendCardFeishu({
cfg: params.cfg,
to: `user:${params.operatorOpenId}`,
card: createQuickActionLauncherCard({
operatorOpenId: params.operatorOpenId,
expiresAt,
chatType: "p2p",
}),
accountId: params.accountId,
});
} catch (err) {
params.runtime?.log?.(
`feishu[${params.accountId ?? "default"}]: failed to open quick-action launcher for ${params.operatorOpenId}: ${String(err)}`,
);
return false;
}
params.runtime?.log?.(
`feishu[${params.accountId ?? "default"}]: opened quick-action launcher for ${params.operatorOpenId}`,
);
return true;
}

View file

@ -0,0 +1,33 @@
import type { FeishuCardInteractionEnvelope } from "./card-interaction.js";
export function buildFeishuCardButton(params: {
label: string;
value: FeishuCardInteractionEnvelope;
type?: "default" | "primary" | "danger";
}) {
return {
tag: "button",
text: {
tag: "plain_text",
content: params.label,
},
type: params.type ?? "default",
value: params.value,
};
}
export function buildFeishuCardInteractionContext(params: {
operatorOpenId: string;
chatId?: string;
expiresAt: number;
chatType?: "p2p" | "group";
sessionKey?: string;
}) {
return {
u: params.operatorOpenId,
...(params.chatId ? { h: params.chatId } : {}),
...(params.sessionKey ? { s: params.sessionKey } : {}),
e: params.expiresAt,
...(params.chatType ? { t: params.chatType } : {}),
};
}

View file

@ -0,0 +1,16 @@
export type {
ChannelMessageActionName,
ChannelMeta,
ChannelPlugin,
ClawdbotConfig,
} from "../runtime-api.js";
export { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-resolution";
export { createActionGate } from "openclaw/plugin-sdk/channel-actions";
export { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-primitives";
export {
buildProbeChannelStatusSummary,
createDefaultChannelRuntimeState,
} from "openclaw/plugin-sdk/status-helpers";
export { PAIRING_APPROVED_MESSAGE } from "openclaw/plugin-sdk/channel-status";
export { chunkTextForOutbound } from "openclaw/plugin-sdk/text-chunking";

View file

@ -0,0 +1,47 @@
import {
getChatInfo as getChatInfoImpl,
getChatMembers as getChatMembersImpl,
getFeishuMemberInfo as getFeishuMemberInfoImpl,
} from "./chat.js";
import {
listFeishuDirectoryGroupsLive as listFeishuDirectoryGroupsLiveImpl,
listFeishuDirectoryPeersLive as listFeishuDirectoryPeersLiveImpl,
} from "./directory.js";
import { feishuOutbound as feishuOutboundImpl } from "./outbound.js";
import {
createPinFeishu as createPinFeishuImpl,
listPinsFeishu as listPinsFeishuImpl,
removePinFeishu as removePinFeishuImpl,
} from "./pins.js";
import { probeFeishu as probeFeishuImpl } from "./probe.js";
import {
addReactionFeishu as addReactionFeishuImpl,
listReactionsFeishu as listReactionsFeishuImpl,
removeReactionFeishu as removeReactionFeishuImpl,
} from "./reactions.js";
import {
editMessageFeishu as editMessageFeishuImpl,
getMessageFeishu as getMessageFeishuImpl,
sendCardFeishu as sendCardFeishuImpl,
sendMessageFeishu as sendMessageFeishuImpl,
} from "./send.js";
export const feishuChannelRuntime = {
listFeishuDirectoryGroupsLive: listFeishuDirectoryGroupsLiveImpl,
listFeishuDirectoryPeersLive: listFeishuDirectoryPeersLiveImpl,
feishuOutbound: { ...feishuOutboundImpl },
createPinFeishu: createPinFeishuImpl,
listPinsFeishu: listPinsFeishuImpl,
removePinFeishu: removePinFeishuImpl,
probeFeishu: probeFeishuImpl,
addReactionFeishu: addReactionFeishuImpl,
listReactionsFeishu: listReactionsFeishuImpl,
removeReactionFeishu: removeReactionFeishuImpl,
getChatInfo: getChatInfoImpl,
getChatMembers: getChatMembersImpl,
getFeishuMemberInfo: getFeishuMemberInfoImpl,
editMessageFeishu: editMessageFeishuImpl,
getMessageFeishu: getMessageFeishuImpl,
sendCardFeishu: sendCardFeishuImpl,
sendMessageFeishu: sendMessageFeishuImpl,
};

View file

@ -0,0 +1,945 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../runtime-api.js";
import { createFeishuCardInteractionEnvelope } from "./card-interaction.js";
import { feishuPlugin } from "./channel.js";
import { looksLikeFeishuId, normalizeFeishuTarget, resolveReceiveIdType } from "./targets.js";
const probeFeishuMock = vi.hoisted(() => vi.fn());
const createFeishuClientMock = vi.hoisted(() => vi.fn());
const addReactionFeishuMock = vi.hoisted(() => vi.fn());
const listReactionsFeishuMock = vi.hoisted(() => vi.fn());
const removeReactionFeishuMock = vi.hoisted(() => vi.fn());
const sendCardFeishuMock = vi.hoisted(() => vi.fn());
const sendMessageFeishuMock = vi.hoisted(() => vi.fn());
const getMessageFeishuMock = vi.hoisted(() => vi.fn());
const editMessageFeishuMock = vi.hoisted(() => vi.fn());
const createPinFeishuMock = vi.hoisted(() => vi.fn());
const listPinsFeishuMock = vi.hoisted(() => vi.fn());
const removePinFeishuMock = vi.hoisted(() => vi.fn());
const getChatInfoMock = vi.hoisted(() => vi.fn());
const getChatMembersMock = vi.hoisted(() => vi.fn());
const getFeishuMemberInfoMock = vi.hoisted(() => vi.fn());
const listFeishuDirectoryPeersLiveMock = vi.hoisted(() => vi.fn());
const listFeishuDirectoryGroupsLiveMock = vi.hoisted(() => vi.fn());
const feishuOutboundSendMediaMock = vi.hoisted(() => vi.fn());
vi.mock("./probe.js", () => ({
probeFeishu: probeFeishuMock,
}));
vi.mock("./client.js", () => ({
createFeishuClient: createFeishuClientMock,
}));
vi.mock("./channel.runtime.js", () => ({
feishuChannelRuntime: {
addReactionFeishu: addReactionFeishuMock,
createPinFeishu: createPinFeishuMock,
editMessageFeishu: editMessageFeishuMock,
getChatInfo: getChatInfoMock,
getChatMembers: getChatMembersMock,
getFeishuMemberInfo: getFeishuMemberInfoMock,
getMessageFeishu: getMessageFeishuMock,
listFeishuDirectoryGroupsLive: listFeishuDirectoryGroupsLiveMock,
listFeishuDirectoryPeersLive: listFeishuDirectoryPeersLiveMock,
listPinsFeishu: listPinsFeishuMock,
listReactionsFeishu: listReactionsFeishuMock,
probeFeishu: probeFeishuMock,
removePinFeishu: removePinFeishuMock,
removeReactionFeishu: removeReactionFeishuMock,
sendCardFeishu: sendCardFeishuMock,
sendMessageFeishu: sendMessageFeishuMock,
feishuOutbound: {
sendText: vi.fn(),
sendMedia: feishuOutboundSendMediaMock,
},
},
}));
vi.mock("../../../src/channels/plugins/bundled.js", () => ({
bundledChannelPlugins: [],
bundledChannelSetupPlugins: [],
}));
function getDescribedActions(cfg: OpenClawConfig, accountId?: string): string[] {
return [...(feishuPlugin.actions?.describeMessageTool?.({ cfg, accountId })?.actions ?? [])];
}
function createLegacyFeishuButtonCard(value: { command?: string; text?: string }) {
return {
schema: "2.0",
body: {
elements: [
{
tag: "action",
actions: [
{
tag: "button",
text: { tag: "plain_text", content: "Run /new" },
value,
},
],
},
],
},
};
}
async function expectLegacyFeishuCardPayloadRejected(cfg: OpenClawConfig, card: unknown) {
await expect(
feishuPlugin.actions?.handleAction?.({
action: "send",
params: { to: "chat:oc_group_1", card },
cfg,
accountId: undefined,
toolContext: {},
} as never),
).rejects.toThrow(
"Feishu card buttons that trigger text or commands must use structured interaction envelopes.",
);
expect(sendCardFeishuMock).not.toHaveBeenCalled();
}
describe("feishuPlugin.status.probeAccount", () => {
it("uses current account credentials for multi-account config", async () => {
const cfg = {
channels: {
feishu: {
enabled: true,
accounts: {
main: {
appId: "cli_main",
appSecret: "secret_main",
enabled: true,
},
},
},
},
} as OpenClawConfig;
const account = feishuPlugin.config.resolveAccount(cfg, "main");
probeFeishuMock.mockResolvedValueOnce({ ok: true, appId: "cli_main" });
const result = await feishuPlugin.status?.probeAccount?.({
account,
timeoutMs: 1_000,
cfg,
});
expect(probeFeishuMock).toHaveBeenCalledTimes(1);
expect(probeFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
accountId: "main",
appId: "cli_main",
appSecret: "secret_main",
}),
);
expect(result).toMatchObject({ ok: true, appId: "cli_main" });
});
});
describe("feishuPlugin.pairing.notifyApproval", () => {
beforeEach(() => {
sendMessageFeishuMock.mockReset();
sendMessageFeishuMock.mockResolvedValue({ messageId: "pairing-msg", chatId: "ou_user" });
});
it("preserves accountId when sending pairing approvals", async () => {
const cfg = {
channels: {
feishu: {
accounts: {
work: {
appId: "cli_work",
appSecret: "secret_work",
enabled: true,
},
},
},
},
} as OpenClawConfig;
await feishuPlugin.pairing?.notifyApproval?.({
cfg,
id: "ou_user",
accountId: "work",
});
expect(sendMessageFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
cfg,
to: "ou_user",
accountId: "work",
}),
);
});
});
describe("feishuPlugin messaging", () => {
it("owns sender/topic session inheritance candidates", () => {
expect(
feishuPlugin.messaging?.resolveSessionConversation?.({
kind: "group",
rawId: "oc_group_chat:topic:om_topic_root:sender:ou_topic_user",
}),
).toEqual({
id: "oc_group_chat:topic:om_topic_root:sender:ou_topic_user",
baseConversationId: "oc_group_chat",
parentConversationCandidates: ["oc_group_chat:topic:om_topic_root", "oc_group_chat"],
});
expect(
feishuPlugin.messaging?.resolveSessionConversation?.({
kind: "group",
rawId: "oc_group_chat:topic:om_topic_root",
}),
).toEqual({
id: "oc_group_chat:topic:om_topic_root",
baseConversationId: "oc_group_chat",
parentConversationCandidates: ["oc_group_chat"],
});
expect(
feishuPlugin.messaging?.resolveSessionConversation?.({
kind: "group",
rawId: "oc_group_chat:Topic:om_topic_root:Sender:ou_topic_user",
}),
).toEqual({
id: "oc_group_chat:topic:om_topic_root:sender:ou_topic_user",
baseConversationId: "oc_group_chat",
parentConversationCandidates: ["oc_group_chat:topic:om_topic_root", "oc_group_chat"],
});
});
});
describe("feishuPlugin actions", () => {
const cfg = {
channels: {
feishu: {
enabled: true,
appId: "cli_main",
appSecret: "secret_main",
actions: {
reactions: true,
},
},
},
} as OpenClawConfig;
beforeEach(() => {
vi.clearAllMocks();
createFeishuClientMock.mockReturnValue({ tag: "client" });
});
it("advertises the expanded Feishu action surface", () => {
expect(getDescribedActions(cfg)).toEqual([
"send",
"read",
"edit",
"thread-reply",
"pin",
"list-pins",
"unpin",
"member-info",
"channel-info",
"channel-list",
"react",
"reactions",
]);
});
it("does not advertise reactions when disabled via actions config", () => {
const disabledCfg = {
channels: {
feishu: {
enabled: true,
appId: "cli_main",
appSecret: "secret_main",
actions: {
reactions: false,
},
},
},
} as OpenClawConfig;
expect(getDescribedActions(disabledCfg)).toEqual([
"send",
"read",
"edit",
"thread-reply",
"pin",
"list-pins",
"unpin",
"member-info",
"channel-info",
"channel-list",
]);
});
it("honors the selected Feishu account during discovery", () => {
const cfg = {
channels: {
feishu: {
enabled: true,
actions: { reactions: false },
accounts: {
default: {
enabled: true,
appId: "cli_main",
appSecret: "secret_main",
actions: { reactions: false },
},
work: {
enabled: true,
appId: "cli_work",
appSecret: "secret_work",
actions: { reactions: true },
},
},
},
},
} as OpenClawConfig;
expect(getDescribedActions(cfg, "default")).toEqual([
"send",
"read",
"edit",
"thread-reply",
"pin",
"list-pins",
"unpin",
"member-info",
"channel-info",
"channel-list",
]);
expect(getDescribedActions(cfg, "work")).toEqual([
"send",
"read",
"edit",
"thread-reply",
"pin",
"list-pins",
"unpin",
"member-info",
"channel-info",
"channel-list",
"react",
"reactions",
]);
});
it("sends text messages", async () => {
sendMessageFeishuMock.mockResolvedValueOnce({ messageId: "om_sent", chatId: "oc_group_1" });
const result = await feishuPlugin.actions?.handleAction?.({
action: "send",
params: { to: "chat:oc_group_1", message: "hello" },
cfg,
accountId: undefined,
toolContext: {},
} as never);
expect(sendMessageFeishuMock).toHaveBeenCalledWith({
cfg,
to: "chat:oc_group_1",
text: "hello",
accountId: undefined,
replyToMessageId: undefined,
replyInThread: false,
});
expect(result?.details).toMatchObject({ ok: true, messageId: "om_sent", chatId: "oc_group_1" });
});
it("sends card messages", async () => {
sendCardFeishuMock.mockResolvedValueOnce({ messageId: "om_card", chatId: "oc_group_1" });
const result = await feishuPlugin.actions?.handleAction?.({
action: "send",
params: { to: "chat:oc_group_1", card: { schema: "2.0" } },
cfg,
accountId: undefined,
toolContext: {},
} as never);
expect(sendCardFeishuMock).toHaveBeenCalledWith({
cfg,
to: "chat:oc_group_1",
card: { schema: "2.0" },
accountId: undefined,
replyToMessageId: undefined,
replyInThread: false,
});
expect(result?.details).toMatchObject({ ok: true, messageId: "om_card", chatId: "oc_group_1" });
});
it("allows structured card button payloads", async () => {
sendCardFeishuMock.mockResolvedValueOnce({ messageId: "om_card", chatId: "oc_group_1" });
const card = {
schema: "2.0",
body: {
elements: [
{
tag: "action",
actions: [
{
tag: "button",
text: { tag: "plain_text", content: "Run /new" },
value: createFeishuCardInteractionEnvelope({
k: "quick",
a: "feishu.quick_actions.help",
q: "/help",
c: { u: "u123", h: "oc_group_1", t: "group", e: Date.now() + 60_000 },
}),
},
],
},
],
},
};
await feishuPlugin.actions?.handleAction?.({
action: "send",
params: { to: "chat:oc_group_1", card },
cfg,
accountId: undefined,
toolContext: {},
} as never);
expect(sendCardFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
card,
}),
);
});
it("rejects raw legacy card command payloads", async () => {
await expectLegacyFeishuCardPayloadRejected(
cfg,
createLegacyFeishuButtonCard({ command: "/new" }),
);
});
it("rejects raw legacy card text payloads", async () => {
await expectLegacyFeishuCardPayloadRejected(
cfg,
createLegacyFeishuButtonCard({ text: "/new" }),
);
});
it("allows non-button controls to carry text metadata values", async () => {
sendCardFeishuMock.mockResolvedValueOnce({ messageId: "om_card", chatId: "oc_group_1" });
const card = {
schema: "2.0",
body: {
elements: [
{
tag: "action",
actions: [
{
tag: "select_static",
placeholder: { tag: "plain_text", content: "Pick one" },
value: { text: "display-only metadata" },
options: [
{
text: { tag: "plain_text", content: "Option A" },
value: "a",
},
],
},
],
},
],
},
};
await feishuPlugin.actions?.handleAction?.({
action: "send",
params: { to: "chat:oc_group_1", card },
cfg,
accountId: undefined,
toolContext: {},
} as never);
expect(sendCardFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
card,
}),
);
});
it("sends media through the outbound adapter", async () => {
feishuOutboundSendMediaMock.mockResolvedValueOnce({
channel: "feishu",
messageId: "om_media",
details: { messageId: "om_media", chatId: "oc_group_1" },
});
const result = await feishuPlugin.actions?.handleAction?.({
action: "send",
params: {
to: "chat:oc_group_1",
message: "test",
media: "/tmp/image.png",
},
cfg,
accountId: undefined,
toolContext: {},
mediaLocalRoots: ["/tmp"],
} as never);
expect(feishuOutboundSendMediaMock).toHaveBeenCalledWith({
cfg,
to: "chat:oc_group_1",
text: "test",
mediaUrl: "/tmp/image.png",
accountId: undefined,
mediaLocalRoots: ["/tmp"],
replyToId: undefined,
});
expect(result?.details).toMatchObject({ messageId: "om_media" });
});
it("reads messages", async () => {
getMessageFeishuMock.mockResolvedValueOnce({
messageId: "om_1",
content: "hello",
contentType: "text",
});
const result = await feishuPlugin.actions?.handleAction?.({
action: "read",
params: { messageId: "om_1" },
cfg,
accountId: undefined,
} as never);
expect(getMessageFeishuMock).toHaveBeenCalledWith({
cfg,
messageId: "om_1",
accountId: undefined,
});
expect(result?.details).toMatchObject({
ok: true,
message: expect.objectContaining({ messageId: "om_1", content: "hello" }),
});
});
it("returns an error result when message reads fail", async () => {
getMessageFeishuMock.mockResolvedValueOnce(null);
const result = await feishuPlugin.actions?.handleAction?.({
action: "read",
params: { messageId: "om_missing" },
cfg,
accountId: undefined,
} as never);
expect((result as { isError?: boolean } | undefined)?.isError).toBe(true);
expect(result?.details).toEqual({
error: "Feishu read failed or message not found: om_missing",
});
});
it("edits messages", async () => {
editMessageFeishuMock.mockResolvedValueOnce({ messageId: "om_2", contentType: "post" });
const result = await feishuPlugin.actions?.handleAction?.({
action: "edit",
params: { messageId: "om_2", text: "updated" },
cfg,
accountId: undefined,
} as never);
expect(editMessageFeishuMock).toHaveBeenCalledWith({
cfg,
messageId: "om_2",
text: "updated",
card: undefined,
accountId: undefined,
});
expect(result?.details).toMatchObject({ ok: true, messageId: "om_2", contentType: "post" });
});
it("sends explicit thread replies with reply_in_thread semantics", async () => {
sendMessageFeishuMock.mockResolvedValueOnce({ messageId: "om_reply", chatId: "oc_group_1" });
const result = await feishuPlugin.actions?.handleAction?.({
action: "thread-reply",
params: { to: "chat:oc_group_1", messageId: "om_parent", text: "reply body" },
cfg,
accountId: undefined,
toolContext: {},
} as never);
expect(sendMessageFeishuMock).toHaveBeenCalledWith({
cfg,
to: "chat:oc_group_1",
text: "reply body",
accountId: undefined,
replyToMessageId: "om_parent",
replyInThread: true,
});
expect(result?.details).toMatchObject({
ok: true,
action: "thread-reply",
messageId: "om_reply",
});
});
it("creates pins", async () => {
createPinFeishuMock.mockResolvedValueOnce({ messageId: "om_pin", chatId: "oc_group_1" });
const result = await feishuPlugin.actions?.handleAction?.({
action: "pin",
params: { messageId: "om_pin" },
cfg,
accountId: undefined,
} as never);
expect(createPinFeishuMock).toHaveBeenCalledWith({
cfg,
messageId: "om_pin",
accountId: undefined,
});
expect(result?.details).toMatchObject({
ok: true,
pin: expect.objectContaining({ messageId: "om_pin" }),
});
});
it("lists pins", async () => {
listPinsFeishuMock.mockResolvedValueOnce({
chatId: "oc_group_1",
pins: [{ messageId: "om_pin" }],
hasMore: false,
pageToken: undefined,
});
const result = await feishuPlugin.actions?.handleAction?.({
action: "list-pins",
params: { chatId: "oc_group_1" },
cfg,
accountId: undefined,
toolContext: {},
} as never);
expect(listPinsFeishuMock).toHaveBeenCalledWith({
cfg,
chatId: "oc_group_1",
startTime: undefined,
endTime: undefined,
pageSize: undefined,
pageToken: undefined,
accountId: undefined,
});
expect(result?.details).toMatchObject({
ok: true,
pins: [expect.objectContaining({ messageId: "om_pin" })],
});
});
it("removes pins", async () => {
const result = await feishuPlugin.actions?.handleAction?.({
action: "unpin",
params: { messageId: "om_pin" },
cfg,
accountId: undefined,
} as never);
expect(removePinFeishuMock).toHaveBeenCalledWith({
cfg,
messageId: "om_pin",
accountId: undefined,
});
expect(result?.details).toMatchObject({ ok: true, messageId: "om_pin" });
});
it("fetches channel info", async () => {
getChatInfoMock.mockResolvedValueOnce({ chat_id: "oc_group_1", name: "Eng" });
const result = await feishuPlugin.actions?.handleAction?.({
action: "channel-info",
params: { chatId: "oc_group_1" },
cfg,
accountId: undefined,
toolContext: {},
} as never);
expect(createFeishuClientMock).toHaveBeenCalled();
expect(getChatInfoMock).toHaveBeenCalledWith({ tag: "client" }, "oc_group_1");
expect(result?.details).toMatchObject({
ok: true,
channel: expect.objectContaining({ chat_id: "oc_group_1", name: "Eng" }),
});
});
it("fetches member lists from a chat", async () => {
getChatMembersMock.mockResolvedValueOnce({
chat_id: "oc_group_1",
members: [{ member_id: "ou_1", name: "Alice" }],
has_more: false,
});
const result = await feishuPlugin.actions?.handleAction?.({
action: "member-info",
params: { chatId: "oc_group_1" },
cfg,
accountId: undefined,
toolContext: {},
} as never);
expect(getChatMembersMock).toHaveBeenCalledWith(
{ tag: "client" },
"oc_group_1",
undefined,
undefined,
"open_id",
);
expect(result?.details).toMatchObject({
ok: true,
members: [expect.objectContaining({ member_id: "ou_1", name: "Alice" })],
});
});
it("fetches individual member info", async () => {
getFeishuMemberInfoMock.mockResolvedValueOnce({ member_id: "ou_1", name: "Alice" });
const result = await feishuPlugin.actions?.handleAction?.({
action: "member-info",
params: { memberId: "ou_1" },
cfg,
accountId: undefined,
toolContext: {},
} as never);
expect(getFeishuMemberInfoMock).toHaveBeenCalledWith({ tag: "client" }, "ou_1", "open_id");
expect(result?.details).toMatchObject({
ok: true,
member: expect.objectContaining({ member_id: "ou_1", name: "Alice" }),
});
});
it("infers user_id lookups from the userId alias", async () => {
getFeishuMemberInfoMock.mockResolvedValueOnce({ member_id: "u_1", name: "Alice" });
await feishuPlugin.actions?.handleAction?.({
action: "member-info",
params: { userId: "u_1" },
cfg,
accountId: undefined,
toolContext: {},
} as never);
expect(getFeishuMemberInfoMock).toHaveBeenCalledWith({ tag: "client" }, "u_1", "user_id");
});
it("honors explicit open_id over alias heuristics", async () => {
getFeishuMemberInfoMock.mockResolvedValueOnce({ member_id: "u_1", name: "Alice" });
await feishuPlugin.actions?.handleAction?.({
action: "member-info",
params: { userId: "u_1", memberIdType: "open_id" },
cfg,
accountId: undefined,
toolContext: {},
} as never);
expect(getFeishuMemberInfoMock).toHaveBeenCalledWith({ tag: "client" }, "u_1", "open_id");
});
it("lists directory-backed peers and groups", async () => {
listFeishuDirectoryGroupsLiveMock.mockResolvedValueOnce([{ kind: "group", id: "oc_group_1" }]);
listFeishuDirectoryPeersLiveMock.mockResolvedValueOnce([{ kind: "user", id: "ou_1" }]);
const result = await feishuPlugin.actions?.handleAction?.({
action: "channel-list",
params: { query: "eng", limit: 5 },
cfg,
accountId: undefined,
} as never);
expect(listFeishuDirectoryGroupsLiveMock).toHaveBeenCalledWith({
cfg,
query: "eng",
limit: 5,
fallbackToStatic: false,
accountId: undefined,
});
expect(listFeishuDirectoryPeersLiveMock).toHaveBeenCalledWith({
cfg,
query: "eng",
limit: 5,
fallbackToStatic: false,
accountId: undefined,
});
expect(result?.details).toMatchObject({
ok: true,
groups: [expect.objectContaining({ id: "oc_group_1" })],
peers: [expect.objectContaining({ id: "ou_1" })],
});
});
it("fails channel-list when live discovery fails", async () => {
listFeishuDirectoryGroupsLiveMock.mockRejectedValueOnce(new Error("token expired"));
await expect(
feishuPlugin.actions?.handleAction?.({
action: "channel-list",
params: { query: "eng", limit: 5, scope: "groups" },
cfg,
accountId: undefined,
} as never),
).rejects.toThrow("token expired");
});
it("requires clearAll=true before removing all bot reactions", async () => {
await expect(
feishuPlugin.actions?.handleAction?.({
action: "react",
params: { messageId: "om_msg1" },
cfg,
accountId: undefined,
} as never),
).rejects.toThrow(
"Emoji is required to add a Feishu reaction. Set clearAll=true to remove all bot reactions.",
);
});
it("allows explicit clearAll=true when removing all bot reactions", async () => {
listReactionsFeishuMock.mockResolvedValueOnce([
{ reactionId: "r1", operatorType: "app" },
{ reactionId: "r2", operatorType: "app" },
]);
const result = await feishuPlugin.actions?.handleAction?.({
action: "react",
params: { messageId: "om_msg1", clearAll: true },
cfg,
accountId: undefined,
} as never);
expect(listReactionsFeishuMock).toHaveBeenCalledWith({
cfg,
messageId: "om_msg1",
accountId: undefined,
});
expect(removeReactionFeishuMock).toHaveBeenCalledTimes(2);
expect(result?.details).toMatchObject({ ok: true, removed: 2 });
});
it("fails for missing params on supported actions", async () => {
await expect(
feishuPlugin.actions?.handleAction?.({
action: "thread-reply",
params: { to: "chat:oc_group_1", message: "reply body" },
cfg,
accountId: undefined,
} as never),
).rejects.toThrow("Feishu thread-reply requires messageId.");
});
it("sends media-only messages without requiring card", async () => {
feishuOutboundSendMediaMock.mockResolvedValueOnce({
channel: "feishu",
messageId: "om_media_only",
details: { messageId: "om_media_only", chatId: "oc_group_1" },
});
const result = await feishuPlugin.actions?.handleAction?.({
action: "send",
params: {
to: "chat:oc_group_1",
media: "https://example.com/image.png",
},
cfg,
accountId: undefined,
toolContext: {},
mediaLocalRoots: [],
} as never);
expect(feishuOutboundSendMediaMock).toHaveBeenCalledWith(
expect.objectContaining({
to: "chat:oc_group_1",
mediaUrl: "https://example.com/image.png",
}),
);
expect(result?.details).toMatchObject({ messageId: "om_media_only" });
});
it("fails for unsupported action names", async () => {
await expect(
feishuPlugin.actions?.handleAction?.({
action: "search",
params: {},
cfg,
accountId: undefined,
} as never),
).rejects.toThrow('Unsupported Feishu action: "search"');
});
});
describe("resolveReceiveIdType", () => {
it("resolves chat IDs by oc_ prefix", () => {
expect(resolveReceiveIdType("oc_123")).toBe("chat_id");
});
it("resolves open IDs by ou_ prefix", () => {
expect(resolveReceiveIdType("ou_123")).toBe("open_id");
});
it("defaults unprefixed IDs to user_id", () => {
expect(resolveReceiveIdType("u_123")).toBe("user_id");
});
it("treats explicit group targets as chat_id", () => {
expect(resolveReceiveIdType("group:oc_123")).toBe("chat_id");
});
it("treats explicit channel targets as chat_id", () => {
expect(resolveReceiveIdType("channel:oc_123")).toBe("chat_id");
});
it("treats dm-prefixed open IDs as open_id", () => {
expect(resolveReceiveIdType("dm:ou_123")).toBe("open_id");
});
});
describe("normalizeFeishuTarget", () => {
it("strips provider and user prefixes", () => {
expect(normalizeFeishuTarget("feishu:user:ou_123")).toBe("ou_123");
expect(normalizeFeishuTarget("lark:user:ou_123")).toBe("ou_123");
});
it("strips provider and chat prefixes", () => {
expect(normalizeFeishuTarget("feishu:chat:oc_123")).toBe("oc_123");
});
it("normalizes group/channel prefixes to chat ids", () => {
expect(normalizeFeishuTarget("group:oc_123")).toBe("oc_123");
expect(normalizeFeishuTarget("feishu:group:oc_123")).toBe("oc_123");
expect(normalizeFeishuTarget("channel:oc_456")).toBe("oc_456");
expect(normalizeFeishuTarget("lark:channel:oc_456")).toBe("oc_456");
});
it("accepts provider-prefixed raw ids", () => {
expect(normalizeFeishuTarget("feishu:ou_123")).toBe("ou_123");
});
it("strips provider and dm prefixes", () => {
expect(normalizeFeishuTarget("lark:dm:ou_123")).toBe("ou_123");
});
});
describe("looksLikeFeishuId", () => {
it("accepts provider-prefixed user targets", () => {
expect(looksLikeFeishuId("feishu:user:ou_123")).toBe(true);
});
it("accepts provider-prefixed chat targets", () => {
expect(looksLikeFeishuId("lark:chat:oc_123")).toBe(true);
});
it("accepts group/channel targets", () => {
expect(looksLikeFeishuId("feishu:group:oc_123")).toBe(true);
expect(looksLikeFeishuId("group:oc_123")).toBe(true);
expect(looksLikeFeishuId("channel:oc_456")).toBe(true);
});
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,25 @@
import { Type, type Static } from "@sinclair/typebox";
const CHAT_ACTION_VALUES = ["members", "info", "member_info"] as const;
const MEMBER_ID_TYPE_VALUES = ["open_id", "user_id", "union_id"] as const;
export const FeishuChatSchema = Type.Object({
action: Type.Unsafe<(typeof CHAT_ACTION_VALUES)[number]>({
type: "string",
enum: [...CHAT_ACTION_VALUES],
description: "Action to run: members | info | member_info",
}),
chat_id: Type.Optional(Type.String({ description: "Chat ID (from URL or event payload)" })),
member_id: Type.Optional(Type.String({ description: "Member ID for member_info lookups" })),
page_size: Type.Optional(Type.Number({ description: "Page size (1-100, default 50)" })),
page_token: Type.Optional(Type.String({ description: "Pagination token" })),
member_id_type: Type.Optional(
Type.Unsafe<(typeof MEMBER_ID_TYPE_VALUES)[number]>({
type: "string",
enum: [...MEMBER_ID_TYPE_VALUES],
description: "Member ID type (default: open_id)",
}),
),
});
export type FeishuChatParams = Static<typeof FeishuChatSchema>;

View file

@ -0,0 +1,145 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { createTestPluginApi } from "../../../test/helpers/plugins/plugin-api.js";
import type { OpenClawPluginApi, PluginRuntime } from "../runtime-api.js";
const createFeishuClientMock = vi.hoisted(() => vi.fn());
const chatGetMock = vi.hoisted(() => vi.fn());
const chatMembersGetMock = vi.hoisted(() => vi.fn());
const contactUserGetMock = vi.hoisted(() => vi.fn());
vi.mock("./client.js", () => ({
createFeishuClient: createFeishuClientMock,
}));
let registerFeishuChatTools: typeof import("./chat.js").registerFeishuChatTools;
function createFeishuToolRuntime(): PluginRuntime {
return {} as PluginRuntime;
}
describe("registerFeishuChatTools", () => {
function createChatToolApi(params: {
config: OpenClawPluginApi["config"];
registerTool: OpenClawPluginApi["registerTool"];
}): OpenClawPluginApi {
return createTestPluginApi({
id: "feishu-test",
name: "Feishu Test",
source: "local",
config: params.config,
runtime: createFeishuToolRuntime(),
logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
registerTool: params.registerTool,
});
}
beforeAll(async () => {
({ registerFeishuChatTools } = await import("./chat.js"));
});
beforeEach(() => {
vi.clearAllMocks();
createFeishuClientMock.mockReturnValue({
im: {
chat: { get: chatGetMock },
chatMembers: { get: chatMembersGetMock },
},
contact: {
user: { get: contactUserGetMock },
},
});
});
it("registers feishu_chat and handles info/members actions", async () => {
const registerTool = vi.fn();
registerFeishuChatTools(
createChatToolApi({
config: {
channels: {
feishu: {
enabled: true,
appId: "app_id",
appSecret: "app_secret", // pragma: allowlist secret
tools: { chat: true },
},
},
},
registerTool,
}),
);
expect(registerTool).toHaveBeenCalledTimes(1);
const tool = registerTool.mock.calls[0]?.[0];
expect(tool?.name).toBe("feishu_chat");
chatGetMock.mockResolvedValueOnce({
code: 0,
data: { name: "group name", user_count: 3 },
});
const infoResult = await tool.execute("tc_1", { action: "info", chat_id: "oc_1" });
expect(infoResult.details).toEqual(
expect.objectContaining({ chat_id: "oc_1", name: "group name", user_count: 3 }),
);
chatMembersGetMock.mockResolvedValueOnce({
code: 0,
data: {
has_more: false,
page_token: "",
items: [{ member_id: "ou_1", name: "member1", member_id_type: "open_id" }],
},
});
const membersResult = await tool.execute("tc_2", { action: "members", chat_id: "oc_1" });
expect(membersResult.details).toEqual(
expect.objectContaining({
chat_id: "oc_1",
members: [expect.objectContaining({ member_id: "ou_1", name: "member1" })],
}),
);
contactUserGetMock.mockResolvedValueOnce({
code: 0,
data: {
user: {
open_id: "ou_1",
name: "member1",
email: "member1@example.com",
department_ids: ["od_1"],
},
},
});
const memberInfoResult = await tool.execute("tc_3", {
action: "member_info",
member_id: "ou_1",
});
expect(memberInfoResult.details).toEqual(
expect.objectContaining({
member_id: "ou_1",
open_id: "ou_1",
name: "member1",
email: "member1@example.com",
department_ids: ["od_1"],
}),
);
});
it("skips registration when chat tool is disabled", () => {
const registerTool = vi.fn();
registerFeishuChatTools(
createChatToolApi({
config: {
channels: {
feishu: {
enabled: true,
appId: "app_id",
appSecret: "app_secret", // pragma: allowlist secret
tools: { chat: false },
},
},
},
registerTool,
}),
);
expect(registerTool).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,193 @@
import type * as Lark from "@larksuiteoapi/node-sdk";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import type { OpenClawPluginApi } from "../runtime-api.js";
import { listEnabledFeishuAccounts } from "./accounts.js";
import { FeishuChatSchema, type FeishuChatParams } from "./chat-schema.js";
import { createFeishuClient } from "./client.js";
import { resolveToolsConfig } from "./tools-config.js";
function json(data: unknown) {
return {
content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }],
details: data,
};
}
export async function getChatInfo(client: Lark.Client, chatId: string) {
const res = await client.im.chat.get({ path: { chat_id: chatId } });
if (res.code !== 0) {
throw new Error(res.msg);
}
const chat = res.data;
return {
chat_id: chatId,
name: chat?.name,
description: chat?.description,
owner_id: chat?.owner_id,
tenant_key: chat?.tenant_key,
user_count: chat?.user_count,
chat_mode: chat?.chat_mode,
chat_type: chat?.chat_type,
join_message_visibility: chat?.join_message_visibility,
leave_message_visibility: chat?.leave_message_visibility,
membership_approval: chat?.membership_approval,
moderation_permission: chat?.moderation_permission,
avatar: chat?.avatar,
};
}
export async function getChatMembers(
client: Lark.Client,
chatId: string,
pageSize?: number,
pageToken?: string,
memberIdType?: "open_id" | "user_id" | "union_id",
) {
const page_size = pageSize ? Math.max(1, Math.min(100, pageSize)) : 50;
const res = await client.im.chatMembers.get({
path: { chat_id: chatId },
params: {
page_size,
page_token: pageToken,
member_id_type: memberIdType ?? "open_id",
},
});
if (res.code !== 0) {
throw new Error(res.msg);
}
return {
chat_id: chatId,
has_more: res.data?.has_more,
page_token: res.data?.page_token,
members:
res.data?.items?.map((item) => ({
member_id: item.member_id,
name: item.name,
tenant_key: item.tenant_key,
member_id_type: item.member_id_type,
})) ?? [],
};
}
export async function getFeishuMemberInfo(
client: Lark.Client,
memberId: string,
memberIdType: "open_id" | "user_id" | "union_id" = "open_id",
) {
const res = await client.contact.user.get({
path: { user_id: memberId },
params: {
user_id_type: memberIdType,
department_id_type: "open_department_id",
},
});
if (res.code !== 0) {
throw new Error(res.msg);
}
const user = res.data?.user;
return {
member_id: memberId,
member_id_type: memberIdType,
open_id: user?.open_id,
user_id: user?.user_id,
union_id: user?.union_id,
name: user?.name,
en_name: user?.en_name,
nickname: user?.nickname,
email: user?.email,
enterprise_email: user?.enterprise_email,
mobile: user?.mobile,
mobile_visible: user?.mobile_visible,
status: user?.status,
avatar: user?.avatar,
department_ids: user?.department_ids,
department_path: user?.department_path,
leader_user_id: user?.leader_user_id,
city: user?.city,
country: user?.country,
work_station: user?.work_station,
join_time: user?.join_time,
is_tenant_manager: user?.is_tenant_manager,
employee_no: user?.employee_no,
employee_type: user?.employee_type,
description: user?.description,
job_title: user?.job_title,
geo: user?.geo,
};
}
export function registerFeishuChatTools(api: OpenClawPluginApi) {
if (!api.config) {
api.logger.debug?.("feishu_chat: No config available, skipping chat tools");
return;
}
const accounts = listEnabledFeishuAccounts(api.config);
if (accounts.length === 0) {
api.logger.debug?.("feishu_chat: No Feishu accounts configured, skipping chat tools");
return;
}
const firstAccount = accounts[0];
const toolsCfg = resolveToolsConfig(firstAccount.config.tools);
if (!toolsCfg.chat) {
api.logger.debug?.("feishu_chat: chat tool disabled in config");
return;
}
const getClient = () => createFeishuClient(firstAccount);
api.registerTool(
{
name: "feishu_chat",
label: "Feishu Chat",
description: "Feishu chat operations. Actions: members, info, member_info",
parameters: FeishuChatSchema,
async execute(_toolCallId, params) {
const p = params as FeishuChatParams;
try {
const client = getClient();
switch (p.action) {
case "members":
if (!p.chat_id) {
return json({ error: "chat_id is required for action members" });
}
return json(
await getChatMembers(
client,
p.chat_id,
p.page_size,
p.page_token,
p.member_id_type,
),
);
case "info":
if (!p.chat_id) {
return json({ error: "chat_id is required for action info" });
}
return json(await getChatInfo(client, p.chat_id));
case "member_info":
if (!p.member_id) {
return json({ error: "member_id is required for action member_info" });
}
return json(
await getFeishuMemberInfo(client, p.member_id, p.member_id_type ?? "open_id"),
);
default:
return json({ error: `Unknown action: ${String(p.action)}` });
}
} catch (err) {
return json({ error: formatErrorMessage(err) });
}
},
},
{ name: "feishu_chat" },
);
api.logger.debug?.("feishu_chat: Registered feishu_chat tool");
}

View file

@ -0,0 +1,390 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { FeishuConfigSchema } from "./config-schema.js";
import type { ResolvedFeishuAccount } from "./types.js";
type CreateFeishuClient = typeof import("./client.js").createFeishuClient;
type CreateFeishuWSClient = typeof import("./client.js").createFeishuWSClient;
type ClearClientCache = typeof import("./client.js").clearClientCache;
type SetFeishuClientRuntimeForTest = typeof import("./client.js").setFeishuClientRuntimeForTest;
const clientCtorMock = vi.hoisted(() =>
vi.fn(function clientCtor() {
return { connected: true };
}),
);
const wsClientCtorMock = vi.hoisted(() =>
vi.fn(function wsClientCtor() {
return { connected: true };
}),
);
const proxyAgentCtorMock = vi.hoisted(() =>
vi.fn(function proxyAgentCtor() {
return { proxied: true };
}),
);
const mockBaseHttpInstance = vi.hoisted(() => ({
request: vi.fn().mockResolvedValue({}),
get: vi.fn().mockResolvedValue({}),
post: vi.fn().mockResolvedValue({}),
put: vi.fn().mockResolvedValue({}),
patch: vi.fn().mockResolvedValue({}),
delete: vi.fn().mockResolvedValue({}),
head: vi.fn().mockResolvedValue({}),
options: vi.fn().mockResolvedValue({}),
}));
const proxyEnvKeys = ["https_proxy", "HTTPS_PROXY", "http_proxy", "HTTP_PROXY"] as const;
type ProxyEnvKey = (typeof proxyEnvKeys)[number];
const registerFeishuDocToolsMock = vi.hoisted(() => vi.fn());
const registerFeishuChatToolsMock = vi.hoisted(() => vi.fn());
const registerFeishuWikiToolsMock = vi.hoisted(() => vi.fn());
const registerFeishuDriveToolsMock = vi.hoisted(() => vi.fn());
const registerFeishuPermToolsMock = vi.hoisted(() => vi.fn());
const registerFeishuBitableToolsMock = vi.hoisted(() => vi.fn());
const feishuPluginMock = vi.hoisted(() => ({ id: "feishu-test-plugin" }));
const setFeishuRuntimeMock = vi.hoisted(() => vi.fn());
const registerFeishuSubagentHooksMock = vi.hoisted(() => vi.fn());
let createFeishuClient: CreateFeishuClient;
let createFeishuWSClient: CreateFeishuWSClient;
let clearClientCache: ClearClientCache;
let setFeishuClientRuntimeForTest: SetFeishuClientRuntimeForTest;
let FEISHU_HTTP_TIMEOUT_MS: number;
let FEISHU_HTTP_TIMEOUT_MAX_MS: number;
let FEISHU_HTTP_TIMEOUT_ENV_VAR: string;
let priorProxyEnv: Partial<Record<ProxyEnvKey, string | undefined>> = {};
let priorFeishuTimeoutEnv: string | undefined;
vi.mock("./channel.js", () => ({
feishuPlugin: feishuPluginMock,
}));
vi.mock("./docx.js", () => ({
registerFeishuDocTools: registerFeishuDocToolsMock,
}));
vi.mock("./chat.js", () => ({
registerFeishuChatTools: registerFeishuChatToolsMock,
}));
vi.mock("./wiki.js", () => ({
registerFeishuWikiTools: registerFeishuWikiToolsMock,
}));
vi.mock("./drive.js", () => ({
registerFeishuDriveTools: registerFeishuDriveToolsMock,
}));
vi.mock("./perm.js", () => ({
registerFeishuPermTools: registerFeishuPermToolsMock,
}));
vi.mock("./bitable.js", () => ({
registerFeishuBitableTools: registerFeishuBitableToolsMock,
}));
vi.mock("./runtime.js", () => ({
setFeishuRuntime: setFeishuRuntimeMock,
}));
vi.mock("./subagent-hooks.js", () => ({
registerFeishuSubagentHooks: registerFeishuSubagentHooksMock,
}));
vi.mock("../../../src/channels/plugins/bundled.js", () => ({
bundledChannelPlugins: [],
bundledChannelSetupPlugins: [],
}));
const baseAccount: ResolvedFeishuAccount = {
accountId: "main",
selectionSource: "explicit",
enabled: true,
configured: true,
appId: "app_123",
appSecret: "secret_123", // pragma: allowlist secret
domain: "feishu",
config: FeishuConfigSchema.parse({}),
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
type HttpInstanceLike = {
get: (url: string, options?: Record<string, unknown>) => Promise<unknown>;
post: (url: string, body?: unknown, options?: Record<string, unknown>) => Promise<unknown>;
};
function readCallOptions(
mock: { mock: { calls: unknown[][] } },
index = -1,
): Record<string, unknown> {
const call = index < 0 ? mock.mock.calls.at(index)?.[0] : mock.mock.calls[index]?.[0];
return isRecord(call) ? call : {};
}
function firstWsClientOptions(): { agent?: unknown } {
const options = readCallOptions(wsClientCtorMock, 0);
return { agent: options.agent };
}
beforeAll(async () => {
vi.doMock("@larksuiteoapi/node-sdk", () => ({
AppType: { SelfBuild: "self" },
Domain: { Feishu: "https://open.feishu.cn", Lark: "https://open.larksuite.com" },
LoggerLevel: { info: "info" },
Client: clientCtorMock,
WSClient: wsClientCtorMock,
EventDispatcher: vi.fn(),
defaultHttpInstance: mockBaseHttpInstance,
}));
vi.doMock("proxy-agent", () => ({
ProxyAgent: proxyAgentCtorMock,
}));
({
createFeishuClient,
createFeishuWSClient,
clearClientCache,
setFeishuClientRuntimeForTest,
FEISHU_HTTP_TIMEOUT_MS,
FEISHU_HTTP_TIMEOUT_MAX_MS,
FEISHU_HTTP_TIMEOUT_ENV_VAR,
} = await import("./client.js"));
});
beforeEach(() => {
priorProxyEnv = {};
priorFeishuTimeoutEnv = process.env[FEISHU_HTTP_TIMEOUT_ENV_VAR];
delete process.env[FEISHU_HTTP_TIMEOUT_ENV_VAR];
for (const key of proxyEnvKeys) {
priorProxyEnv[key] = process.env[key];
delete process.env[key];
}
vi.clearAllMocks();
clearClientCache();
setFeishuClientRuntimeForTest({
sdk: {
AppType: { SelfBuild: "self" } as never,
Domain: {
Feishu: "https://open.feishu.cn",
Lark: "https://open.larksuite.com",
} as never,
LoggerLevel: { info: "info" } as never,
Client: clientCtorMock as never,
WSClient: wsClientCtorMock as never,
EventDispatcher: vi.fn() as never,
defaultHttpInstance: mockBaseHttpInstance as never,
},
});
});
afterEach(() => {
for (const key of proxyEnvKeys) {
const value = priorProxyEnv[key];
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
if (priorFeishuTimeoutEnv === undefined) {
delete process.env[FEISHU_HTTP_TIMEOUT_ENV_VAR];
} else {
process.env[FEISHU_HTTP_TIMEOUT_ENV_VAR] = priorFeishuTimeoutEnv;
}
setFeishuClientRuntimeForTest();
});
describe("createFeishuClient HTTP timeout", () => {
const getLastClientHttpInstance = (): HttpInstanceLike | undefined => {
const httpInstance = readCallOptions(clientCtorMock).httpInstance;
if (
isRecord(httpInstance) &&
typeof httpInstance.get === "function" &&
typeof httpInstance.post === "function"
) {
return {
get: httpInstance.get as HttpInstanceLike["get"],
post: httpInstance.post as HttpInstanceLike["post"],
};
}
return undefined;
};
const expectGetCallTimeout = async (timeout: number) => {
const httpInstance = getLastClientHttpInstance();
expect(httpInstance).toBeDefined();
await httpInstance?.get("https://example.com/api");
expect(mockBaseHttpInstance.get).toHaveBeenCalledWith(
"https://example.com/api",
expect.objectContaining({ timeout }),
);
};
it("passes a custom httpInstance with default timeout to Lark.Client", () => {
createFeishuClient({ appId: "app_1", appSecret: "secret_1", accountId: "timeout-test" }); // pragma: allowlist secret
expect(readCallOptions(clientCtorMock).httpInstance).toBeDefined();
});
it("injects default timeout into HTTP request options", async () => {
createFeishuClient({ appId: "app_2", appSecret: "secret_2", accountId: "timeout-inject" }); // pragma: allowlist secret
const httpInstance = getLastClientHttpInstance();
expect(httpInstance).toBeDefined();
await httpInstance?.post(
"https://example.com/api",
{ data: 1 },
{ headers: { "X-Custom": "yes" } },
);
expect(mockBaseHttpInstance.post).toHaveBeenCalledWith(
"https://example.com/api",
{ data: 1 },
expect.objectContaining({ timeout: FEISHU_HTTP_TIMEOUT_MS, headers: { "X-Custom": "yes" } }),
);
});
it("allows explicit timeout override per-request", async () => {
createFeishuClient({ appId: "app_3", appSecret: "secret_3", accountId: "timeout-override" }); // pragma: allowlist secret
const httpInstance = getLastClientHttpInstance();
expect(httpInstance).toBeDefined();
await httpInstance?.get("https://example.com/api", { timeout: 5_000 });
expect(mockBaseHttpInstance.get).toHaveBeenCalledWith(
"https://example.com/api",
expect.objectContaining({ timeout: 5_000 }),
);
});
it("uses config-configured default timeout when provided", async () => {
createFeishuClient({
appId: "app_4",
appSecret: "secret_4", // pragma: allowlist secret
accountId: "timeout-config",
config: { httpTimeoutMs: 45_000 },
});
await expectGetCallTimeout(45_000);
});
it("falls back to default timeout when configured timeout is invalid", async () => {
createFeishuClient({
appId: "app_5",
appSecret: "secret_5", // pragma: allowlist secret
accountId: "timeout-config-invalid",
config: { httpTimeoutMs: -1 },
});
await expectGetCallTimeout(FEISHU_HTTP_TIMEOUT_MS);
});
it("uses env timeout override when provided and no direct timeout is set", async () => {
process.env[FEISHU_HTTP_TIMEOUT_ENV_VAR] = "60000";
createFeishuClient({
appId: "app_8",
appSecret: "secret_8", // pragma: allowlist secret
accountId: "timeout-env-override",
config: { httpTimeoutMs: 45_000 },
});
await expectGetCallTimeout(60_000);
});
it("prefers direct timeout over env override", async () => {
process.env[FEISHU_HTTP_TIMEOUT_ENV_VAR] = "60000";
createFeishuClient({
appId: "app_10",
appSecret: "secret_10", // pragma: allowlist secret
accountId: "timeout-direct-override",
httpTimeoutMs: 120_000,
config: { httpTimeoutMs: 45_000 },
});
await expectGetCallTimeout(120_000);
});
it("clamps env timeout override to max bound", async () => {
process.env[FEISHU_HTTP_TIMEOUT_ENV_VAR] = String(FEISHU_HTTP_TIMEOUT_MAX_MS + 123_456);
createFeishuClient({
appId: "app_9",
appSecret: "secret_9", // pragma: allowlist secret
accountId: "timeout-env-clamp",
});
await expectGetCallTimeout(FEISHU_HTTP_TIMEOUT_MAX_MS);
});
it("recreates cached client when configured timeout changes", async () => {
createFeishuClient({
appId: "app_6",
appSecret: "secret_6", // pragma: allowlist secret
accountId: "timeout-cache-change",
config: { httpTimeoutMs: 30_000 },
});
createFeishuClient({
appId: "app_6",
appSecret: "secret_6", // pragma: allowlist secret
accountId: "timeout-cache-change",
config: { httpTimeoutMs: 45_000 },
});
expect(clientCtorMock.mock.calls.length).toBe(2);
const httpInstance = getLastClientHttpInstance();
expect(httpInstance).toBeDefined();
await httpInstance?.get("https://example.com/api");
expect(mockBaseHttpInstance.get).toHaveBeenCalledWith(
"https://example.com/api",
expect.objectContaining({ timeout: 45_000 }),
);
});
});
describe("createFeishuWSClient proxy handling", () => {
it("does not set a ws proxy agent when proxy env is absent", async () => {
await createFeishuWSClient(baseAccount);
expect(proxyAgentCtorMock).not.toHaveBeenCalled();
const options = firstWsClientOptions();
expect(options.agent).toBeUndefined();
});
it("creates a ws proxy agent when lowercase https_proxy is set", async () => {
process.env.https_proxy = "http://lower-https:8001";
await createFeishuWSClient(baseAccount);
expect(proxyAgentCtorMock).toHaveBeenCalledTimes(1);
const options = firstWsClientOptions();
expect(options.agent).toEqual({ proxied: true });
});
it("creates a ws proxy agent when uppercase HTTPS_PROXY is set", async () => {
process.env.HTTPS_PROXY = "http://upper-https:8002";
await createFeishuWSClient(baseAccount);
expect(proxyAgentCtorMock).toHaveBeenCalledTimes(1);
const options = firstWsClientOptions();
expect(options.agent).toEqual({ proxied: true });
});
it("falls back to HTTP_PROXY for ws proxy agent creation", async () => {
process.env.HTTP_PROXY = "http://upper-http:8999";
await createFeishuWSClient(baseAccount);
expect(proxyAgentCtorMock).toHaveBeenCalledTimes(1);
const options = firstWsClientOptions();
expect(options.agent).toEqual({ proxied: true });
});
});

View file

@ -0,0 +1,290 @@
import type { Agent } from "node:https";
import { createRequire } from "node:module";
import * as Lark from "@larksuiteoapi/node-sdk";
import { resolveAmbientNodeProxyAgent } from "openclaw/plugin-sdk/extension-shared";
import type { FeishuConfig, FeishuDomain, ResolvedFeishuAccount } from "./types.js";
const require = createRequire(import.meta.url);
const PACKAGE_JSON_CANDIDATES = [
"../package.json",
"./package.json",
"../../package.json",
] as const;
function readPluginVersion(): string {
for (const candidate of PACKAGE_JSON_CANDIDATES) {
try {
const version = (require(candidate) as { version?: unknown }).version;
if (typeof version === "string" && version.trim().length > 0) {
return version;
}
} catch {
// Ignore missing candidate paths across source and bundled layouts.
}
}
return "unknown";
}
const pluginVersion = readPluginVersion();
export { pluginVersion };
const FEISHU_USER_AGENT = `openclaw-feishu-builtin/${pluginVersion}/${process.platform}`;
export { FEISHU_USER_AGENT };
/** User-Agent header value for all Feishu API requests. */
export function getFeishuUserAgent(): string {
return FEISHU_USER_AGENT;
}
type FeishuClientSdk = Pick<
typeof Lark,
| "AppType"
| "Client"
| "defaultHttpInstance"
| "Domain"
| "EventDispatcher"
| "LoggerLevel"
| "WSClient"
>;
const defaultFeishuClientSdk: FeishuClientSdk = {
AppType: Lark.AppType,
Client: Lark.Client,
defaultHttpInstance: Lark.defaultHttpInstance,
Domain: Lark.Domain,
EventDispatcher: Lark.EventDispatcher,
LoggerLevel: Lark.LoggerLevel,
WSClient: Lark.WSClient,
};
let feishuClientSdk: FeishuClientSdk = defaultFeishuClientSdk;
// Override the SDK's default User-Agent interceptor.
// The Lark SDK registers an axios request interceptor that sets
// 'oapi-node-sdk/1.0.0'. Axios request interceptors execute in LIFO order
// (last-registered runs first), so simply appending ours doesn't work — the
// SDK's interceptor would run last and overwrite our UA. We must clear
// handlers[] first, then register our own as the sole interceptor.
//
// Risk is low: the SDK only registers one interceptor (UA) at init time, and
// we clear it at module load before any other code can register handlers.
// If a future SDK version adds more interceptors, the upgrade will need
// compatibility verification regardless.
{
const inst = Lark.defaultHttpInstance as {
interceptors?: {
request: { handlers: unknown[]; use: (fn: (req: unknown) => unknown) => void };
};
};
if (inst.interceptors?.request) {
inst.interceptors.request.handlers = [];
inst.interceptors.request.use((req: unknown) => {
const r = req as { headers?: Record<string, string> };
if (r.headers) {
r.headers["User-Agent"] = getFeishuUserAgent();
}
return req;
});
}
}
/** Default HTTP timeout for Feishu API requests (30 seconds). */
export const FEISHU_HTTP_TIMEOUT_MS = 30_000;
export const FEISHU_HTTP_TIMEOUT_MAX_MS = 300_000;
export const FEISHU_HTTP_TIMEOUT_ENV_VAR = "OPENCLAW_FEISHU_HTTP_TIMEOUT_MS";
type FeishuHttpInstanceLike = Pick<
typeof feishuClientSdk.defaultHttpInstance,
"request" | "get" | "post" | "put" | "patch" | "delete" | "head" | "options"
>;
async function getWsProxyAgent() {
return resolveAmbientNodeProxyAgent<Agent>();
}
// Multi-account client cache
const clientCache = new Map<
string,
{
client: Lark.Client;
config: { appId: string; appSecret: string; domain?: FeishuDomain; httpTimeoutMs: number };
}
>();
function resolveDomain(domain: FeishuDomain | undefined): Lark.Domain | string {
if (domain === "lark") {
return feishuClientSdk.Domain.Lark;
}
if (domain === "feishu" || !domain) {
return feishuClientSdk.Domain.Feishu;
}
return domain.replace(/\/+$/, ""); // Custom URL for private deployment
}
/**
* Create an HTTP instance that delegates to the Lark SDK's default instance
* but injects a default request timeout and User-Agent header to prevent
* indefinite hangs and set a standardized User-Agent per OAPI best practices.
*/
function createTimeoutHttpInstance(defaultTimeoutMs: number): Lark.HttpInstance {
const base: FeishuHttpInstanceLike = feishuClientSdk.defaultHttpInstance;
function injectTimeout<D>(opts?: Lark.HttpRequestOptions<D>): Lark.HttpRequestOptions<D> {
return { timeout: defaultTimeoutMs, ...opts } as Lark.HttpRequestOptions<D>;
}
return {
request: (opts) => base.request(injectTimeout(opts)),
get: (url, opts) => base.get(url, injectTimeout(opts)),
post: (url, data, opts) => base.post(url, data, injectTimeout(opts)),
put: (url, data, opts) => base.put(url, data, injectTimeout(opts)),
patch: (url, data, opts) => base.patch(url, data, injectTimeout(opts)),
delete: (url, opts) => base.delete(url, injectTimeout(opts)),
head: (url, opts) => base.head(url, injectTimeout(opts)),
options: (url, opts) => base.options(url, injectTimeout(opts)),
};
}
/**
* Credentials needed to create a Feishu client.
* Both FeishuConfig and ResolvedFeishuAccount satisfy this interface.
*/
export type FeishuClientCredentials = {
accountId?: string;
appId?: string;
appSecret?: string;
domain?: FeishuDomain;
httpTimeoutMs?: number;
config?: Pick<FeishuConfig, "httpTimeoutMs">;
};
function resolveConfiguredHttpTimeoutMs(creds: FeishuClientCredentials): number {
const clampTimeout = (value: number): number => {
const rounded = Math.floor(value);
return Math.min(Math.max(rounded, 1), FEISHU_HTTP_TIMEOUT_MAX_MS);
};
const fromDirectField = creds.httpTimeoutMs;
if (
typeof fromDirectField === "number" &&
Number.isFinite(fromDirectField) &&
fromDirectField > 0
) {
return clampTimeout(fromDirectField);
}
const envRaw = process.env[FEISHU_HTTP_TIMEOUT_ENV_VAR];
if (envRaw) {
const envValue = Number(envRaw);
if (Number.isFinite(envValue) && envValue > 0) {
return clampTimeout(envValue);
}
}
const fromConfig = creds.config?.httpTimeoutMs;
const timeout = fromConfig;
if (typeof timeout !== "number" || !Number.isFinite(timeout) || timeout <= 0) {
return FEISHU_HTTP_TIMEOUT_MS;
}
return clampTimeout(timeout);
}
/**
* Create or get a cached Feishu client for an account.
* Accepts any object with appId, appSecret, and optional domain/accountId.
*/
export function createFeishuClient(creds: FeishuClientCredentials): Lark.Client {
const { accountId = "default", appId, appSecret, domain } = creds;
const defaultHttpTimeoutMs = resolveConfiguredHttpTimeoutMs(creds);
if (!appId || !appSecret) {
throw new Error(`Feishu credentials not configured for account "${accountId}"`);
}
// Check cache
const cached = clientCache.get(accountId);
if (
cached &&
cached.config.appId === appId &&
cached.config.appSecret === appSecret &&
cached.config.domain === domain &&
cached.config.httpTimeoutMs === defaultHttpTimeoutMs
) {
return cached.client;
}
// Create new client with timeout-aware HTTP instance
const client = new feishuClientSdk.Client({
appId,
appSecret,
appType: feishuClientSdk.AppType.SelfBuild,
domain: resolveDomain(domain),
httpInstance: createTimeoutHttpInstance(defaultHttpTimeoutMs),
});
// Cache it
clientCache.set(accountId, {
client,
config: { appId, appSecret, domain, httpTimeoutMs: defaultHttpTimeoutMs },
});
return client;
}
/**
* Create a Feishu WebSocket client for an account.
* Note: WSClient is not cached since each call creates a new connection.
*/
export async function createFeishuWSClient(account: ResolvedFeishuAccount): Promise<Lark.WSClient> {
const { accountId, appId, appSecret, domain } = account;
if (!appId || !appSecret) {
throw new Error(`Feishu credentials not configured for account "${accountId}"`);
}
const agent = await getWsProxyAgent();
return new feishuClientSdk.WSClient({
appId,
appSecret,
domain: resolveDomain(domain),
loggerLevel: feishuClientSdk.LoggerLevel.info,
...(agent ? { agent } : {}),
});
}
/**
* Create an event dispatcher for an account.
*/
export function createEventDispatcher(account: ResolvedFeishuAccount): Lark.EventDispatcher {
return new feishuClientSdk.EventDispatcher({
encryptKey: account.encryptKey,
verificationToken: account.verificationToken,
});
}
/**
* Get a cached client for an account (if exists).
*/
export function getFeishuClient(accountId: string): Lark.Client | null {
return clientCache.get(accountId)?.client ?? null;
}
/**
* Clear client cache for a specific account or all accounts.
*/
export function clearClientCache(accountId?: string): void {
if (accountId) {
clientCache.delete(accountId);
} else {
clientCache.clear();
}
}
export function setFeishuClientRuntimeForTest(overrides?: {
sdk?: Partial<FeishuClientSdk>;
}): void {
feishuClientSdk = overrides?.sdk
? { ...defaultFeishuClientSdk, ...overrides.sdk }
: defaultFeishuClientSdk;
}

View file

@ -0,0 +1,6 @@
export {
createReplyPrefixContext,
type ClawdbotConfig,
type ReplyPayload,
type RuntimeEnv,
} from "../runtime-api.js";

View file

@ -0,0 +1,160 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const resolveFeishuRuntimeAccountMock = vi.hoisted(() => vi.fn());
const createFeishuClientMock = vi.hoisted(() => vi.fn());
const createReplyPrefixContextMock = vi.hoisted(() => vi.fn());
const createCommentTypingReactionLifecycleMock = vi.hoisted(() => vi.fn());
const deliverCommentThreadTextMock = vi.hoisted(() => vi.fn());
const createReplyDispatcherWithTypingMock = vi.hoisted(() => vi.fn());
const getFeishuRuntimeMock = vi.hoisted(() => vi.fn());
vi.mock("./accounts.js", () => ({
resolveFeishuRuntimeAccount: resolveFeishuRuntimeAccountMock,
}));
vi.mock("./client.js", () => ({
createFeishuClient: createFeishuClientMock,
}));
vi.mock("./comment-dispatcher-runtime-api.js", () => ({
createReplyPrefixContext: createReplyPrefixContextMock,
}));
vi.mock("./comment-reaction.js", () => ({
createCommentTypingReactionLifecycle: createCommentTypingReactionLifecycleMock,
}));
vi.mock("./drive.js", () => ({
deliverCommentThreadText: deliverCommentThreadTextMock,
}));
vi.mock("./runtime.js", () => ({
getFeishuRuntime: getFeishuRuntimeMock,
}));
import { createFeishuCommentReplyDispatcher } from "./comment-dispatcher.js";
describe("createFeishuCommentReplyDispatcher", () => {
beforeEach(() => {
vi.clearAllMocks();
resolveFeishuRuntimeAccountMock.mockReturnValue({
accountId: "main",
appId: "app_id",
appSecret: "app_secret",
domain: "feishu",
config: {},
});
createFeishuClientMock.mockReturnValue({});
createReplyPrefixContextMock.mockReturnValue({
responsePrefix: undefined,
responsePrefixContextProvider: undefined,
});
deliverCommentThreadTextMock.mockResolvedValue({
delivery_mode: "reply_comment",
reply_id: "reply_1",
});
createCommentTypingReactionLifecycleMock.mockReturnValue({
start: vi.fn(async () => {}),
cleanup: vi.fn(async () => {}),
});
createReplyDispatcherWithTypingMock.mockImplementation(() => ({
dispatcher: {
markComplete: vi.fn(),
waitForIdle: vi.fn(async () => {}),
},
replyOptions: {},
markDispatchIdle: vi.fn(),
markRunComplete: vi.fn(),
}));
getFeishuRuntimeMock.mockReturnValue({
channel: {
text: {
resolveTextChunkLimit: vi.fn(() => 4000),
resolveChunkMode: vi.fn(() => "line"),
chunkTextWithMode: vi.fn((text: string) => [text]),
},
reply: {
createReplyDispatcherWithTyping: createReplyDispatcherWithTypingMock,
resolveHumanDelayConfig: vi.fn(() => undefined),
},
},
});
});
it("sends final comment text without waiting for typing cleanup", async () => {
let resolveCleanup: (() => void) | undefined;
const cleanup = vi.fn(
() =>
new Promise<void>((resolve) => {
resolveCleanup = resolve;
}),
);
createCommentTypingReactionLifecycleMock.mockReturnValue({
start: vi.fn(async () => {}),
cleanup,
});
createFeishuCommentReplyDispatcher({
cfg: {} as never,
agentId: "main",
runtime: { log: vi.fn(), error: vi.fn() } as never,
accountId: "main",
fileToken: "doc_token_1",
fileType: "docx",
commentId: "comment_1",
replyId: "reply_1",
isWholeComment: false,
});
const options = createReplyDispatcherWithTypingMock.mock.calls.at(-1)?.[0];
const deliverPromise = options.deliver({ text: "hello world" }, { kind: "final" });
const status = await Promise.race([
deliverPromise.then(() => "done"),
new Promise<string>((resolve) => setTimeout(() => resolve("pending"), 0)),
]);
expect(status).toBe("done");
expect(deliverCommentThreadTextMock).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
file_token: "doc_token_1",
file_type: "docx",
comment_id: "comment_1",
content: "hello world",
is_whole_comment: false,
}),
);
expect(cleanup).not.toHaveBeenCalled();
options.onCleanup?.();
expect(cleanup).toHaveBeenCalledTimes(1);
resolveCleanup?.();
await deliverPromise;
});
it("starts the typing reaction from dispatcher onReplyStart", async () => {
const start = vi.fn(async () => {});
createCommentTypingReactionLifecycleMock.mockReturnValue({
start,
cleanup: vi.fn(async () => {}),
});
createFeishuCommentReplyDispatcher({
cfg: {} as never,
agentId: "main",
runtime: { log: vi.fn(), error: vi.fn() } as never,
accountId: "main",
fileToken: "doc_token_1",
fileType: "docx",
commentId: "comment_1",
replyId: "reply_1",
isWholeComment: false,
});
const options = createReplyDispatcherWithTypingMock.mock.calls.at(-1)?.[0];
await options.onReplyStart?.();
expect(start).toHaveBeenCalledTimes(1);
});
});

View file

@ -0,0 +1,107 @@
import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
import { resolveFeishuRuntimeAccount } from "./accounts.js";
import { createFeishuClient } from "./client.js";
import {
createReplyPrefixContext,
type ClawdbotConfig,
type ReplyPayload,
type RuntimeEnv,
} from "./comment-dispatcher-runtime-api.js";
import { createCommentTypingReactionLifecycle } from "./comment-reaction.js";
import type { CommentFileType } from "./comment-target.js";
import { deliverCommentThreadText } from "./drive.js";
import { getFeishuRuntime } from "./runtime.js";
export type CreateFeishuCommentReplyDispatcherParams = {
cfg: ClawdbotConfig;
agentId: string;
runtime: RuntimeEnv;
accountId?: string;
fileToken: string;
fileType: CommentFileType;
commentId: string;
replyId?: string;
isWholeComment?: boolean;
};
export function createFeishuCommentReplyDispatcher(
params: CreateFeishuCommentReplyDispatcherParams,
) {
const core = getFeishuRuntime();
const prefixContext = createReplyPrefixContext({
cfg: params.cfg,
agentId: params.agentId,
channel: "feishu",
accountId: params.accountId,
});
const account = resolveFeishuRuntimeAccount({ cfg: params.cfg, accountId: params.accountId });
const client = createFeishuClient(account);
const textChunkLimit = core.channel.text.resolveTextChunkLimit(
params.cfg,
"feishu",
params.accountId,
{
fallbackLimit: 4000,
},
);
const chunkMode = core.channel.text.resolveChunkMode(params.cfg, "feishu");
const typingReaction = createCommentTypingReactionLifecycle({
cfg: params.cfg,
fileToken: params.fileToken,
fileType: params.fileType,
replyId: params.replyId,
accountId: params.accountId,
runtime: params.runtime,
});
const { dispatcher, replyOptions, markDispatchIdle, markRunComplete } =
core.channel.reply.createReplyDispatcherWithTyping({
responsePrefix: prefixContext.responsePrefix,
responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
humanDelay: core.channel.reply.resolveHumanDelayConfig(params.cfg, params.agentId),
onReplyStart: async () => {
await typingReaction.start();
},
deliver: async (payload: ReplyPayload, info) => {
if (info.kind !== "final") {
return;
}
const reply = resolveSendableOutboundReplyParts(payload);
if (!reply.hasText) {
if (reply.hasMedia) {
params.runtime.log?.(
`feishu[${params.accountId ?? "default"}]: comment reply ignored media-only payload for comment=${params.commentId}`,
);
}
return;
}
const chunks = core.channel.text.chunkTextWithMode(reply.text, textChunkLimit, chunkMode);
for (const chunk of chunks) {
await deliverCommentThreadText(client, {
file_token: params.fileToken,
file_type: params.fileType,
comment_id: params.commentId,
content: chunk,
is_whole_comment: params.isWholeComment,
});
}
},
onError: (err, info) => {
params.runtime.error?.(
`feishu[${params.accountId ?? "default"}]: comment dispatcher failed kind=${info.kind} comment=${params.commentId}: ${String(err)}`,
);
},
onCleanup: () => {
void typingReaction.cleanup();
},
});
return {
dispatcher,
replyOptions,
markDispatchIdle,
markRunComplete,
startTypingReaction: typingReaction.start,
cleanupTypingReaction: typingReaction.cleanup,
};
}

View file

@ -0,0 +1,3 @@
export type { OpenClawConfig as ClawdbotConfig } from "openclaw/plugin-sdk/config-runtime";
export type { RuntimeEnv } from "openclaw/plugin-sdk/runtime";
export { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing";

View file

@ -0,0 +1,441 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig, PluginRuntime } from "../runtime-api.js";
import { handleFeishuCommentEvent } from "./comment-handler.js";
import { setFeishuRuntime } from "./runtime.js";
const resolveDriveCommentEventTurnMock = vi.hoisted(() => vi.fn());
const createFeishuCommentReplyDispatcherMock = vi.hoisted(() => vi.fn());
const maybeCreateDynamicAgentMock = vi.hoisted(() => vi.fn());
const createFeishuClientMock = vi.hoisted(() => vi.fn(() => ({ request: vi.fn() })));
const deliverCommentThreadTextMock = vi.hoisted(() => vi.fn());
vi.mock("./monitor.comment.js", () => ({
resolveDriveCommentEventTurn: resolveDriveCommentEventTurnMock,
}));
vi.mock("./comment-dispatcher.js", () => ({
createFeishuCommentReplyDispatcher: createFeishuCommentReplyDispatcherMock,
}));
vi.mock("./dynamic-agent.js", () => ({
maybeCreateDynamicAgent: maybeCreateDynamicAgentMock,
}));
vi.mock("./client.js", () => ({
createFeishuClient: createFeishuClientMock,
}));
vi.mock("./drive.js", () => ({
deliverCommentThreadText: deliverCommentThreadTextMock,
}));
function buildConfig(overrides?: Partial<ClawdbotConfig>): ClawdbotConfig {
return {
channels: {
feishu: {
enabled: true,
dmPolicy: "open",
},
},
...overrides,
} as ClawdbotConfig;
}
function buildResolvedRoute(matchedBy: "binding.channel" | "default" = "binding.channel") {
return {
agentId: "main",
channel: "feishu",
accountId: "default",
sessionKey: "agent:main:feishu:direct:ou_sender",
mainSessionKey: "agent:main:feishu",
lastRoutePolicy: "session" as const,
matchedBy,
};
}
function createTestRuntime(overrides?: {
readAllowFromStore?: () => Promise<unknown[]>;
upsertPairingRequest?: () => Promise<{ code: string; created: boolean }>;
resolveAgentRoute?: () => ReturnType<typeof buildResolvedRoute>;
dispatchReplyFromConfig?: PluginRuntime["channel"]["reply"]["dispatchReplyFromConfig"];
withReplyDispatcher?: PluginRuntime["channel"]["reply"]["withReplyDispatcher"];
}) {
const finalizeInboundContext = vi.fn((ctx: Record<string, unknown>) => ctx);
const dispatchReplyFromConfig =
overrides?.dispatchReplyFromConfig ??
vi.fn(async () => ({
queuedFinal: true,
counts: { tool: 0, block: 0, final: 1 },
}));
const withReplyDispatcher =
overrides?.withReplyDispatcher ??
vi.fn(
async ({
run,
onSettled,
}: {
run: () => Promise<unknown>;
onSettled?: () => Promise<void> | void;
}) => {
try {
return await run();
} finally {
await onSettled?.();
}
},
);
const recordInboundSession = vi.fn(async () => {});
return {
channel: {
routing: {
buildAgentSessionKey: vi.fn(
({
agentId,
channel,
peer,
}: {
agentId: string;
channel: string;
peer?: { kind?: string; id?: string };
}) => `agent:${agentId}:${channel}:${peer?.kind ?? "direct"}:${peer?.id ?? "peer"}`,
),
resolveAgentRoute: vi.fn(overrides?.resolveAgentRoute ?? (() => buildResolvedRoute())),
},
reply: {
finalizeInboundContext,
dispatchReplyFromConfig,
withReplyDispatcher,
},
session: {
resolveStorePath: vi.fn(() => "/tmp/feishu-session-store.json"),
recordInboundSession,
},
pairing: {
readAllowFromStore: vi.fn(overrides?.readAllowFromStore ?? (async () => [])),
upsertPairingRequest: vi.fn(
overrides?.upsertPairingRequest ??
(async () => ({
code: "TESTCODE",
created: true,
})),
),
buildPairingReply: vi.fn((code: string) => `Pairing code: ${code}`),
},
},
} as unknown as PluginRuntime;
}
describe("handleFeishuCommentEvent", () => {
beforeEach(() => {
vi.clearAllMocks();
maybeCreateDynamicAgentMock.mockResolvedValue({ created: false });
resolveDriveCommentEventTurnMock.mockResolvedValue({
eventId: "evt_1",
messageId: "drive-comment:evt_1",
commentId: "comment_1",
replyId: "reply_1",
noticeType: "add_comment",
fileToken: "doc_token_1",
fileType: "docx",
isWholeComment: false,
senderId: "ou_sender",
senderUserId: "on_sender_user",
timestamp: "1774951528000",
isMentioned: true,
documentTitle: "Project review",
prompt: "prompt body",
preview: "prompt body",
rootCommentText: "root comment",
targetReplyText: "latest reply",
});
deliverCommentThreadTextMock.mockResolvedValue({
delivery_mode: "reply_comment",
reply_id: "r1",
});
const runtime = createTestRuntime();
setFeishuRuntime(runtime);
createFeishuCommentReplyDispatcherMock.mockReturnValue({
dispatcher: {
markComplete: vi.fn(),
waitForIdle: vi.fn(async () => {}),
},
replyOptions: {},
markDispatchIdle: vi.fn(),
markRunComplete: vi.fn(),
startTypingReaction: vi.fn(async () => {}),
cleanupTypingReaction: vi.fn(async () => {}),
});
});
it("records a comment-thread inbound context with a routable Feishu origin", async () => {
await handleFeishuCommentEvent({
cfg: buildConfig(),
accountId: "default",
event: { event_id: "evt_1" },
botOpenId: "ou_bot",
runtime: {
log: vi.fn(),
error: vi.fn(),
} as never,
});
const runtime = (await import("./runtime.js")).getFeishuRuntime();
const finalizeInboundContext = runtime.channel.reply.finalizeInboundContext as ReturnType<
typeof vi.fn
>;
const recordInboundSession = runtime.channel.session.recordInboundSession as ReturnType<
typeof vi.fn
>;
const dispatchReplyFromConfig = runtime.channel.reply.dispatchReplyFromConfig as ReturnType<
typeof vi.fn
>;
expect(finalizeInboundContext).toHaveBeenCalledWith(
expect.objectContaining({
From: "feishu:ou_sender",
To: "comment:docx:doc_token_1:comment_1",
Surface: "feishu-comment",
OriginatingChannel: "feishu",
OriginatingTo: "comment:docx:doc_token_1:comment_1",
MessageSid: "drive-comment:evt_1",
MessageThreadId: "reply_1",
}),
);
expect(recordInboundSession).toHaveBeenCalledTimes(1);
expect(recordInboundSession).toHaveBeenCalledWith(
expect.objectContaining({
sessionKey: "agent:main:feishu:direct:comment-doc:docx:doc_token_1",
}),
);
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
});
it("allows comment senders matched by user_id allowlist entries", async () => {
const runtime = createTestRuntime();
setFeishuRuntime(runtime);
await handleFeishuCommentEvent({
cfg: buildConfig({
channels: {
feishu: {
enabled: true,
dmPolicy: "allowlist",
allowFrom: ["on_sender_user"],
},
},
}),
accountId: "default",
event: { event_id: "evt_1" },
botOpenId: "ou_bot",
runtime: {
log: vi.fn(),
error: vi.fn(),
} as never,
});
const dispatchReplyFromConfig = runtime.channel.reply.dispatchReplyFromConfig as ReturnType<
typeof vi.fn
>;
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
expect(deliverCommentThreadTextMock).not.toHaveBeenCalled();
});
it("issues a pairing challenge in the comment thread when dmPolicy=pairing", async () => {
const runtime = createTestRuntime();
setFeishuRuntime(runtime);
await handleFeishuCommentEvent({
cfg: buildConfig({
channels: {
feishu: {
enabled: true,
dmPolicy: "pairing",
allowFrom: [],
},
},
}),
accountId: "default",
event: { event_id: "evt_1" },
botOpenId: "ou_bot",
runtime: {
log: vi.fn(),
error: vi.fn(),
} as never,
});
expect(deliverCommentThreadTextMock).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
file_token: "doc_token_1",
file_type: "docx",
comment_id: "comment_1",
is_whole_comment: false,
}),
);
const dispatchReplyFromConfig = runtime.channel.reply.dispatchReplyFromConfig as ReturnType<
typeof vi.fn
>;
expect(dispatchReplyFromConfig).not.toHaveBeenCalled();
});
it("passes whole-comment metadata to the comment reply dispatcher", async () => {
resolveDriveCommentEventTurnMock.mockResolvedValueOnce({
eventId: "evt_whole",
messageId: "drive-comment:evt_whole",
commentId: "comment_whole",
replyId: "reply_whole",
noticeType: "add_reply",
fileToken: "doc_token_1",
fileType: "docx",
isWholeComment: true,
senderId: "ou_sender",
senderUserId: "on_sender_user",
timestamp: "1774951528000",
isMentioned: false,
documentTitle: "Project review",
prompt: "prompt body",
preview: "prompt body",
rootCommentText: "root comment",
targetReplyText: "reply text",
});
await handleFeishuCommentEvent({
cfg: buildConfig(),
accountId: "default",
event: { event_id: "evt_whole" },
botOpenId: "ou_bot",
runtime: {
log: vi.fn(),
error: vi.fn(),
} as never,
});
expect(createFeishuCommentReplyDispatcherMock).toHaveBeenCalledWith(
expect.objectContaining({
commentId: "comment_whole",
fileToken: "doc_token_1",
fileType: "docx",
replyId: "reply_whole",
isWholeComment: true,
}),
);
});
it("always finalizes comment typing cleanup even when dispatch fails", async () => {
const dispatchReplyFromConfig = vi.fn(async () => {
throw new Error("dispatch failed");
});
const runtime = createTestRuntime({ dispatchReplyFromConfig });
setFeishuRuntime(runtime);
const markRunComplete = vi.fn();
const markDispatchIdle = vi.fn();
const cleanupTypingReaction = vi.fn(async () => {});
createFeishuCommentReplyDispatcherMock.mockReturnValue({
dispatcher: {
markComplete: vi.fn(),
waitForIdle: vi.fn(async () => {}),
},
replyOptions: {},
markDispatchIdle,
markRunComplete,
startTypingReaction: vi.fn(async () => {}),
cleanupTypingReaction,
});
await expect(
handleFeishuCommentEvent({
cfg: buildConfig(),
accountId: "default",
event: { event_id: "evt_1" },
botOpenId: "ou_bot",
runtime: {
log: vi.fn(),
error: vi.fn(),
} as never,
}),
).rejects.toThrow("dispatch failed");
expect(markRunComplete).toHaveBeenCalledTimes(1);
expect(markDispatchIdle).toHaveBeenCalledTimes(1);
expect(cleanupTypingReaction).toHaveBeenCalledTimes(1);
});
it("does not wait for comment typing cleanup before returning", async () => {
let resolveCleanup: (() => void) | undefined;
const cleanupTypingReaction = vi.fn(
() =>
new Promise<void>((resolve) => {
resolveCleanup = resolve;
}),
);
createFeishuCommentReplyDispatcherMock.mockReturnValue({
dispatcher: {
markComplete: vi.fn(),
waitForIdle: vi.fn(async () => {}),
},
replyOptions: {},
markDispatchIdle: vi.fn(),
markRunComplete: vi.fn(),
startTypingReaction: vi.fn(async () => {}),
cleanupTypingReaction,
});
const eventPromise = handleFeishuCommentEvent({
cfg: buildConfig(),
accountId: "default",
event: { event_id: "evt_1" },
botOpenId: "ou_bot",
runtime: {
log: vi.fn(),
error: vi.fn(),
} as never,
});
const status = await Promise.race([
eventPromise.then(() => "done"),
new Promise<string>((resolve) => setTimeout(() => resolve("pending"), 0)),
]);
expect(status).toBe("done");
expect(cleanupTypingReaction).toHaveBeenCalledTimes(1);
resolveCleanup?.();
await eventPromise;
});
it("does not start comment typing reaction before dispatch begins", async () => {
const startTypingReaction = vi.fn(async () => {});
createFeishuCommentReplyDispatcherMock.mockReturnValue({
dispatcher: {
markComplete: vi.fn(),
waitForIdle: vi.fn(async () => {}),
},
replyOptions: {},
markDispatchIdle: vi.fn(),
markRunComplete: vi.fn(),
startTypingReaction,
cleanupTypingReaction: vi.fn(async () => {}),
});
await handleFeishuCommentEvent({
cfg: buildConfig(),
accountId: "default",
event: { event_id: "evt_1" },
botOpenId: "ou_bot",
runtime: {
log: vi.fn(),
error: vi.fn(),
} as never,
});
expect(startTypingReaction).not.toHaveBeenCalled();
const runtime = (await import("./runtime.js")).getFeishuRuntime();
const dispatchReplyFromConfig = runtime.channel.reply.dispatchReplyFromConfig as ReturnType<
typeof vi.fn
>;
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
});
});

View file

@ -0,0 +1,259 @@
import type { ResolvedAgentRoute } from "openclaw/plugin-sdk/routing";
import { resolveFeishuRuntimeAccount } from "./accounts.js";
import { createFeishuClient } from "./client.js";
import { createFeishuCommentReplyDispatcher } from "./comment-dispatcher.js";
import {
createChannelPairingController,
type ClawdbotConfig,
type RuntimeEnv,
} from "./comment-handler-runtime-api.js";
import { buildFeishuCommentTarget } from "./comment-target.js";
import { deliverCommentThreadText } from "./drive.js";
import { maybeCreateDynamicAgent } from "./dynamic-agent.js";
import {
resolveDriveCommentEventTurn,
type FeishuDriveCommentNoticeEvent,
} from "./monitor.comment.js";
import { resolveFeishuAllowlistMatch } from "./policy.js";
import { getFeishuRuntime } from "./runtime.js";
import type { DynamicAgentCreationConfig } from "./types.js";
type HandleFeishuCommentEventParams = {
cfg: ClawdbotConfig;
accountId: string;
runtime?: RuntimeEnv;
event: FeishuDriveCommentNoticeEvent;
botOpenId?: string;
};
function buildCommentSessionKey(params: {
core: ReturnType<typeof getFeishuRuntime>;
route: ResolvedAgentRoute;
fileType: string;
fileToken: string;
}): string {
return params.core.channel.routing.buildAgentSessionKey({
agentId: params.route.agentId,
channel: "feishu",
accountId: params.route.accountId,
peer: {
kind: "direct",
id: `comment-doc:${params.fileType}:${params.fileToken}`,
},
dmScope: "per-account-channel-peer",
});
}
function parseTimestampMs(value: string | undefined): number {
const parsed = value ? Number.parseInt(value, 10) : Number.NaN;
return Number.isFinite(parsed) ? parsed : Date.now();
}
export async function handleFeishuCommentEvent(
params: HandleFeishuCommentEventParams,
): Promise<void> {
const account = resolveFeishuRuntimeAccount({ cfg: params.cfg, accountId: params.accountId });
const feishuCfg = account.config;
const core = getFeishuRuntime();
const log = params.runtime?.log ?? console.log;
const error = params.runtime?.error ?? console.error;
const runtime = (params.runtime ?? { log, error }) as RuntimeEnv;
const turn = await resolveDriveCommentEventTurn({
cfg: params.cfg,
accountId: account.accountId,
event: params.event,
botOpenId: params.botOpenId,
logger: log,
});
if (!turn) {
log(
`feishu[${account.accountId}]: drive comment notice skipped ` +
`event=${params.event.event_id ?? "unknown"} comment=${params.event.comment_id ?? "unknown"}`,
);
return;
}
const commentTarget = buildFeishuCommentTarget({
fileType: turn.fileType,
fileToken: turn.fileToken,
commentId: turn.commentId,
});
const dmPolicy = feishuCfg?.dmPolicy ?? "pairing";
const configAllowFrom = feishuCfg?.allowFrom ?? [];
const pairing = createChannelPairingController({
core,
channel: "feishu",
accountId: account.accountId,
});
const storeAllowFrom =
dmPolicy !== "allowlist" && dmPolicy !== "open"
? await pairing.readAllowFromStore().catch(() => [])
: [];
const effectiveDmAllowFrom = [...configAllowFrom, ...storeAllowFrom];
const senderAllowed = resolveFeishuAllowlistMatch({
allowFrom: effectiveDmAllowFrom,
senderId: turn.senderId,
senderIds: [turn.senderUserId],
}).allowed;
if (dmPolicy !== "open" && !senderAllowed) {
if (dmPolicy === "pairing") {
const client = createFeishuClient(account);
await pairing.issueChallenge({
senderId: turn.senderId,
senderIdLine: `Your Feishu user id: ${turn.senderId}`,
meta: { name: turn.senderId },
onCreated: ({ code }) => {
log(
`feishu[${account.accountId}]: comment pairing request sender=${turn.senderId} code=${code}`,
);
},
sendPairingReply: async (text) => {
await deliverCommentThreadText(client, {
file_token: turn.fileToken,
file_type: turn.fileType,
comment_id: turn.commentId,
content: text,
is_whole_comment: turn.isWholeComment,
});
},
onReplyError: (err) => {
log(
`feishu[${account.accountId}]: comment pairing reply failed for ${turn.senderId}: ${String(err)}`,
);
},
});
} else {
log(
`feishu[${account.accountId}]: blocked unauthorized comment sender ${turn.senderId} ` +
`(dmPolicy=${dmPolicy}, comment=${turn.commentId})`,
);
}
return;
}
let effectiveCfg = params.cfg;
let route = core.channel.routing.resolveAgentRoute({
cfg: params.cfg,
channel: "feishu",
accountId: account.accountId,
peer: {
kind: "direct",
id: turn.senderId,
},
});
if (route.matchedBy === "default") {
const dynamicCfg = feishuCfg?.dynamicAgentCreation as DynamicAgentCreationConfig | undefined;
if (dynamicCfg?.enabled) {
const dynamicResult = await maybeCreateDynamicAgent({
cfg: params.cfg,
runtime: core,
senderOpenId: turn.senderId,
dynamicCfg,
log: (message) => log(message),
});
if (dynamicResult.created) {
effectiveCfg = dynamicResult.updatedCfg;
route = core.channel.routing.resolveAgentRoute({
cfg: dynamicResult.updatedCfg,
channel: "feishu",
accountId: account.accountId,
peer: {
kind: "direct",
id: turn.senderId,
},
});
log(
`feishu[${account.accountId}]: dynamic agent created for comment flow, route=${route.sessionKey}`,
);
}
}
}
const commentSessionKey = buildCommentSessionKey({
core,
route,
fileType: turn.fileType,
fileToken: turn.fileToken,
});
const bodyForAgent = `[message_id: ${turn.messageId}]\n${turn.prompt}`;
const ctxPayload = core.channel.reply.finalizeInboundContext({
Body: bodyForAgent,
BodyForAgent: bodyForAgent,
RawBody: turn.targetReplyText ?? turn.rootCommentText ?? turn.prompt,
CommandBody: turn.targetReplyText ?? turn.rootCommentText ?? turn.prompt,
From: `feishu:${turn.senderId}`,
To: commentTarget,
SessionKey: commentSessionKey,
AccountId: route.accountId,
ChatType: "direct",
ConversationLabel: turn.documentTitle
? `Feishu comment · ${turn.documentTitle}`
: "Feishu comment",
SenderName: turn.senderId,
SenderId: turn.senderId,
Provider: "feishu",
Surface: "feishu-comment",
MessageSid: turn.messageId,
// For Feishu comment turns, MessageThreadId carries the inbound reply_id so
// comment-aware tools can clean typing reaction before sending visible output.
MessageThreadId: turn.replyId,
Timestamp: parseTimestampMs(turn.timestamp),
WasMentioned: turn.isMentioned,
CommandAuthorized: false,
OriginatingChannel: "feishu",
OriginatingTo: commentTarget,
});
const storePath = core.channel.session.resolveStorePath(effectiveCfg.session?.store, {
agentId: route.agentId,
});
await core.channel.session.recordInboundSession({
storePath,
sessionKey: commentSessionKey,
ctx: ctxPayload,
onRecordError: (err) => {
error(
`feishu[${account.accountId}]: failed to record comment inbound session ${commentSessionKey}: ${String(err)}`,
);
},
});
const { dispatcher, replyOptions, markDispatchIdle, markRunComplete, cleanupTypingReaction } =
createFeishuCommentReplyDispatcher({
cfg: effectiveCfg,
agentId: route.agentId,
runtime,
accountId: account.accountId,
fileToken: turn.fileToken,
fileType: turn.fileType,
commentId: turn.commentId,
replyId: turn.replyId,
isWholeComment: turn.isWholeComment,
});
try {
log(
`feishu[${account.accountId}]: dispatching drive comment to agent ` +
`(session=${commentSessionKey} comment=${turn.commentId} type=${turn.noticeType})`,
);
const { queuedFinal, counts } = await core.channel.reply.withReplyDispatcher({
dispatcher,
run: () =>
core.channel.reply.dispatchReplyFromConfig({
ctx: ctxPayload,
cfg: effectiveCfg,
dispatcher,
replyOptions,
}),
});
log(
`feishu[${account.accountId}]: drive comment dispatch complete ` +
`(queuedFinal=${queuedFinal}, replies=${counts.final}, session=${commentSessionKey})`,
);
} finally {
markRunComplete();
markDispatchIdle();
void cleanupTypingReaction();
}
}

View file

@ -0,0 +1,190 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig } from "../runtime-api.js";
import {
cleanupAmbientCommentTypingReaction,
createCommentTypingReactionLifecycle,
} from "./comment-reaction.js";
const resolveFeishuRuntimeAccountMock = vi.hoisted(() => vi.fn());
const createFeishuClientMock = vi.hoisted(() => vi.fn());
vi.mock("./accounts.js", () => ({
resolveFeishuRuntimeAccount: resolveFeishuRuntimeAccountMock,
}));
vi.mock("./client.js", () => ({
createFeishuClient: createFeishuClientMock,
}));
describe("createCommentTypingReactionLifecycle", () => {
const request = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
resolveFeishuRuntimeAccountMock.mockReturnValue({
accountId: "default",
configured: true,
config: {
typingIndicator: true,
},
});
createFeishuClientMock.mockReturnValue({
request,
});
request.mockResolvedValue({
code: 0,
data: {},
});
});
it("adds and removes a comment typing reaction using reply_id", async () => {
const lifecycle = createCommentTypingReactionLifecycle({
cfg: {} as ClawdbotConfig,
fileToken: "doc_token_1",
fileType: "docx",
replyId: "reply_1",
runtime: {
log: vi.fn(),
} as never,
});
await lifecycle.start();
await lifecycle.cleanup();
expect(request).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
method: "POST",
url: "/open-apis/drive/v2/files/doc_token_1/comments/reaction?file_type=docx",
data: {
action: "add",
reply_id: "reply_1",
reaction_type: "Typing",
},
}),
);
expect(request).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
method: "POST",
url: "/open-apis/drive/v2/files/doc_token_1/comments/reaction?file_type=docx",
data: {
action: "delete",
reply_id: "reply_1",
reaction_type: "Typing",
},
}),
);
});
it("skips requests when reply_id is missing", async () => {
const lifecycle = createCommentTypingReactionLifecycle({
cfg: {} as ClawdbotConfig,
fileToken: "doc_token_1",
fileType: "docx",
replyId: undefined,
runtime: {
log: vi.fn(),
} as never,
});
await lifecycle.start();
await lifecycle.cleanup();
expect(request).not.toHaveBeenCalled();
});
it("shares cleanup state so ambient cleanup and finally cleanup do not delete twice", async () => {
const lifecycle = createCommentTypingReactionLifecycle({
cfg: {} as ClawdbotConfig,
fileToken: "doc_token_1",
fileType: "docx",
replyId: "reply_1",
runtime: {
log: vi.fn(),
} as never,
});
await lifecycle.start();
await cleanupAmbientCommentTypingReaction({
client: { request } as never,
deliveryContext: {
channel: "feishu",
to: "comment:docx:doc_token_1:comment_1",
threadId: "reply_1",
},
});
await lifecycle.cleanup();
expect(request).toHaveBeenCalledTimes(2);
expect(request).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
data: {
action: "delete",
reply_id: "reply_1",
reaction_type: "Typing",
},
}),
);
});
it("retries delete during later cleanup after an ambient delete failure", async () => {
request
.mockResolvedValueOnce({
code: 0,
data: {},
})
.mockResolvedValueOnce({
code: 5001,
msg: "temporary failure",
})
.mockResolvedValueOnce({
code: 0,
data: {},
});
const lifecycle = createCommentTypingReactionLifecycle({
cfg: {} as ClawdbotConfig,
fileToken: "doc_token_1",
fileType: "docx",
replyId: "reply_1",
runtime: {
log: vi.fn(),
} as never,
});
await lifecycle.start();
await cleanupAmbientCommentTypingReaction({
client: { request } as never,
deliveryContext: {
channel: "feishu",
to: "comment:docx:doc_token_1:comment_1",
threadId: "reply_1",
},
});
await lifecycle.cleanup();
expect(request).toHaveBeenCalledTimes(3);
expect(request).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
data: {
action: "delete",
reply_id: "reply_1",
reaction_type: "Typing",
},
}),
);
expect(request).toHaveBeenNthCalledWith(
3,
expect.objectContaining({
data: {
action: "delete",
reply_id: "reply_1",
reaction_type: "Typing",
},
}),
);
});
});

View file

@ -0,0 +1,281 @@
import type { ClawdbotConfig, RuntimeEnv } from "../runtime-api.js";
import { resolveFeishuRuntimeAccount } from "./accounts.js";
import { createFeishuClient } from "./client.js";
import { encodeQuery, isRecord, readString } from "./comment-shared.js";
import { parseFeishuCommentTarget, type CommentFileType } from "./comment-target.js";
const COMMENT_TYPING_REACTION_TYPE = "Typing";
const COMMENT_REACTION_TIMEOUT_MS = 30_000;
const commentTypingReactionState = new Map<
string,
{
active: boolean;
cleaned: boolean;
cleanupPromise?: Promise<boolean>;
}
>();
type FeishuCommentReactionClient = ReturnType<typeof createFeishuClient> & {
request(params: {
method: "POST";
url: string;
data: unknown;
timeout: number;
}): Promise<unknown>;
};
function buildCommentTypingReactionKey(params: {
fileToken: string;
fileType: CommentFileType;
replyId: string;
}): string {
return `${params.fileType}:${params.fileToken}:${params.replyId}`;
}
function ensureCommentTypingReactionState(key: string) {
const existing = commentTypingReactionState.get(key);
if (existing) {
return existing;
}
const created = {
active: false,
cleaned: false,
cleanupPromise: undefined,
};
commentTypingReactionState.set(key, created);
return created;
}
async function requestCommentTypingReactionWithClient(params: {
client: FeishuCommentReactionClient;
fileToken: string;
fileType: CommentFileType;
replyId: string;
action: "add" | "delete";
runtime?: RuntimeEnv;
logPrefix?: string;
}): Promise<boolean> {
try {
const response = (await params.client.request({
method: "POST",
url:
`/open-apis/drive/v2/files/${encodeURIComponent(params.fileToken)}/comments/reaction` +
encodeQuery({
file_type: params.fileType,
}),
data: {
action: params.action,
reply_id: params.replyId,
reaction_type: COMMENT_TYPING_REACTION_TYPE,
},
timeout: COMMENT_REACTION_TIMEOUT_MS,
})) as {
code?: number;
msg?: string;
log_id?: string;
error?: { log_id?: string };
};
if (response.code === 0) {
return true;
}
params.runtime?.log?.(
`${params.logPrefix ?? "[feishu]"}: comment typing reaction ${params.action} failed ` +
`reply=${params.replyId} file=${params.fileType}:${params.fileToken} ` +
`code=${response.code ?? "unknown"} msg=${response.msg ?? "unknown"} ` +
`log_id=${response.log_id ?? response.error?.log_id ?? "unknown"}`,
);
} catch (error) {
params.runtime?.log?.(
`${params.logPrefix ?? "[feishu]"}: comment typing reaction ${params.action} threw ` +
`reply=${params.replyId} file=${params.fileType}:${params.fileToken} ` +
`error=${formatCommentReactionFailure(error)}`,
);
}
return false;
}
function formatCommentReactionFailure(error: unknown): string {
if (!isRecord(error)) {
return typeof error === "string" ? error : JSON.stringify(error);
}
const response = isRecord(error.response) ? error.response : undefined;
const responseData = isRecord(response?.data) ? response?.data : undefined;
return JSON.stringify({
message:
typeof error.message === "string"
? error.message
: typeof error === "string"
? error
: JSON.stringify(error),
code: readString(error.code),
method: readString(isRecord(error.config) ? error.config.method : undefined),
url: readString(isRecord(error.config) ? error.config.url : undefined),
http_status: typeof response?.status === "number" ? response.status : undefined,
feishu_code:
typeof responseData?.code === "number" ? responseData.code : readString(responseData?.code),
feishu_msg: readString(responseData?.msg),
feishu_log_id:
readString(responseData?.log_id) ||
readString(isRecord(responseData?.error) ? responseData.error.log_id : undefined),
});
}
async function requestCommentTypingReaction(params: {
cfg: ClawdbotConfig;
fileToken: string;
fileType: CommentFileType;
replyId: string;
action: "add" | "delete";
accountId?: string;
runtime?: RuntimeEnv;
}): Promise<boolean> {
const account = resolveFeishuRuntimeAccount({ cfg: params.cfg, accountId: params.accountId });
if (!account.configured || !(account.config.typingIndicator ?? true)) {
return false;
}
const client = createFeishuClient(account) as FeishuCommentReactionClient;
return requestCommentTypingReactionWithClient({
client,
fileToken: params.fileToken,
fileType: params.fileType,
replyId: params.replyId,
action: params.action,
runtime: params.runtime,
logPrefix: `feishu[${account.accountId}]`,
});
}
async function cleanupCommentTypingReactionByKey(params: {
key: string;
performDelete: () => Promise<boolean>;
}): Promise<boolean> {
const state = ensureCommentTypingReactionState(params.key);
if (state.cleaned) {
return false;
}
if (state.cleanupPromise) {
return await state.cleanupPromise;
}
const cleanupPromise = (async (): Promise<boolean> => {
if (!state.active) {
state.cleaned = true;
return false;
}
const deleted = await params.performDelete();
if (deleted) {
state.cleaned = true;
state.active = false;
}
return deleted;
})();
state.cleanupPromise = cleanupPromise;
try {
return await cleanupPromise;
} finally {
state.cleanupPromise = undefined;
if (state.cleaned) {
state.active = false;
commentTypingReactionState.delete(params.key);
}
}
}
export async function cleanupAmbientCommentTypingReaction(params: {
client: FeishuCommentReactionClient;
deliveryContext?: {
channel?: string;
to?: string;
threadId?: string | number;
};
runtime?: RuntimeEnv;
}): Promise<boolean> {
const deliveryContext = params.deliveryContext;
if (
deliveryContext?.channel &&
deliveryContext.channel !== "feishu" &&
deliveryContext.channel !== "feishu-comment"
) {
return false;
}
const target = parseFeishuCommentTarget(deliveryContext?.to);
const replyId =
typeof deliveryContext?.threadId === "string" || typeof deliveryContext?.threadId === "number"
? String(deliveryContext.threadId).trim()
: "";
if (!target || !replyId) {
return false;
}
const key = buildCommentTypingReactionKey({
fileToken: target.fileToken,
fileType: target.fileType,
replyId,
});
return cleanupCommentTypingReactionByKey({
key,
performDelete: () =>
requestCommentTypingReactionWithClient({
client: params.client,
fileToken: target.fileToken,
fileType: target.fileType,
replyId,
action: "delete",
runtime: params.runtime,
logPrefix: "[feishu]",
}),
});
}
export function createCommentTypingReactionLifecycle(params: {
cfg: ClawdbotConfig;
fileToken: string;
fileType: CommentFileType;
replyId?: string;
accountId?: string;
runtime?: RuntimeEnv;
}) {
const key = params.replyId?.trim()
? buildCommentTypingReactionKey({
fileToken: params.fileToken,
fileType: params.fileType,
replyId: params.replyId.trim(),
})
: undefined;
const state = key ? ensureCommentTypingReactionState(key) : undefined;
return {
start: async (): Promise<void> => {
const replyId = params.replyId?.trim();
if (!state || state.cleaned || state.active || !replyId) {
return;
}
state.active = await requestCommentTypingReaction({
cfg: params.cfg,
fileToken: params.fileToken,
fileType: params.fileType,
replyId,
action: "add",
accountId: params.accountId,
runtime: params.runtime,
});
},
cleanup: async (): Promise<void> => {
const replyId = params.replyId?.trim();
if (!key || !replyId) {
return;
}
await cleanupCommentTypingReactionByKey({
key,
performDelete: () =>
requestCommentTypingReaction({
cfg: params.cfg,
fileToken: params.fileToken,
fileType: params.fileType,
replyId,
action: "delete",
accountId: params.accountId,
runtime: params.runtime,
}),
});
},
};
}

View file

@ -0,0 +1,182 @@
import { describe, expect, it } from "vitest";
import {
parseCommentContentElements,
resolveCommentLinkedDocumentFromUrl,
} from "./comment-shared.js";
const VALID_TOKEN_22 = "ABCDEFGHIJKLMNOPQRSTUV";
const VALID_TOKEN_27 = "ZsJfdxrBFo0RwuxteOLc1Ekvneb";
describe("resolveCommentLinkedDocumentFromUrl", () => {
it.each([
{
label: "doc",
url: `https://example.test/doc/${VALID_TOKEN_22}`,
expectedKind: "doc",
expectedResolvedType: "doc",
expectedToken: VALID_TOKEN_22,
},
{
label: "docs",
url: `https://example.test/docs/${VALID_TOKEN_22}`,
expectedKind: "doc",
expectedResolvedType: "doc",
expectedToken: VALID_TOKEN_22,
},
{
label: "space/doc",
url: `https://example.test/space/doc/${VALID_TOKEN_22}`,
expectedKind: "doc",
expectedResolvedType: "doc",
expectedToken: VALID_TOKEN_22,
},
{
label: "sheet",
url: `https://example.test/sheet/${VALID_TOKEN_22}`,
expectedKind: "sheet",
expectedResolvedType: "sheet",
expectedToken: VALID_TOKEN_22,
},
{
label: "sheets",
url: `https://example.test/sheets/${VALID_TOKEN_22}`,
expectedKind: "sheet",
expectedResolvedType: "sheet",
expectedToken: VALID_TOKEN_22,
},
{
label: "space/sheet",
url: `https://example.test/space/sheet/${VALID_TOKEN_22}`,
expectedKind: "sheet",
expectedResolvedType: "sheet",
expectedToken: VALID_TOKEN_22,
},
{
label: "docx with hash",
url: `https://bytedance.larkoffice.com/docx/${VALID_TOKEN_27}#share-Huggdiqveo5N7NxyA01ck4gLnHh`,
expectedKind: "docx",
expectedResolvedType: "docx",
expectedToken: VALID_TOKEN_27,
},
{
label: "mindnote",
url: `https://example.test/mindnote/${VALID_TOKEN_22}`,
expectedKind: "mindnote",
expectedResolvedType: "mindnote",
expectedToken: VALID_TOKEN_22,
},
{
label: "mindnotes",
url: `https://example.test/mindnotes/${VALID_TOKEN_22}`,
expectedKind: "mindnote",
expectedResolvedType: "mindnote",
expectedToken: VALID_TOKEN_22,
},
{
label: "space/mindnote",
url: `https://example.test/space/mindnote/${VALID_TOKEN_22}`,
expectedKind: "mindnote",
expectedResolvedType: "mindnote",
expectedToken: VALID_TOKEN_22,
},
{
label: "bitable",
url: `https://example.test/bitable/${VALID_TOKEN_22}?table=tbl_123`,
expectedKind: "bitable",
expectedResolvedType: "bitable",
expectedToken: VALID_TOKEN_22,
},
{
label: "base",
url: `https://example.test/base/${VALID_TOKEN_22}`,
expectedKind: "base",
expectedResolvedType: "base",
expectedToken: VALID_TOKEN_22,
},
{
label: "space/bitable",
url: `https://example.test/space/bitable/${VALID_TOKEN_22}`,
expectedKind: "bitable",
expectedResolvedType: "bitable",
expectedToken: VALID_TOKEN_22,
},
{
label: "file",
url: `https://example.test/file/${VALID_TOKEN_22}`,
expectedKind: "file",
expectedResolvedType: "file",
expectedToken: VALID_TOKEN_22,
},
{
label: "space/file",
url: `https://example.test/space/file/${VALID_TOKEN_22}`,
expectedKind: "file",
expectedResolvedType: "file",
expectedToken: VALID_TOKEN_22,
},
{
label: "wiki",
url: `https://example.test/wiki/${VALID_TOKEN_22}`,
expectedKind: "wiki",
expectedResolvedType: undefined,
expectedToken: VALID_TOKEN_22,
},
{
label: "space/wiki",
url: `https://example.test/space/wiki/${VALID_TOKEN_22}`,
expectedKind: "wiki",
expectedResolvedType: undefined,
expectedToken: VALID_TOKEN_22,
},
])("$label", ({ url, expectedKind, expectedResolvedType, expectedToken }) => {
const linked = resolveCommentLinkedDocumentFromUrl({ rawUrl: url });
expect(linked.urlKind).toBe(expectedKind);
expect(linked.resolvedObjType).toBe(expectedResolvedType);
expect(linked.resolvedObjToken ?? linked.wikiNodeToken).toBe(expectedToken);
});
it("does not resolve doc-like paths with short tokens", () => {
expect(
resolveCommentLinkedDocumentFromUrl({
rawUrl: "https://www.baidu.com/docx/guide",
}),
).toEqual({
rawUrl: "https://www.baidu.com/docx/guide",
urlKind: "unknown",
});
});
});
describe("parseCommentContentElements", () => {
it("keeps raw external urls in text but excludes unresolved links from structured references", () => {
const parsed = parseCommentContentElements({
elements: [
{
type: "docs_link",
docs_link: { url: `https://bytedance.larkoffice.com/docx/${VALID_TOKEN_27}` },
},
{
type: "text_run",
text_run: { text: " 和 " },
},
{
type: "docs_link",
docs_link: { url: "https://www.baidu.com/docx/guide" },
},
],
});
expect(parsed.plainText).toBe(
`https://bytedance.larkoffice.com/docx/${VALID_TOKEN_27} 和 https://www.baidu.com/docx/guide`,
);
expect(parsed.linkedDocuments).toEqual([
expect.objectContaining({
rawUrl: `https://bytedance.larkoffice.com/docx/${VALID_TOKEN_27}`,
urlKind: "docx",
resolvedObjType: "docx",
resolvedObjToken: VALID_TOKEN_27,
}),
]);
});
});

View file

@ -0,0 +1,337 @@
import {
asOptionalRecord,
hasNonEmptyString as sharedHasNonEmptyString,
isRecord as sharedIsRecord,
normalizeOptionalString,
readStringValue,
} from "openclaw/plugin-sdk/text-runtime";
import { FEISHU_COMMENT_FILE_TYPES, type CommentFileType } from "./comment-target.js";
export function encodeQuery(params: Record<string, string | undefined>): string {
const query = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
const trimmed = value?.trim();
if (trimmed) {
query.set(key, trimmed);
}
}
const queryString = query.toString();
return queryString ? `?${queryString}` : "";
}
export const readString = readStringValue;
export const normalizeString = normalizeOptionalString;
export const isRecord = sharedIsRecord;
export const asRecord = asOptionalRecord;
export const hasNonEmptyString = sharedHasNonEmptyString;
export type ParsedCommentDocumentRef = {
fileType?: CommentFileType;
fileToken?: string;
};
export type ParsedCommentMention = {
userId: string;
displayText: string;
isBotMention: boolean;
};
export type ParsedCommentLinkedDocumentKind =
| CommentFileType
| "wiki"
| "mindnote"
| "bitable"
| "base"
| "unknown";
export type ParsedCommentResolvedDocumentType = Exclude<
ParsedCommentLinkedDocumentKind,
"wiki" | "unknown"
>;
export type ParsedCommentLinkedDocument = {
rawUrl: string;
urlKind: ParsedCommentLinkedDocumentKind;
wikiNodeToken?: string;
resolvedObjType?: ParsedCommentResolvedDocumentType;
resolvedObjToken?: string;
isCurrentDocument?: boolean;
};
export type ParsedCommentContent = {
plainText?: string;
semanticText?: string;
mentions: ParsedCommentMention[];
linkedDocuments: ParsedCommentLinkedDocument[];
botMentioned: boolean;
};
function readDocsLinkUrl(element: Record<string, unknown>): string | undefined {
const docsLink = isRecord(element.docs_link) ? element.docs_link : undefined;
return (
normalizeString(docsLink?.url) ||
normalizeString(docsLink?.link) ||
normalizeString(element.url) ||
normalizeString(element.link) ||
undefined
);
}
function readMentionUserId(element: Record<string, unknown>): string | undefined {
const mention = isRecord(element.mention) ? element.mention : undefined;
const person = isRecord(element.person) ? element.person : undefined;
return (
normalizeString(person?.user_id) ||
normalizeString(mention?.user_id) ||
normalizeString(mention?.open_id) ||
normalizeString(element.mention_user) ||
normalizeString(element.user_id) ||
undefined
);
}
function readMentionDisplayText(element: Record<string, unknown>, userId: string): string {
const mention = isRecord(element.mention) ? element.mention : undefined;
const mentionName =
normalizeString(mention?.name) ||
normalizeString(mention?.display_name) ||
normalizeString(element.name);
return mentionName ? `@${mentionName}` : `@${userId}`;
}
function normalizeCommentText(parts: string[]): string | undefined {
const text = parts.join("").trim();
return text || undefined;
}
function normalizeCommentSemanticText(parts: string[]): string | undefined {
const text = parts.join("").replace(/\s+/g, " ").trim();
return text || undefined;
}
function readElementTextPreservingWhitespace(element: Record<string, unknown>): string | undefined {
return (
(isRecord(element.text_run)
? readString(element.text_run.content) || readString(element.text_run.text)
: undefined) ||
readString(element.text) ||
readString(element.content) ||
readString(element.name) ||
undefined
);
}
const FEISHU_LINK_TOKEN_MIN_LENGTH = 22;
const FEISHU_LINK_TOKEN_MAX_LENGTH = 28;
const COMMENT_LINK_KIND_ALIASES = new Map<string, ParsedCommentResolvedDocumentType | "wiki">([
["doc", "doc"],
["docs", "doc"],
["docx", "docx"],
["sheet", "sheet"],
["sheets", "sheet"],
["slide", "slides"],
["slides", "slides"],
["file", "file"],
["files", "file"],
["wiki", "wiki"],
["mindnote", "mindnote"],
["mindnotes", "mindnote"],
["bitable", "bitable"],
["base", "base"],
]);
function isCommentFileType(
value: ParsedCommentResolvedDocumentType | "wiki" | undefined,
): value is CommentFileType {
return (
typeof value === "string" && (FEISHU_COMMENT_FILE_TYPES as readonly string[]).includes(value)
);
}
function isReasonableFeishuLinkToken(token: string | undefined): token is string {
return (
typeof token === "string" &&
token.length >= FEISHU_LINK_TOKEN_MIN_LENGTH &&
token.length <= FEISHU_LINK_TOKEN_MAX_LENGTH
);
}
function parseCommentLinkedDocumentPath(pathname: string): {
urlKind: ParsedCommentResolvedDocumentType | "wiki";
token: string;
} | null {
const segments = pathname
.split("/")
.map((segment) => segment.trim())
.filter(Boolean);
const offset = segments[0]?.toLowerCase() === "space" ? 1 : 0;
const kind = COMMENT_LINK_KIND_ALIASES.get(segments[offset]?.toLowerCase() ?? "");
const token = normalizeString(segments[offset + 1]);
if (!kind || !isReasonableFeishuLinkToken(token)) {
return null;
}
return { urlKind: kind, token };
}
function hasResolvedLinkedDocumentReference(link: ParsedCommentLinkedDocument): boolean {
return (
link.urlKind !== "unknown" && (Boolean(link.resolvedObjToken) || Boolean(link.wikiNodeToken))
);
}
export function resolveCommentLinkedDocumentFromUrl(params: {
rawUrl: string;
currentDocument?: ParsedCommentDocumentRef;
}): ParsedCommentLinkedDocument {
const link: ParsedCommentLinkedDocument = {
rawUrl: params.rawUrl,
urlKind: "unknown",
};
try {
const parsed = new URL(params.rawUrl);
const parsedPath = parseCommentLinkedDocumentPath(parsed.pathname);
if (!parsedPath) {
return link;
}
const { urlKind, token } = parsedPath;
link.urlKind = urlKind;
if (urlKind === "wiki") {
link.urlKind = "wiki";
link.wikiNodeToken = token;
} else {
link.resolvedObjType = urlKind;
link.resolvedObjToken = token;
}
if (
link.resolvedObjType &&
link.resolvedObjToken &&
isCommentFileType(link.resolvedObjType) &&
params.currentDocument?.fileType === link.resolvedObjType &&
params.currentDocument.fileToken === link.resolvedObjToken
) {
link.isCurrentDocument = true;
} else if (
link.resolvedObjType &&
link.resolvedObjToken &&
isCommentFileType(link.resolvedObjType)
) {
link.isCurrentDocument = false;
}
} catch {
return link;
}
return link;
}
export function parseCommentContentElements(params: {
elements?: unknown[];
botOpenIds?: Iterable<string | undefined>;
currentDocument?: ParsedCommentDocumentRef;
}): ParsedCommentContent {
const elements = Array.isArray(params.elements) ? params.elements : [];
const plainTextParts: string[] = [];
const semanticTextParts: string[] = [];
const mentions: ParsedCommentMention[] = [];
const linkedDocuments: ParsedCommentLinkedDocument[] = [];
const botIds = new Set(
Array.from(params.botOpenIds ?? [])
.map((value) => normalizeString(value))
.filter((value): value is string => Boolean(value)),
);
const linkedDocumentKeys = new Set<string>();
let botMentioned = false;
for (const rawElement of elements) {
if (!isRecord(rawElement)) {
continue;
}
const element = rawElement;
const type = normalizeString(element.type);
const text =
(type === "text_run" ? readElementTextPreservingWhitespace(element) : undefined) ||
(type === "text" ? readElementTextPreservingWhitespace(element) : undefined) ||
(type === "docs_link" || type === "link" ? readDocsLinkUrl(element) : undefined) ||
(type === "mention" || type === "mention_user" || type === "person"
? (() => {
const userId = readMentionUserId(element);
return userId ? readMentionDisplayText(element, userId) : undefined;
})()
: undefined) ||
readElementTextPreservingWhitespace(element) ||
undefined;
if (type === "mention" || type === "mention_user" || type === "person") {
const userId = readMentionUserId(element);
if (userId) {
const displayText = readMentionDisplayText(element, userId);
const isBotMention = botIds.has(userId);
mentions.push({ userId, displayText, isBotMention });
plainTextParts.push(displayText);
if (!isBotMention) {
semanticTextParts.push(displayText);
} else {
botMentioned = true;
}
continue;
}
}
if (type === "docs_link" || type === "link") {
const rawUrl = readDocsLinkUrl(element);
if (rawUrl) {
plainTextParts.push(rawUrl);
semanticTextParts.push(rawUrl);
const linkedDocument = resolveCommentLinkedDocumentFromUrl({
rawUrl,
currentDocument: params.currentDocument,
});
if (hasResolvedLinkedDocumentReference(linkedDocument)) {
const key = [
linkedDocument.rawUrl,
linkedDocument.urlKind,
linkedDocument.resolvedObjType,
linkedDocument.resolvedObjToken,
linkedDocument.wikiNodeToken,
].join(":");
if (!linkedDocumentKeys.has(key)) {
linkedDocumentKeys.add(key);
linkedDocuments.push(linkedDocument);
}
}
continue;
}
}
if (text) {
plainTextParts.push(text);
semanticTextParts.push(text);
}
}
return {
plainText: normalizeCommentText(plainTextParts),
semanticText: normalizeCommentSemanticText(semanticTextParts),
mentions,
linkedDocuments,
botMentioned,
};
}
export function extractCommentElementText(element: unknown): string | undefined {
return parseCommentContentElements({ elements: [element] }).plainText;
}
export function extractReplyText(
reply: { content?: { elements?: unknown[] } } | undefined,
): string | undefined {
if (!reply || !isRecord(reply.content)) {
return undefined;
}
return parseCommentContentElements({
elements: Array.isArray(reply.content.elements) ? reply.content.elements : [],
}).plainText;
}

View file

@ -0,0 +1,44 @@
export const FEISHU_COMMENT_FILE_TYPES = ["doc", "docx", "file", "sheet", "slides"] as const;
export type CommentFileType = (typeof FEISHU_COMMENT_FILE_TYPES)[number];
export function normalizeCommentFileType(value: unknown): CommentFileType | undefined {
return typeof value === "string" &&
(FEISHU_COMMENT_FILE_TYPES as readonly string[]).includes(value)
? (value as CommentFileType)
: undefined;
}
export type FeishuCommentTarget = {
fileType: CommentFileType;
fileToken: string;
commentId: string;
};
export function buildFeishuCommentTarget(params: FeishuCommentTarget): string {
return `comment:${params.fileType}:${params.fileToken}:${params.commentId}`;
}
export function parseFeishuCommentTarget(
raw: string | undefined | null,
): FeishuCommentTarget | null {
const trimmed = raw?.trim();
if (!trimmed?.startsWith("comment:")) {
return null;
}
const parts = trimmed.split(":");
if (parts.length !== 4) {
return null;
}
const fileType = normalizeCommentFileType(parts[1]);
const fileToken = parts[2]?.trim();
const commentId = parts[3]?.trim();
if (!fileType || !fileToken || !commentId) {
return null;
}
return {
fileType,
fileToken,
commentId,
};
}

View file

@ -0,0 +1,270 @@
import { describe, expect, it } from "vitest";
import { FeishuConfigSchema, FeishuGroupSchema } from "./config-schema.js";
function expectSchemaIssue(
result: ReturnType<typeof FeishuConfigSchema.safeParse>,
issuePath: string,
) {
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues.some((issue) => issue.path.join(".") === issuePath)).toBe(true);
}
}
describe("FeishuConfigSchema webhook validation", () => {
it("applies top-level defaults", () => {
const result = FeishuConfigSchema.parse({});
expect(result.domain).toBe("feishu");
expect(result.connectionMode).toBe("websocket");
expect(result.webhookPath).toBe("/feishu/events");
expect(result.dmPolicy).toBe("pairing");
expect(result.groupPolicy).toBe("allowlist");
// requireMention has no schema-level default now — it is resolved at runtime
// through shared channel group-policy resolution, with an open-group override
// that defaults to false only when requireMention is otherwise unset.
expect(result.requireMention).toBeUndefined();
});
it("does not force top-level policy defaults into account config", () => {
const result = FeishuConfigSchema.parse({
accounts: {
main: {},
},
});
expect(result.accounts?.main?.dmPolicy).toBeUndefined();
expect(result.accounts?.main?.groupPolicy).toBeUndefined();
expect(result.accounts?.main?.requireMention).toBeUndefined();
});
it("normalizes legacy groupPolicy allowall to open", () => {
const result = FeishuConfigSchema.parse({
groupPolicy: "allowall",
});
expect(result.groupPolicy).toBe("open");
});
it("rejects top-level webhook mode without verificationToken", () => {
const result = FeishuConfigSchema.safeParse({
connectionMode: "webhook",
appId: "cli_top",
appSecret: "secret_top", // pragma: allowlist secret
});
expectSchemaIssue(result, "verificationToken");
});
it("rejects top-level webhook mode without encryptKey", () => {
const result = FeishuConfigSchema.safeParse({
connectionMode: "webhook",
verificationToken: "token_top",
appId: "cli_top",
appSecret: "secret_top", // pragma: allowlist secret
});
expectSchemaIssue(result, "encryptKey");
});
it("accepts top-level webhook mode with verificationToken and encryptKey", () => {
const result = FeishuConfigSchema.safeParse({
connectionMode: "webhook",
verificationToken: "token_top",
encryptKey: "encrypt_top",
appId: "cli_top",
appSecret: "secret_top", // pragma: allowlist secret
});
expect(result.success).toBe(true);
});
it("rejects account webhook mode without verificationToken", () => {
const result = FeishuConfigSchema.safeParse({
accounts: {
main: {
connectionMode: "webhook",
appId: "cli_main",
appSecret: "secret_main", // pragma: allowlist secret
},
},
});
expectSchemaIssue(result, "accounts.main.verificationToken");
});
it("rejects account webhook mode without encryptKey", () => {
const result = FeishuConfigSchema.safeParse({
accounts: {
main: {
connectionMode: "webhook",
verificationToken: "token_main",
appId: "cli_main",
appSecret: "secret_main", // pragma: allowlist secret
},
},
});
expectSchemaIssue(result, "accounts.main.encryptKey");
});
it("accepts account webhook mode inheriting top-level verificationToken and encryptKey", () => {
const result = FeishuConfigSchema.safeParse({
verificationToken: "token_top",
encryptKey: "encrypt_top",
accounts: {
main: {
connectionMode: "webhook",
appId: "cli_main",
appSecret: "secret_main", // pragma: allowlist secret
},
},
});
expect(result.success).toBe(true);
});
it("accepts SecretRef verificationToken in webhook mode", () => {
const result = FeishuConfigSchema.safeParse({
connectionMode: "webhook",
verificationToken: {
source: "env",
provider: "default",
id: "FEISHU_VERIFICATION_TOKEN",
},
encryptKey: "encrypt_top",
appId: "cli_top",
appSecret: {
source: "env",
provider: "default",
id: "FEISHU_APP_SECRET",
},
});
expect(result.success).toBe(true);
});
it("accepts SecretRef encryptKey in webhook mode", () => {
const result = FeishuConfigSchema.safeParse({
connectionMode: "webhook",
verificationToken: {
source: "env",
provider: "default",
id: "FEISHU_VERIFICATION_TOKEN",
},
encryptKey: {
source: "env",
provider: "default",
id: "FEISHU_ENCRYPT_KEY",
},
appId: "cli_top",
appSecret: {
source: "env",
provider: "default",
id: "FEISHU_APP_SECRET",
},
});
expect(result.success).toBe(true);
});
});
describe("FeishuConfigSchema replyInThread", () => {
it("accepts replyInThread at top level", () => {
const result = FeishuConfigSchema.parse({ replyInThread: "enabled" });
expect(result.replyInThread).toBe("enabled");
});
it("defaults replyInThread to undefined when not set", () => {
const result = FeishuConfigSchema.parse({});
expect(result.replyInThread).toBeUndefined();
});
it("rejects invalid replyInThread value", () => {
const result = FeishuConfigSchema.safeParse({ replyInThread: "always" });
expect(result.success).toBe(false);
});
it("accepts replyInThread in group config", () => {
const result = FeishuGroupSchema.parse({ replyInThread: "enabled" });
expect(result.replyInThread).toBe("enabled");
});
it("accepts replyInThread in account config", () => {
const result = FeishuConfigSchema.parse({
accounts: {
main: { replyInThread: "enabled" },
},
});
expect(result.accounts?.main?.replyInThread).toBe("enabled");
});
});
describe("FeishuConfigSchema optimization flags", () => {
it("defaults top-level typingIndicator and resolveSenderNames to true", () => {
const result = FeishuConfigSchema.parse({});
expect(result.typingIndicator).toBe(true);
expect(result.resolveSenderNames).toBe(true);
});
it("accepts account-level optimization flags", () => {
const result = FeishuConfigSchema.parse({
accounts: {
main: {
typingIndicator: false,
resolveSenderNames: false,
},
},
});
expect(result.accounts?.main?.typingIndicator).toBe(false);
expect(result.accounts?.main?.resolveSenderNames).toBe(false);
});
});
describe("FeishuConfigSchema actions", () => {
it("accepts top-level reactions action gate", () => {
const result = FeishuConfigSchema.parse({
actions: { reactions: false },
});
expect(result.actions?.reactions).toBe(false);
});
it("accepts account-level reactions action gate", () => {
const result = FeishuConfigSchema.parse({
accounts: {
main: {
actions: { reactions: false },
},
},
});
expect(result.accounts?.main?.actions?.reactions).toBe(false);
});
});
describe("FeishuConfigSchema defaultAccount", () => {
it("accepts defaultAccount when it matches an account key", () => {
const result = FeishuConfigSchema.safeParse({
defaultAccount: "router-d",
accounts: {
"router-d": { appId: "cli_router", appSecret: "secret_router" }, // pragma: allowlist secret
},
});
expect(result.success).toBe(true);
});
it("rejects defaultAccount when it does not match an account key", () => {
const result = FeishuConfigSchema.safeParse({
defaultAccount: "router-d",
accounts: {
backup: { appId: "cli_backup", appSecret: "secret_backup" }, // pragma: allowlist secret
},
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues.some((issue) => issue.path.join(".") === "defaultAccount")).toBe(
true,
);
}
});
});

View file

@ -0,0 +1,314 @@
import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import { z } from "openclaw/plugin-sdk/zod";
export { z };
import { buildSecretInputSchema, hasConfiguredSecretInput } from "./secret-input.js";
const ChannelActionsSchema = z
.object({
reactions: z.boolean().optional(),
})
.strict()
.optional();
const DmPolicySchema = z.enum(["open", "pairing", "allowlist"]);
const GroupPolicySchema = z.union([
z.enum(["open", "allowlist", "disabled"]),
z.literal("allowall").transform(() => "open" as const),
]);
const FeishuDomainSchema = z.union([
z.enum(["feishu", "lark"]),
z.string().url().startsWith("https://"),
]);
const FeishuConnectionModeSchema = z.enum(["websocket", "webhook"]);
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(),
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();
// Streaming card mode: when enabled, card replies use Feishu's Card Kit streaming API
// for incremental text display with a "Thinking..." placeholder
const StreamingModeSchema = z.boolean().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();
/**
* Dynamic agent creation configuration.
* When enabled, a new agent is created for each unique DM user.
*/
const DynamicAgentCreationSchema = z
.object({
enabled: z.boolean().optional(),
workspaceTemplate: z.string().optional(),
agentDirTemplate: z.string().optional(),
maxAgents: 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)
chat: z.boolean().optional(), // Chat info + member query 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();
/**
* Group session scope for routing Feishu group messages.
* - "group" (default): one session per group chat
* - "group_sender": one session per (group + sender)
* - "group_topic": one session per group topic thread (falls back to group if no topic)
* - "group_topic_sender": one session per (group + topic thread + sender),
* falls back to (group + sender) if no topic
*/
const GroupSessionScopeSchema = z
.enum(["group", "group_sender", "group_topic", "group_topic_sender"])
.optional();
/**
* @deprecated Use groupSessionScope instead.
*
* Topic session isolation mode for group chats.
* - "disabled" (default): All messages in a group share one session
* - "enabled": Messages in different topics get separate sessions
*
* Topic routing uses `root_id` when present to keep session continuity and
* falls back to `thread_id` when `root_id` is unavailable.
*/
const TopicSessionModeSchema = z.enum(["disabled", "enabled"]).optional();
const ReactionNotificationModeSchema = z.enum(["off", "own", "all"]).optional();
/**
* Reply-in-thread mode for group chats.
* - "disabled" (default): Bot replies are normal inline replies
* - "enabled": Bot replies create or continue a Feishu topic thread
*
* When enabled, the Feishu reply API is called with `reply_in_thread: true`,
* causing the reply to appear as a topic (话题) under the original message.
*/
const ReplyInThreadSchema = z.enum(["disabled", "enabled"]).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(),
groupSessionScope: GroupSessionScopeSchema,
topicSessionMode: TopicSessionModeSchema,
replyInThread: ReplyInThreadSchema,
})
.strict();
const FeishuSharedConfigShape = {
webhookHost: z.string().optional(),
webhookPort: z.number().int().positive().optional(),
capabilities: z.array(z.string()).optional(),
markdown: MarkdownConfigSchema,
configWrites: z.boolean().optional(),
dmPolicy: DmPolicySchema.optional(),
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
groupPolicy: GroupPolicySchema.optional(),
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
groupSenderAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
requireMention: z.boolean().optional(),
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(),
httpTimeoutMs: z.number().int().positive().max(300_000).optional(),
heartbeat: ChannelHeartbeatVisibilitySchema,
renderMode: RenderModeSchema,
streaming: StreamingModeSchema,
tools: FeishuToolsConfigSchema,
actions: ChannelActionsSchema,
replyInThread: ReplyInThreadSchema,
reactionNotifications: ReactionNotificationModeSchema,
typingIndicator: z.boolean().optional(),
resolveSenderNames: z.boolean().optional(),
};
/**
* Per-account configuration.
* All fields are optional - missing fields inherit from top-level config.
*/
export const FeishuAccountConfigSchema = z
.object({
enabled: z.boolean().optional(),
name: z.string().optional(), // Display name for this account
appId: z.string().optional(),
appSecret: buildSecretInputSchema().optional(),
encryptKey: buildSecretInputSchema().optional(),
verificationToken: buildSecretInputSchema().optional(),
domain: FeishuDomainSchema.optional(),
connectionMode: FeishuConnectionModeSchema.optional(),
webhookPath: z.string().optional(),
...FeishuSharedConfigShape,
groupSessionScope: GroupSessionScopeSchema,
topicSessionMode: TopicSessionModeSchema,
})
.strict();
export const FeishuConfigSchema = z
.object({
enabled: z.boolean().optional(),
defaultAccount: z.string().optional(),
// Top-level credentials (backward compatible for single-account mode)
appId: z.string().optional(),
appSecret: buildSecretInputSchema().optional(),
encryptKey: buildSecretInputSchema().optional(),
verificationToken: buildSecretInputSchema().optional(),
domain: FeishuDomainSchema.optional().default("feishu"),
connectionMode: FeishuConnectionModeSchema.optional().default("websocket"),
webhookPath: z.string().optional().default("/feishu/events"),
...FeishuSharedConfigShape,
dmPolicy: DmPolicySchema.optional().default("pairing"),
reactionNotifications: ReactionNotificationModeSchema.optional().default("own"),
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
requireMention: z.boolean().optional(),
groupSessionScope: GroupSessionScopeSchema,
topicSessionMode: TopicSessionModeSchema,
// Dynamic agent creation for DM users
dynamicAgentCreation: DynamicAgentCreationSchema,
// Optimization flags
typingIndicator: z.boolean().optional().default(true),
resolveSenderNames: z.boolean().optional().default(true),
// Multi-account configuration
accounts: z.record(z.string(), FeishuAccountConfigSchema.optional()).optional(),
})
.strict()
.superRefine((value, ctx) => {
const defaultAccount = value.defaultAccount?.trim();
if (defaultAccount && value.accounts && Object.keys(value.accounts).length > 0) {
const normalizedDefaultAccount = normalizeAccountId(defaultAccount);
if (!Object.prototype.hasOwnProperty.call(value.accounts, normalizedDefaultAccount)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["defaultAccount"],
message: `channels.feishu.defaultAccount="${defaultAccount}" does not match a configured account key`,
});
}
}
const defaultConnectionMode = value.connectionMode ?? "websocket";
const defaultVerificationTokenConfigured = hasConfiguredSecretInput(value.verificationToken);
const defaultEncryptKeyConfigured = hasConfiguredSecretInput(value.encryptKey);
if (defaultConnectionMode === "webhook") {
if (!defaultVerificationTokenConfigured) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["verificationToken"],
message:
'channels.feishu.connectionMode="webhook" requires channels.feishu.verificationToken',
});
}
if (!defaultEncryptKeyConfigured) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["encryptKey"],
message: 'channels.feishu.connectionMode="webhook" requires channels.feishu.encryptKey',
});
}
}
for (const [accountId, account] of Object.entries(value.accounts ?? {})) {
if (!account) {
continue;
}
const accountConnectionMode = account.connectionMode ?? defaultConnectionMode;
if (accountConnectionMode !== "webhook") {
continue;
}
const accountVerificationTokenConfigured =
hasConfiguredSecretInput(account.verificationToken) || defaultVerificationTokenConfigured;
const accountEncryptKeyConfigured =
hasConfiguredSecretInput(account.encryptKey) || defaultEncryptKeyConfigured;
if (!accountVerificationTokenConfigured) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["accounts", accountId, "verificationToken"],
message:
`channels.feishu.accounts.${accountId}.connectionMode="webhook" requires ` +
"a verificationToken (account-level or top-level)",
});
}
if (!accountEncryptKeyConfigured) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["accounts", accountId, "encryptKey"],
message:
`channels.feishu.accounts.${accountId}.connectionMode="webhook" requires ` +
"an encryptKey (account-level or top-level)",
});
}
}
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,199 @@
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
export type FeishuGroupSessionScope =
| "group"
| "group_sender"
| "group_topic"
| "group_topic_sender";
function normalizeText(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed || undefined;
}
export function buildFeishuConversationId(params: {
chatId: string;
scope: FeishuGroupSessionScope;
senderOpenId?: string;
topicId?: string;
}): string {
const chatId = normalizeText(params.chatId) ?? "unknown";
const senderOpenId = normalizeText(params.senderOpenId);
const topicId = normalizeText(params.topicId);
switch (params.scope) {
case "group_sender":
return senderOpenId ? `${chatId}:sender:${senderOpenId}` : chatId;
case "group_topic":
return topicId ? `${chatId}:topic:${topicId}` : chatId;
case "group_topic_sender":
if (topicId && senderOpenId) {
return `${chatId}:topic:${topicId}:sender:${senderOpenId}`;
}
if (topicId) {
return `${chatId}:topic:${topicId}`;
}
return senderOpenId ? `${chatId}:sender:${senderOpenId}` : chatId;
case "group":
default:
return chatId;
}
}
export function parseFeishuTargetId(raw: unknown): string | undefined {
const target = normalizeText(raw);
if (!target) {
return undefined;
}
const withoutProvider = target.replace(/^(feishu|lark):/i, "").trim();
if (!withoutProvider) {
return undefined;
}
const lowered = normalizeLowercaseStringOrEmpty(withoutProvider);
for (const prefix of ["chat:", "group:", "channel:", "user:", "dm:", "open_id:"]) {
if (lowered.startsWith(prefix)) {
return normalizeText(withoutProvider.slice(prefix.length));
}
}
return withoutProvider;
}
export function parseFeishuDirectConversationId(raw: unknown): string | undefined {
const target = normalizeText(raw);
if (!target) {
return undefined;
}
const withoutProvider = target.replace(/^(feishu|lark):/i, "").trim();
if (!withoutProvider) {
return undefined;
}
const lowered = normalizeLowercaseStringOrEmpty(withoutProvider);
for (const prefix of ["user:", "dm:", "open_id:"]) {
if (lowered.startsWith(prefix)) {
return normalizeText(withoutProvider.slice(prefix.length));
}
}
const id = parseFeishuTargetId(target);
if (!id) {
return undefined;
}
if (id.startsWith("ou_") || id.startsWith("on_")) {
return id;
}
return undefined;
}
export function parseFeishuConversationId(params: {
conversationId: string;
parentConversationId?: string;
}): {
canonicalConversationId: string;
chatId: string;
topicId?: string;
senderOpenId?: string;
scope: FeishuGroupSessionScope;
} | null {
const conversationId = normalizeText(params.conversationId);
const parentConversationId = normalizeText(params.parentConversationId);
if (!conversationId) {
return null;
}
const topicSenderMatch = conversationId.match(/^(.+):topic:([^:]+):sender:([^:]+)$/i);
if (topicSenderMatch) {
const [, chatId, topicId, senderOpenId] = topicSenderMatch;
return {
canonicalConversationId: buildFeishuConversationId({
chatId,
scope: "group_topic_sender",
topicId,
senderOpenId,
}),
chatId,
topicId,
senderOpenId,
scope: "group_topic_sender",
};
}
const topicMatch = conversationId.match(/^(.+):topic:([^:]+)$/i);
if (topicMatch) {
const [, chatId, topicId] = topicMatch;
return {
canonicalConversationId: buildFeishuConversationId({
chatId,
scope: "group_topic",
topicId,
}),
chatId,
topicId,
scope: "group_topic",
};
}
const senderMatch = conversationId.match(/^(.+):sender:([^:]+)$/i);
if (senderMatch) {
const [, chatId, senderOpenId] = senderMatch;
return {
canonicalConversationId: buildFeishuConversationId({
chatId,
scope: "group_sender",
senderOpenId,
}),
chatId,
senderOpenId,
scope: "group_sender",
};
}
if (parentConversationId) {
return {
canonicalConversationId: buildFeishuConversationId({
chatId: parentConversationId,
scope: "group_topic",
topicId: conversationId,
}),
chatId: parentConversationId,
topicId: conversationId,
scope: "group_topic",
};
}
return {
canonicalConversationId: conversationId,
chatId: conversationId,
scope: "group",
};
}
export function buildFeishuModelOverrideParentCandidates(
parentConversationId?: string | null,
): string[] {
const rawId = normalizeText(parentConversationId);
if (!rawId) {
return [];
}
const topicSenderMatch = rawId.match(/^(.+):topic:([^:]+):sender:([^:]+)$/i);
if (topicSenderMatch) {
const chatId = normalizeLowercaseStringOrEmpty(topicSenderMatch[1]);
const topicId = normalizeLowercaseStringOrEmpty(topicSenderMatch[2]);
if (chatId && topicId) {
return [`${chatId}:topic:${topicId}`, chatId];
}
return [];
}
const topicMatch = rawId.match(/^(.+):topic:([^:]+)$/i);
if (topicMatch) {
const chatId = normalizeLowercaseStringOrEmpty(topicMatch[1]);
return chatId ? [chatId] : [];
}
const senderMatch = rawId.match(/^(.+):sender:([^:]+)$/i);
if (senderMatch) {
const chatId = normalizeLowercaseStringOrEmpty(senderMatch[1]);
return chatId ? [chatId] : [];
}
return [];
}

View file

@ -0,0 +1,5 @@
export {
createDedupeCache,
createPersistentDedupe,
readJsonFileWithFallback,
} from "../runtime-api.js";

View file

@ -0,0 +1,166 @@
import os from "node:os";
import path from "node:path";
import { createDedupeCache, createPersistentDedupe } from "./dedup-runtime-api.js";
// Persistent TTL: 24 hours — survives restarts & WebSocket reconnects.
const DEDUP_TTL_MS = 24 * 60 * 60 * 1000;
const MEMORY_MAX_SIZE = 1_000;
const FILE_MAX_ENTRIES = 10_000;
const EVENT_DEDUP_TTL_MS = 5 * 60 * 1000;
const EVENT_MEMORY_MAX_SIZE = 2_000;
const processingClaims = createDedupeCache({
ttlMs: EVENT_DEDUP_TTL_MS,
maxSize: EVENT_MEMORY_MAX_SIZE,
});
function resolveStateDirFromEnv(env: NodeJS.ProcessEnv = process.env): string {
const stateOverride = env.OPENCLAW_STATE_DIR?.trim();
if (stateOverride) {
return stateOverride;
}
if (env.VITEST || env.NODE_ENV === "test") {
return path.join(os.tmpdir(), ["openclaw-vitest", String(process.pid)].join("-"));
}
return path.join(os.homedir(), ".openclaw");
}
function resolveNamespaceFilePath(namespace: string): string {
const safe = namespace.replace(/[^a-zA-Z0-9_-]/g, "_");
return path.join(resolveStateDirFromEnv(), "feishu", "dedup", `${safe}.json`);
}
const persistentDedupe = createPersistentDedupe({
ttlMs: DEDUP_TTL_MS,
memoryMaxSize: MEMORY_MAX_SIZE,
fileMaxEntries: FILE_MAX_ENTRIES,
resolveFilePath: resolveNamespaceFilePath,
});
function resolveEventDedupeKey(
namespace: string,
messageId: string | undefined | null,
): string | null {
const trimmed = messageId?.trim();
if (!trimmed) {
return null;
}
return `${namespace}:${trimmed}`;
}
function normalizeMessageId(messageId: string | undefined | null): string | null {
const trimmed = messageId?.trim();
return trimmed ? trimmed : null;
}
export function tryBeginFeishuMessageProcessing(
messageId: string | undefined | null,
namespace = "global",
): boolean {
return !processingClaims.check(resolveEventDedupeKey(namespace, messageId));
}
export function releaseFeishuMessageProcessing(
messageId: string | undefined | null,
namespace = "global",
): void {
processingClaims.delete(resolveEventDedupeKey(namespace, messageId));
}
export async function claimUnprocessedFeishuMessage(params: {
messageId: string | undefined | null;
namespace?: string;
log?: (...args: unknown[]) => void;
}): Promise<"claimed" | "duplicate" | "inflight" | "invalid"> {
const { messageId, namespace = "global", log } = params;
const normalizedMessageId = normalizeMessageId(messageId);
if (!normalizedMessageId) {
return "invalid";
}
if (await hasProcessedFeishuMessage(normalizedMessageId, namespace, log)) {
return "duplicate";
}
if (!tryBeginFeishuMessageProcessing(normalizedMessageId, namespace)) {
return "inflight";
}
return "claimed";
}
export async function finalizeFeishuMessageProcessing(params: {
messageId: string | undefined | null;
namespace?: string;
log?: (...args: unknown[]) => void;
claimHeld?: boolean;
}): Promise<boolean> {
const { messageId, namespace = "global", log, claimHeld = false } = params;
const normalizedMessageId = normalizeMessageId(messageId);
if (!normalizedMessageId) {
return false;
}
if (!claimHeld && !tryBeginFeishuMessageProcessing(normalizedMessageId, namespace)) {
return false;
}
if (!(await tryRecordMessagePersistent(normalizedMessageId, namespace, log))) {
releaseFeishuMessageProcessing(normalizedMessageId, namespace);
return false;
}
return true;
}
export async function recordProcessedFeishuMessage(
messageId: string | undefined | null,
namespace = "global",
log?: (...args: unknown[]) => void,
): Promise<boolean> {
const normalizedMessageId = normalizeMessageId(messageId);
if (!normalizedMessageId) {
return false;
}
return await tryRecordMessagePersistent(normalizedMessageId, namespace, log);
}
export async function hasProcessedFeishuMessage(
messageId: string | undefined | null,
namespace = "global",
log?: (...args: unknown[]) => void,
): Promise<boolean> {
const normalizedMessageId = normalizeMessageId(messageId);
if (!normalizedMessageId) {
return false;
}
return hasRecordedMessagePersistent(normalizedMessageId, namespace, log);
}
export async function tryRecordMessagePersistent(
messageId: string,
namespace = "global",
log?: (...args: unknown[]) => void,
): Promise<boolean> {
return persistentDedupe.checkAndRecord(messageId, {
namespace,
onDiskError: (error) => {
log?.(`feishu-dedup: disk error, falling back to memory: ${String(error)}`);
},
});
}
export async function hasRecordedMessagePersistent(
messageId: string,
namespace = "global",
log?: (...args: unknown[]) => void,
): Promise<boolean> {
return persistentDedupe.hasRecent(messageId, {
namespace,
onDiskError: (error) => {
log?.(`feishu-dedup: persistent peek failed: ${String(error)}`);
},
});
}
export async function warmupDedupFromDisk(
namespace: string,
log?: (...args: unknown[]) => void,
): Promise<number> {
return persistentDedupe.warmup(namespace, (error) => {
log?.(`feishu-dedup: warmup disk error: ${String(error)}`);
});
}

View file

@ -0,0 +1,61 @@
import {
listDirectoryGroupEntriesFromMapKeysAndAllowFrom,
listDirectoryUserEntriesFromAllowFromAndMapKeys,
} from "openclaw/plugin-sdk/directory-runtime";
import type { ClawdbotConfig } from "../runtime-api.js";
import { resolveFeishuAccount } from "./accounts.js";
import { normalizeFeishuTarget } from "./targets.js";
export type FeishuDirectoryPeer = {
kind: "user";
id: string;
name?: string;
};
export type FeishuDirectoryGroup = {
kind: "group";
id: string;
name?: string;
};
function toFeishuDirectoryPeers(ids: string[]): FeishuDirectoryPeer[] {
return ids.map((id) => ({ kind: "user", id }));
}
function toFeishuDirectoryGroups(ids: string[]): FeishuDirectoryGroup[] {
return ids.map((id) => ({ kind: "group", id }));
}
export async function listFeishuDirectoryPeers(params: {
cfg: ClawdbotConfig;
query?: string;
limit?: number;
accountId?: string;
}): Promise<FeishuDirectoryPeer[]> {
const account = resolveFeishuAccount({ cfg: params.cfg, accountId: params.accountId });
const entries = listDirectoryUserEntriesFromAllowFromAndMapKeys({
allowFrom: account.config.allowFrom,
map: account.config.dms,
query: params.query,
limit: params.limit,
normalizeAllowFromId: (entry) => normalizeFeishuTarget(entry) ?? entry,
normalizeMapKeyId: (entry) => normalizeFeishuTarget(entry) ?? entry,
});
return toFeishuDirectoryPeers(entries.map((entry) => entry.id));
}
export async function listFeishuDirectoryGroups(params: {
cfg: ClawdbotConfig;
query?: string;
limit?: number;
accountId?: string;
}): Promise<FeishuDirectoryGroup[]> {
const account = resolveFeishuAccount({ cfg: params.cfg, accountId: params.accountId });
const entries = listDirectoryGroupEntriesFromMapKeysAndAllowFrom({
groups: account.config.groups,
allowFrom: account.config.groupAllowFrom,
query: params.query,
limit: params.limit,
});
return toFeishuDirectoryGroups(entries.map((entry) => entry.id));
}

View file

@ -0,0 +1,136 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig } from "../runtime-api.js";
const createFeishuClientMock = vi.hoisted(() => vi.fn());
vi.mock("./client.js", () => ({
createFeishuClient: createFeishuClientMock,
}));
const freshDirectoryModulePath = "./directory.js?directory-test";
const {
listFeishuDirectoryGroups,
listFeishuDirectoryGroupsLive,
listFeishuDirectoryPeers,
listFeishuDirectoryPeersLive,
} = await import(freshDirectoryModulePath);
function makeStaticCfg(): ClawdbotConfig {
return {
channels: {
feishu: {
allowFrom: ["user:alice", "user:bob"],
dms: {
"user:carla": {},
},
groups: {
"chat-1": {},
},
groupAllowFrom: ["chat-2"],
},
},
} as ClawdbotConfig;
}
function makeConfiguredCfg(): ClawdbotConfig {
return {
channels: {
feishu: {
...makeStaticCfg().channels?.feishu,
appId: "cli_test_app_id",
appSecret: "cli_test_app_secret",
},
},
} as ClawdbotConfig;
}
describe("feishu directory (config-backed)", () => {
beforeEach(() => {
createFeishuClientMock.mockReset();
});
it("merges allowFrom + dms into peer entries", async () => {
const peers = await listFeishuDirectoryPeers({ cfg: makeStaticCfg(), query: "a" });
expect(peers).toEqual([
{ kind: "user", id: "alice" },
{ kind: "user", id: "carla" },
]);
});
it("normalizes spaced provider-prefixed peer entries", async () => {
const cfg = {
channels: {
feishu: {
allowFrom: [" feishu:user:ou_alice "],
dms: {
" lark:dm:ou_carla ": {},
},
groups: {},
groupAllowFrom: [],
},
},
} as ClawdbotConfig;
const peers = await listFeishuDirectoryPeers({ cfg });
expect(peers).toEqual([
{ kind: "user", id: "ou_alice" },
{ kind: "user", id: "ou_carla" },
]);
});
it("merges groups map + groupAllowFrom into group entries", async () => {
const groups = await listFeishuDirectoryGroups({ cfg: makeStaticCfg() });
expect(groups).toEqual([
{ kind: "group", id: "chat-1" },
{ kind: "group", id: "chat-2" },
]);
});
it("falls back to static peers on live lookup failure by default", async () => {
createFeishuClientMock.mockReturnValueOnce({
contact: {
user: {
list: vi.fn(async () => {
throw new Error("token expired");
}),
},
},
});
const peers = await listFeishuDirectoryPeersLive({ cfg: makeConfiguredCfg(), query: "a" });
expect(peers).toEqual([
{ kind: "user", id: "alice" },
{ kind: "user", id: "carla" },
]);
});
it("surfaces live peer lookup failures when fallback is disabled", async () => {
createFeishuClientMock.mockReturnValueOnce({
contact: {
user: {
list: vi.fn(async () => {
throw new Error("token expired");
}),
},
},
});
await expect(
listFeishuDirectoryPeersLive({ cfg: makeConfiguredCfg(), fallbackToStatic: false }),
).rejects.toThrow("token expired");
});
it("surfaces live group lookup failures when fallback is disabled", async () => {
createFeishuClientMock.mockReturnValueOnce({
im: {
chat: {
list: vi.fn(async () => ({ code: 999, msg: "forbidden" })),
},
},
});
await expect(
listFeishuDirectoryGroupsLive({ cfg: makeConfiguredCfg(), fallbackToStatic: false }),
).rejects.toThrow("forbidden");
});
});

View file

@ -0,0 +1,126 @@
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import type { ClawdbotConfig } from "../runtime-api.js";
import { resolveFeishuAccount } from "./accounts.js";
import { createFeishuClient } from "./client.js";
import {
listFeishuDirectoryGroups,
listFeishuDirectoryPeers,
type FeishuDirectoryGroup,
type FeishuDirectoryPeer,
} from "./directory.static.js";
export { listFeishuDirectoryGroups, listFeishuDirectoryPeers } from "./directory.static.js";
export async function listFeishuDirectoryPeersLive(params: {
cfg: ClawdbotConfig;
query?: string;
limit?: number;
accountId?: string;
fallbackToStatic?: boolean;
}): Promise<FeishuDirectoryPeer[]> {
const account = resolveFeishuAccount({ cfg: params.cfg, accountId: params.accountId });
if (!account.configured) {
return listFeishuDirectoryPeers(params);
}
try {
const client = createFeishuClient(account);
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) {
throw new Error(response.msg || `code ${response.code}`);
}
const q = normalizeLowercaseStringOrEmpty(params.query);
for (const user of response.data?.items ?? []) {
if (user.open_id) {
const name = user.name || "";
if (
!q ||
normalizeLowercaseStringOrEmpty(user.open_id).includes(q) ||
normalizeLowercaseStringOrEmpty(name).includes(q)
) {
peers.push({
kind: "user",
id: user.open_id,
name: name || undefined,
});
}
}
if (peers.length >= limit) {
break;
}
}
return peers;
} catch (err) {
if (params.fallbackToStatic === false) {
throw err instanceof Error ? err : new Error("Feishu live peer lookup failed");
}
return listFeishuDirectoryPeers(params);
}
}
export async function listFeishuDirectoryGroupsLive(params: {
cfg: ClawdbotConfig;
query?: string;
limit?: number;
accountId?: string;
fallbackToStatic?: boolean;
}): Promise<FeishuDirectoryGroup[]> {
const account = resolveFeishuAccount({ cfg: params.cfg, accountId: params.accountId });
if (!account.configured) {
return listFeishuDirectoryGroups(params);
}
try {
const client = createFeishuClient(account);
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) {
throw new Error(response.msg || `code ${response.code}`);
}
const q = normalizeLowercaseStringOrEmpty(params.query);
for (const chat of response.data?.items ?? []) {
if (chat.chat_id) {
const name = chat.name || "";
if (
!q ||
normalizeLowercaseStringOrEmpty(chat.chat_id).includes(q) ||
normalizeLowercaseStringOrEmpty(name).includes(q)
) {
groups.push({
kind: "group",
id: chat.chat_id,
name: name || undefined,
});
}
}
if (groups.length >= limit) {
break;
}
}
return groups;
} catch (err) {
if (params.fallbackToStatic === false) {
throw err instanceof Error ? err : new Error("Feishu live group lookup failed");
}
return listFeishuDirectoryGroups(params);
}
}

View file

@ -0,0 +1,182 @@
import { Type, type Static } from "@sinclair/typebox";
const tableCreationProperties = {
doc_token: Type.String({ description: "Document token" }),
parent_block_id: Type.Optional(
Type.String({ description: "Parent block ID (default: document root)" }),
),
row_size: Type.Integer({ description: "Table row count", minimum: 1 }),
column_size: Type.Integer({ description: "Table column count", minimum: 1 }),
column_width: Type.Optional(
Type.Array(Type.Number({ minimum: 1 }), {
description: "Column widths in px (length should match column_size)",
}),
),
};
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("insert"),
doc_token: Type.String({ description: "Document token" }),
content: Type.String({ description: "Markdown content to insert" }),
after_block_id: Type.String({
description: "Insert content after this block ID. Use list_blocks to find block IDs.",
}),
}),
Type.Object({
action: Type.Literal("create"),
title: Type.String({ description: "Document title" }),
folder_token: Type.Optional(Type.String({ description: "Target folder token (optional)" })),
grant_to_requester: Type.Optional(
Type.Boolean({
description:
"Grant edit permission to the trusted requesting Feishu user from runtime context (default: true).",
}),
),
}),
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" }),
}),
// Table creation (explicit structure)
Type.Object({
action: Type.Literal("create_table"),
...tableCreationProperties,
}),
Type.Object({
action: Type.Literal("write_table_cells"),
doc_token: Type.String({ description: "Document token" }),
table_block_id: Type.String({ description: "Table block ID" }),
values: Type.Array(Type.Array(Type.String()), {
description: "2D matrix values[row][col] to write into table cells",
minItems: 1,
}),
}),
Type.Object({
action: Type.Literal("create_table_with_values"),
...tableCreationProperties,
values: Type.Array(Type.Array(Type.String()), {
description: "2D matrix values[row][col] to write into table cells",
minItems: 1,
}),
}),
// Table row/column manipulation
Type.Object({
action: Type.Literal("insert_table_row"),
doc_token: Type.String({ description: "Document token" }),
block_id: Type.String({ description: "Table block ID" }),
row_index: Type.Optional(
Type.Number({ description: "Row index to insert at (-1 for end, default: -1)" }),
),
}),
Type.Object({
action: Type.Literal("insert_table_column"),
doc_token: Type.String({ description: "Document token" }),
block_id: Type.String({ description: "Table block ID" }),
column_index: Type.Optional(
Type.Number({ description: "Column index to insert at (-1 for end, default: -1)" }),
),
}),
Type.Object({
action: Type.Literal("delete_table_rows"),
doc_token: Type.String({ description: "Document token" }),
block_id: Type.String({ description: "Table block ID" }),
row_start: Type.Number({ description: "Start row index (0-based)" }),
row_count: Type.Optional(Type.Number({ description: "Number of rows to delete (default: 1)" })),
}),
Type.Object({
action: Type.Literal("delete_table_columns"),
doc_token: Type.String({ description: "Document token" }),
block_id: Type.String({ description: "Table block ID" }),
column_start: Type.Number({ description: "Start column index (0-based)" }),
column_count: Type.Optional(
Type.Number({ description: "Number of columns to delete (default: 1)" }),
),
}),
Type.Object({
action: Type.Literal("merge_table_cells"),
doc_token: Type.String({ description: "Document token" }),
block_id: Type.String({ description: "Table block ID" }),
row_start: Type.Number({ description: "Start row index" }),
row_end: Type.Number({ description: "End row index (exclusive)" }),
column_start: Type.Number({ description: "Start column index" }),
column_end: Type.Number({ description: "End column index (exclusive)" }),
}),
// Image / file upload
Type.Object({
action: Type.Literal("upload_image"),
doc_token: Type.String({ description: "Document token" }),
url: Type.Optional(Type.String({ description: "Remote image URL (http/https)" })),
file_path: Type.Optional(Type.String({ description: "Local image file path" })),
image: Type.Optional(
Type.String({
description:
"Image as data URI (data:image/png;base64,...) or plain base64 string. Use instead of url/file_path for DALL-E outputs, canvas screenshots, etc.",
}),
),
parent_block_id: Type.Optional(
Type.String({ description: "Parent block ID (default: document root)" }),
),
filename: Type.Optional(Type.String({ description: "Optional filename override" })),
index: Type.Optional(
Type.Integer({
minimum: 0,
description: "Insert position (0-based index among siblings). Omit to append.",
}),
),
}),
Type.Object({
action: Type.Literal("upload_file"),
doc_token: Type.String({ description: "Document token" }),
url: Type.Optional(Type.String({ description: "Remote file URL (http/https)" })),
file_path: Type.Optional(Type.String({ description: "Local file path" })),
parent_block_id: Type.Optional(
Type.String({ description: "Parent block ID (default: document root)" }),
),
filename: Type.Optional(Type.String({ description: "Optional filename override" })),
}),
// Text color / style
Type.Object({
action: Type.Literal("color_text"),
doc_token: Type.String({ description: "Document token" }),
block_id: Type.String({ description: "Text block ID to update" }),
content: Type.String({
description:
'Text with color markup. Tags: [red], [green], [blue], [orange], [yellow], [purple], [grey], [bold], [bg:yellow]. Example: "Revenue [green]+15%[/green] YoY"',
}),
}),
]);
export type FeishuDocParams = Static<typeof FeishuDocSchema>;

View file

@ -0,0 +1,97 @@
import type * as Lark from "@larksuiteoapi/node-sdk";
import { describe, expect, it, vi } from "vitest";
import { BATCH_SIZE, insertBlocksInBatches } from "./docx-batch-insert.js";
import type { FeishuDocxBlock } from "./docx-types.js";
type InsertBlocksClient = Parameters<typeof insertBlocksInBatches>[0];
type DocxDescendantCreate = Lark.Client["docx"]["documentBlockDescendant"]["create"];
type DocxDescendantCreateParams = Parameters<DocxDescendantCreate>[0];
type DocxDescendantCreateResponse = Awaited<ReturnType<DocxDescendantCreate>>;
function createDocxDescendantClient(create: DocxDescendantCreate): InsertBlocksClient {
return {
docx: {
documentBlockDescendant: {
create,
},
},
} as InsertBlocksClient;
}
function createCountingIterable<T>(values: T[]) {
let iterations = 0;
return {
values: {
[Symbol.iterator]: function* () {
iterations += 1;
yield* values;
},
},
getIterations: () => iterations,
};
}
describe("insertBlocksInBatches", () => {
it("builds the source block map once for large flat trees", async () => {
const blockCount = BATCH_SIZE + 200;
const blocks = Array.from({ length: blockCount }, (_, index) => ({
block_id: `block_${index}`,
block_type: 2,
}));
const counting = createCountingIterable(blocks);
const createMock = vi.fn(
async (params?: DocxDescendantCreateParams): Promise<DocxDescendantCreateResponse> => ({
code: 0,
data: {
children: (params?.data?.children_id ?? []).map((id) => ({
block_id: id,
block_type: 2,
})),
},
}),
);
const client = createDocxDescendantClient((params) => createMock(params));
const result = await insertBlocksInBatches(
client,
"doc_1",
Array.from(counting.values),
blocks.map((block) => block.block_id),
);
expect(counting.getIterations()).toBe(1);
expect(createMock).toHaveBeenCalledTimes(2);
expect(createMock.mock.calls[0]?.[0]?.data.children_id).toHaveLength(BATCH_SIZE);
expect(createMock.mock.calls[1]?.[0]?.data.children_id).toHaveLength(200);
expect(result.children).toHaveLength(blockCount);
});
it("keeps nested descendants grouped with their root blocks", async () => {
const createMock = vi.fn(
async (params?: DocxDescendantCreateParams): Promise<DocxDescendantCreateResponse> => ({
code: 0,
data: {
children: (params?.data?.children_id ?? []).map((id) => ({
block_id: id,
block_type: 2,
})),
},
}),
);
const client = createDocxDescendantClient((params) => createMock(params));
const blocks: FeishuDocxBlock[] = [
{ block_id: "root_a", block_type: 1, children: ["child_a"] },
{ block_id: "child_a", block_type: 2 },
{ block_id: "root_b", block_type: 1, children: ["child_b"] },
{ block_id: "child_b", block_type: 2 },
];
await insertBlocksInBatches(client, "doc_1", blocks, ["root_a", "root_b"]);
expect(createMock).toHaveBeenCalledTimes(1);
expect(createMock.mock.calls[0]?.[0]?.data.children_id).toEqual(["root_a", "root_b"]);
expect(
createMock.mock.calls[0]?.[0]?.data.descendants.map((block) => block.block_id ?? ""),
).toEqual(["root_a", "child_a", "root_b", "child_b"]);
});
});

View file

@ -0,0 +1,223 @@
/**
* Batch insertion for large Feishu documents (>1000 blocks).
*
* The Feishu Descendant API has a limit of 1000 blocks per request.
* This module handles splitting large documents into batches while
* preserving parent-child relationships between blocks.
*/
import type * as Lark from "@larksuiteoapi/node-sdk";
import { readStringValue } from "openclaw/plugin-sdk/text-runtime";
import { cleanBlocksForDescendant } from "./docx-table-ops.js";
import type { FeishuDocxBlock, FeishuDocxBlockChild } from "./docx-types.js";
export const BATCH_SIZE = 1000; // Feishu API limit per request
type Logger = { info?: (msg: string) => void };
type DocxDescendantCreatePayload = NonNullable<
Parameters<Lark.Client["docx"]["documentBlockDescendant"]["create"]>[0]
>;
type DocxDescendantCreateBlock = NonNullable<
NonNullable<DocxDescendantCreatePayload["data"]>["descendants"]
>[number];
function normalizeChildIds(children: string[] | string | undefined): string[] | undefined {
if (Array.isArray(children)) {
return children;
}
const child = readStringValue(children);
return child ? [child] : undefined;
}
function toDescendantBlock(block: FeishuDocxBlock): DocxDescendantCreateBlock {
const children = normalizeChildIds(block.children);
return {
...block,
...(children ? { children } : {}),
} as DocxDescendantCreateBlock;
}
/**
* Collect all descendant blocks for a given first-level block ID.
* Recursively traverses the block tree to gather all children.
*/
function collectDescendants(
blockMap: Map<string, FeishuDocxBlock>,
rootId: string,
): FeishuDocxBlock[] {
const result: FeishuDocxBlock[] = [];
const visited = new Set<string>();
function collect(blockId: string) {
if (visited.has(blockId)) {
return;
}
visited.add(blockId);
const block = blockMap.get(blockId);
if (!block) {
return;
}
result.push(block);
// Recursively collect children
const children = block.children;
if (Array.isArray(children)) {
for (const childId of children) {
collect(childId);
}
} else if (typeof children === "string") {
collect(children);
}
}
collect(rootId);
return result;
}
/**
* Insert a single batch of blocks using Descendant API.
*
* @param parentBlockId - Parent block to insert into (defaults to docToken)
* @param index - Position within parent's children (-1 = end)
*/
async function insertBatch(
client: Lark.Client,
docToken: string,
blocks: FeishuDocxBlock[],
firstLevelBlockIds: string[],
parentBlockId: string = docToken,
index: number = -1,
): Promise<FeishuDocxBlockChild[]> {
const descendants = cleanBlocksForDescendant(blocks);
if (descendants.length === 0) {
return [];
}
const res = await client.docx.documentBlockDescendant.create({
path: { document_id: docToken, block_id: parentBlockId },
data: {
children_id: firstLevelBlockIds,
descendants: descendants.map(toDescendantBlock),
index,
},
});
if (res.code !== 0) {
throw new Error(`${res.msg} (code: ${res.code})`);
}
return res.data?.children ?? [];
}
/**
* Insert blocks in batches for large documents (>1000 blocks).
*
* Batches are split to ensure BOTH children_id AND descendants
* arrays stay under the 1000 block API limit.
*
* @param client - Feishu API client
* @param docToken - Document ID
* @param blocks - All blocks from Convert API
* @param firstLevelBlockIds - IDs of top-level blocks to insert
* @param logger - Optional logger for progress updates
* @param parentBlockId - Parent block to insert into (defaults to docToken = document root)
* @param startIndex - Starting position within parent (-1 = end). For multi-batch inserts,
* each batch advances this by the number of first-level IDs inserted so far.
* @returns Inserted children blocks and any skipped block IDs
*/
export async function insertBlocksInBatches(
client: Lark.Client,
docToken: string,
blocks: FeishuDocxBlock[],
firstLevelBlockIds: string[],
logger?: Logger,
parentBlockId: string = docToken,
startIndex: number = -1,
): Promise<{ children: FeishuDocxBlockChild[]; skipped: string[] }> {
const allChildren: FeishuDocxBlockChild[] = [];
// Build batches ensuring each batch has ≤1000 total descendants
const batches: Array<{ firstLevelIds: string[]; blocks: FeishuDocxBlock[] }> = [];
let currentBatch: { firstLevelIds: string[]; blocks: FeishuDocxBlock[] } = {
firstLevelIds: [],
blocks: [],
};
const usedBlockIds = new Set<string>();
const blockMap = new Map<string, FeishuDocxBlock>();
for (const block of blocks) {
if (block.block_id) {
blockMap.set(block.block_id, block);
}
}
for (const firstLevelId of firstLevelBlockIds) {
const descendants = collectDescendants(blockMap, firstLevelId);
const newBlocks = descendants.filter((b) => b.block_id && !usedBlockIds.has(b.block_id));
// A single block whose subtree exceeds the API limit cannot be split
// (a table or other compound block must be inserted atomically).
if (newBlocks.length > BATCH_SIZE) {
throw new Error(
`Block "${firstLevelId}" has ${newBlocks.length} descendants, which exceeds the ` +
`Feishu API limit of ${BATCH_SIZE} blocks per request. ` +
`Please split the content into smaller sections.`,
);
}
// If adding this first-level block would exceed limit, start new batch
if (
currentBatch.blocks.length + newBlocks.length > BATCH_SIZE &&
currentBatch.blocks.length > 0
) {
batches.push(currentBatch);
currentBatch = { firstLevelIds: [], blocks: [] };
}
// Add to current batch
currentBatch.firstLevelIds.push(firstLevelId);
for (const block of newBlocks) {
currentBatch.blocks.push(block);
if (block.block_id) {
usedBlockIds.add(block.block_id);
}
}
}
// Don't forget the last batch
if (currentBatch.blocks.length > 0) {
batches.push(currentBatch);
}
// Insert each batch, advancing index for position-aware inserts.
// When startIndex == -1 (append to end), each batch appends after the previous.
// When startIndex >= 0, each batch starts at startIndex + count of first-level IDs already inserted.
let currentIndex = startIndex;
for (let i = 0; i < batches.length; i++) {
const batch = batches[i];
logger?.info?.(
`feishu_doc: Inserting batch ${i + 1}/${batches.length} (${batch.blocks.length} blocks)...`,
);
const children = await insertBatch(
client,
docToken,
batch.blocks,
batch.firstLevelIds,
parentBlockId,
currentIndex,
);
allChildren.push(...children);
// Advance index only for explicit positions; -1 always means "after last inserted"
if (currentIndex !== -1) {
currentIndex += batch.firstLevelIds.length;
}
}
return { children: allChildren, skipped: [] };
}

View file

@ -0,0 +1,154 @@
/**
* Colored text support for Feishu documents.
*
* Parses a simple color markup syntax and updates a text block
* with native Feishu text_run color styles.
*
* Syntax: [color]text[/color]
* Supported colors: red, orange, yellow, green, blue, purple, grey
*
* Example:
* "Revenue [green]+15%[/green] YoY, Costs [red]-3%[/red]"
*/
import type * as Lark from "@larksuiteoapi/node-sdk";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
// Feishu text_color values (1-7)
const TEXT_COLOR: Record<string, number> = {
red: 1, // Pink (closest to red in Feishu)
orange: 2,
yellow: 3,
green: 4,
blue: 5,
purple: 6,
grey: 7,
gray: 7,
};
// Feishu background_color values (1-15)
const BACKGROUND_COLOR: Record<string, number> = {
red: 1,
orange: 2,
yellow: 3,
green: 4,
blue: 5,
purple: 6,
grey: 7,
gray: 7,
};
interface Segment {
text: string;
textColor?: number;
bgColor?: number;
bold?: boolean;
}
type DocxPatchPayload = NonNullable<Parameters<Lark.Client["docx"]["documentBlock"]["patch"]>[0]>;
type DocxTextElement = NonNullable<
NonNullable<NonNullable<DocxPatchPayload["data"]>["update_text_elements"]>["elements"]
>[number];
/**
* Parse color markup into segments.
*
* Supports:
* [red]text[/red] → red text
* [bg:yellow]text[/bg] → yellow background
* [bold]text[/bold] → bold
* [green bold]text[/green] → green + bold
*/
export function parseColorMarkup(content: string): Segment[] {
const segments: Segment[] = [];
// Only [known_tag]...[/...] pairs are treated as markup. Using an open
// pattern like \[([^\]]+)\] would match any bracket token — e.g. [Q1] —
// and cause it to consume a later real closing tag ([/red]), silently
// corrupting the surrounding styled spans. Restricting the opening tag to
// the set of recognised colour/style names prevents that: [Q1] does not
// match the tag alternative and each of its characters falls through to the
// plain-text alternatives instead.
//
// Closing tag name is still not validated against the opening tag:
// [red]text[/green] is treated as [red]text[/red] — opening style applies
// and the closing tag is consumed regardless of its name.
const KNOWN = "(?:bg:[a-z]+|bold|red|orange|yellow|green|blue|purple|gr[ae]y)";
const tagPattern = new RegExp(
`\\[(${KNOWN}(?:\\s+${KNOWN})*)\\](.*?)\\[\\/(?:[^\\]]+)\\]|([^[]+|\\[)`,
"gis",
);
let match;
while ((match = tagPattern.exec(content)) !== null) {
if (match[3] !== undefined) {
// Plain text segment
if (match[3]) {
segments.push({ text: match[3] });
}
} else {
// Tagged segment
const tagStr = normalizeLowercaseStringOrEmpty(match[1]);
const text = match[2];
const tags = tagStr.split(/\s+/);
const segment: Segment = { text };
for (const tag of tags) {
if (tag.startsWith("bg:")) {
const color = tag.slice(3);
if (BACKGROUND_COLOR[color]) {
segment.bgColor = BACKGROUND_COLOR[color];
}
} else if (tag === "bold") {
segment.bold = true;
} else if (TEXT_COLOR[tag]) {
segment.textColor = TEXT_COLOR[tag];
}
}
if (text) {
segments.push(segment);
}
}
}
return segments;
}
/**
* Update a text block with colored segments.
*/
export async function updateColorText(
client: Lark.Client,
docToken: string,
blockId: string,
content: string,
) {
const segments = parseColorMarkup(content);
const elements: DocxTextElement[] = segments.map((seg) => ({
text_run: {
content: seg.text,
text_element_style: {
...(seg.textColor && { text_color: seg.textColor }),
...(seg.bgColor && { background_color: seg.bgColor }),
...(seg.bold && { bold: true }),
},
},
}));
const res = await client.docx.documentBlock.patch({
path: { document_id: docToken, block_id: blockId },
data: { update_text_elements: { elements } },
});
if (res.code !== 0) {
throw new Error(res.msg);
}
return {
success: true,
segments: segments.length,
block: res.data?.block,
};
}

View file

@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import { cleanBlocksForDescendant } from "./docx-table-ops.js";
describe("cleanBlocksForDescendant", () => {
it("removes parent links and read-only table fields while normalizing table cells", () => {
const blocks = [
{
block_id: "table-1",
parent_id: "parent-1",
block_type: 31,
children: "cell-1",
table: {
property: {
row_size: 1,
column_size: 1,
column_width: [240],
},
cells: ["cell-1"],
merge_info: [{ row_span: 1, col_span: 1 }],
},
},
{
block_id: "cell-1",
parent_id: "table-1",
block_type: 32,
children: "text-1",
},
{
block_id: "text-1",
parent_id: "cell-1",
block_type: 2,
text: {
elements: [{ text_run: { content: "hello" } }],
},
},
];
const cleaned = cleanBlocksForDescendant(blocks);
expect(cleaned[0]).not.toHaveProperty("parent_id");
expect(cleaned[1]).not.toHaveProperty("parent_id");
expect(cleaned[2]).not.toHaveProperty("parent_id");
expect(cleaned[0]?.table).toEqual({
property: {
row_size: 1,
column_size: 1,
column_width: [240],
},
});
expect(cleaned[1]?.children).toEqual(["text-1"]);
});
});

View file

@ -0,0 +1,319 @@
/**
* Table utilities and row/column manipulation operations for Feishu documents.
*
* Combines:
* - Adaptive column width calculation (content-proportional, CJK-aware)
* - Block cleaning for Descendant API (removes read-only fields)
* - Table row/column insert, delete, and merge operations
*/
import type * as Lark from "@larksuiteoapi/node-sdk";
import type { FeishuBlockTable, FeishuDocxBlock } from "./docx-types.js";
// ============ Table Utilities ============
// Feishu table constraints
const MIN_COLUMN_WIDTH = 50; // Feishu API minimum
const MAX_COLUMN_WIDTH = 400; // Reasonable maximum for readability
const DEFAULT_TABLE_WIDTH = 730; // Approximate Feishu page content width
/**
* Calculate adaptive column widths based on cell content length.
*
* Algorithm:
* 1. For each column, find the max content length across all rows
* 2. Weight CJK characters as 2x width (they render wider)
* 3. Calculate proportional widths based on content length
* 4. Apply min/max constraints
* 5. Redistribute remaining space to fill total table width
*
* Total width is derived from the original column_width values returned
* by the Convert API, ensuring tables match Feishu's expected dimensions.
*
* @param blocks - Array of blocks from Convert API
* @param tableBlockId - The block_id of the table block
* @returns Array of column widths in pixels
*/
function normalizeChildBlockIds(children: string[] | string | undefined): string[] {
if (Array.isArray(children)) {
return children;
}
return typeof children === "string" ? [children] : [];
}
function omitParentId(block: FeishuDocxBlock): FeishuDocxBlock {
const cleanBlock = { ...block };
delete cleanBlock.parent_id;
return cleanBlock;
}
function createDescendantTable(
table: FeishuBlockTable,
adaptiveWidths: number[] | undefined,
): FeishuBlockTable {
const { row_size, column_size } = table.property || {};
return {
property: {
row_size,
column_size,
...(adaptiveWidths?.length ? { column_width: adaptiveWidths } : {}),
},
};
}
export function calculateAdaptiveColumnWidths(
blocks: FeishuDocxBlock[],
tableBlockId: string,
): number[] {
// Find the table block
const tableBlock = blocks.find((b) => b.block_id === tableBlockId && b.block_type === 31);
if (!tableBlock?.table?.property) {
return [];
}
const { row_size, column_size, column_width: originalWidths } = tableBlock.table.property;
if (!row_size || !column_size) {
return [];
}
// Use original total width from Convert API, or fall back to default
const totalWidth =
originalWidths && originalWidths.length > 0
? originalWidths.reduce((a: number, b: number) => a + b, 0)
: DEFAULT_TABLE_WIDTH;
const cellIds = normalizeChildBlockIds(tableBlock.children);
// Build block lookup map
const blockMap = new Map<string, FeishuDocxBlock>();
for (const block of blocks) {
if (block.block_id) {
blockMap.set(block.block_id, block);
}
}
// Extract text content from a table cell
function getCellText(cellId: string): string {
const cell = blockMap.get(cellId);
let text = "";
const childIds = normalizeChildBlockIds(cell?.children);
for (const childId of childIds) {
const child = blockMap.get(childId);
if (child?.text?.elements) {
for (const elem of child.text.elements) {
if (elem.text_run?.content) {
text += elem.text_run.content;
}
}
}
}
return text;
}
// Calculate weighted length (CJK chars count as 2)
// CJK (Chinese/Japanese/Korean) characters render ~2x wider than ASCII
function getWeightedLength(text: string): number {
return Array.from(text).reduce((sum, char) => {
return sum + (char.charCodeAt(0) > 255 ? 2 : 1);
}, 0);
}
// Find max content length per column
const maxLengths = Array.from({ length: column_size }, () => 0);
for (let row = 0; row < row_size; row++) {
for (let col = 0; col < column_size; col++) {
const cellIndex = row * column_size + col;
const cellId = cellIds[cellIndex];
if (cellId) {
const content = getCellText(cellId);
const length = getWeightedLength(content);
maxLengths[col] = Math.max(maxLengths[col], length);
}
}
}
// Handle empty table: distribute width equally, clamped to [MIN, MAX] so
// wide tables (e.g. 15+ columns) don't produce sub-50 widths that Feishu
// rejects as invalid column_width values.
const totalLength = maxLengths.reduce((a, b) => a + b, 0);
if (totalLength === 0) {
const equalWidth = Math.max(
MIN_COLUMN_WIDTH,
Math.min(MAX_COLUMN_WIDTH, Math.floor(totalWidth / column_size)),
);
return Array.from({ length: column_size }, () => equalWidth);
}
// Calculate proportional widths
let widths = maxLengths.map((len) => {
const proportion = len / totalLength;
return Math.round(proportion * totalWidth);
});
// Apply min/max constraints
widths = widths.map((w) => Math.max(MIN_COLUMN_WIDTH, Math.min(MAX_COLUMN_WIDTH, w)));
// Redistribute remaining space to fill total width
let remaining = totalWidth - widths.reduce((a, b) => a + b, 0);
while (remaining > 0) {
// Find columns that can still grow (not at max)
const growable = widths.map((w, i) => (w < MAX_COLUMN_WIDTH ? i : -1)).filter((i) => i >= 0);
if (growable.length === 0) {
break;
}
// Distribute evenly among growable columns
const perColumn = Math.floor(remaining / growable.length);
if (perColumn === 0) {
break;
}
for (const i of growable) {
const add = Math.min(perColumn, MAX_COLUMN_WIDTH - widths[i]);
widths[i] += add;
remaining -= add;
}
}
return widths;
}
/**
* Clean blocks for Descendant API with adaptive column widths.
*
* - Removes parent_id from all blocks
* - Fixes children type (string → array) for TableCell blocks
* - Removes merge_info (read-only, causes API error)
* - Calculates and applies adaptive column_width for tables
*
* @param blocks - Array of blocks from Convert API
* @returns Cleaned blocks ready for Descendant API
*/
export function cleanBlocksForDescendant(blocks: FeishuDocxBlock[]): FeishuDocxBlock[] {
// Pre-calculate adaptive widths for all tables
const tableWidths = new Map<string, number[]>();
for (const block of blocks) {
if (block.block_type === 31 && block.block_id) {
const widths = calculateAdaptiveColumnWidths(blocks, block.block_id);
tableWidths.set(block.block_id, widths);
}
}
return blocks.map((block) => {
const cleanBlock = omitParentId(block);
// Fix: Convert API sometimes returns children as string for TableCell
if (cleanBlock.block_type === 32 && typeof cleanBlock.children === "string") {
cleanBlock.children = [cleanBlock.children];
}
// Clean table blocks
if (cleanBlock.block_type === 31 && cleanBlock.table) {
const adaptiveWidths = block.block_id ? tableWidths.get(block.block_id) : undefined;
cleanBlock.table = createDescendantTable(cleanBlock.table, adaptiveWidths);
}
return cleanBlock;
});
}
// ============ Table Row/Column Operations ============
export async function insertTableRow(
client: Lark.Client,
docToken: string,
blockId: string,
rowIndex: number = -1,
) {
const res = await client.docx.documentBlock.patch({
path: { document_id: docToken, block_id: blockId },
data: { insert_table_row: { row_index: rowIndex } },
});
if (res.code !== 0) {
throw new Error(res.msg);
}
return { success: true, block: res.data?.block };
}
export async function insertTableColumn(
client: Lark.Client,
docToken: string,
blockId: string,
columnIndex: number = -1,
) {
const res = await client.docx.documentBlock.patch({
path: { document_id: docToken, block_id: blockId },
data: { insert_table_column: { column_index: columnIndex } },
});
if (res.code !== 0) {
throw new Error(res.msg);
}
return { success: true, block: res.data?.block };
}
export async function deleteTableRows(
client: Lark.Client,
docToken: string,
blockId: string,
rowStart: number,
rowCount: number = 1,
) {
const res = await client.docx.documentBlock.patch({
path: { document_id: docToken, block_id: blockId },
data: { delete_table_rows: { row_start_index: rowStart, row_end_index: rowStart + rowCount } },
});
if (res.code !== 0) {
throw new Error(res.msg);
}
return { success: true, rows_deleted: rowCount, block: res.data?.block };
}
export async function deleteTableColumns(
client: Lark.Client,
docToken: string,
blockId: string,
columnStart: number,
columnCount: number = 1,
) {
const res = await client.docx.documentBlock.patch({
path: { document_id: docToken, block_id: blockId },
data: {
delete_table_columns: {
column_start_index: columnStart,
column_end_index: columnStart + columnCount,
},
},
});
if (res.code !== 0) {
throw new Error(res.msg);
}
return { success: true, columns_deleted: columnCount, block: res.data?.block };
}
export async function mergeTableCells(
client: Lark.Client,
docToken: string,
blockId: string,
rowStart: number,
rowEnd: number,
columnStart: number,
columnEnd: number,
) {
const res = await client.docx.documentBlock.patch({
path: { document_id: docToken, block_id: blockId },
data: {
merge_table_cells: {
row_start_index: rowStart,
row_end_index: rowEnd,
column_start_index: columnStart,
column_end_index: columnEnd,
},
},
});
if (res.code !== 0) {
throw new Error(res.msg);
}
return { success: true, block: res.data?.block };
}

View file

@ -0,0 +1,38 @@
export type FeishuBlockText = {
elements?: Array<{
text_run?: {
content?: string;
};
}>;
};
export type FeishuBlockTableProperty = {
row_size?: number;
column_size?: number;
column_width?: number[];
};
export type FeishuBlockTable = {
property?: FeishuBlockTableProperty;
merge_info?: Array<{ row_span?: number; col_span?: number }>;
cells?: string[];
};
export type FeishuDocxBlock = {
block_id?: string;
parent_id?: string;
children?: string[] | string;
block_type: number;
text?: FeishuBlockText;
table?: FeishuBlockTable;
image?: object;
[key: string]: object | string | number | boolean | string[] | undefined;
};
export type FeishuDocxBlockChild = {
block_id?: string;
parent_id?: string;
block_type?: number;
children?: string[] | FeishuDocxBlockChild[];
table?: FeishuBlockTable;
};

View file

@ -0,0 +1,79 @@
import { beforeAll, beforeEach, describe, expect, test, vi } from "vitest";
import type { OpenClawPluginApi } from "../runtime-api.js";
import { createToolFactoryHarness } from "./tool-factory-test-harness.js";
const createFeishuClientMock = vi.fn((creds: { appId?: string } | undefined) => ({
__appId: creds?.appId,
}));
vi.mock("./client.js", () => {
return {
createFeishuClient: (creds: { appId?: string } | undefined) => createFeishuClientMock(creds),
};
});
// Patch SDK import so tool execution can run without network concerns.
vi.mock("@larksuiteoapi/node-sdk", () => {
return {
default: {},
};
});
describe("feishu_doc account selection", () => {
let registerFeishuDocTools: typeof import("./docx.js").registerFeishuDocTools;
beforeAll(async () => {
({ registerFeishuDocTools } = await import("./docx.js"));
});
beforeEach(() => {
vi.clearAllMocks();
});
function createDocEnabledConfig(): OpenClawPluginApi["config"] {
return {
channels: {
feishu: {
enabled: true,
accounts: {
a: { appId: "app-a", appSecret: "sec-a", tools: { doc: true } }, // pragma: allowlist secret
b: { appId: "app-b", appSecret: "sec-b", tools: { doc: true } }, // pragma: allowlist secret
},
},
},
} as OpenClawPluginApi["config"];
}
test("uses agentAccountId context when params omit accountId", async () => {
const cfg = createDocEnabledConfig();
const { api, resolveTool } = createToolFactoryHarness(cfg);
registerFeishuDocTools(api);
const docToolA = resolveTool("feishu_doc", { agentAccountId: "a" });
const docToolB = resolveTool("feishu_doc", { agentAccountId: "b" });
await docToolA.execute("call-a", { action: "list_blocks", doc_token: "d" });
await docToolB.execute("call-b", { action: "list_blocks", doc_token: "d" });
expect(createFeishuClientMock).toHaveBeenCalledTimes(2);
expect(createFeishuClientMock.mock.calls[0]?.[0]?.appId).toBe("app-a");
expect(createFeishuClientMock.mock.calls[1]?.[0]?.appId).toBe("app-b");
});
test("explicit accountId param overrides agentAccountId context", async () => {
const cfg = createDocEnabledConfig();
const { api, resolveTool } = createToolFactoryHarness(cfg);
registerFeishuDocTools(api);
const docTool = resolveTool("feishu_doc", { agentAccountId: "b" });
await docTool.execute("call-override", {
action: "list_blocks",
doc_token: "d",
accountId: "a",
});
expect(createFeishuClientMock.mock.calls.at(-1)?.[0]?.appId).toBe("app-a");
});
});

View file

@ -0,0 +1,685 @@
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createToolFactoryHarness, type ToolLike } from "./tool-factory-test-harness.js";
const createFeishuClientMock = vi.hoisted(() => vi.fn());
const resolveFeishuToolAccountMock = vi.hoisted(() => vi.fn());
const fetchRemoteMediaMock = vi.hoisted(() => vi.fn());
const loadWebMediaMock = vi.hoisted(() => vi.fn());
const convertMock = vi.hoisted(() => vi.fn());
const documentCreateMock = vi.hoisted(() => vi.fn());
const blockListMock = vi.hoisted(() => vi.fn());
const blockChildrenCreateMock = vi.hoisted(() => vi.fn());
const blockChildrenGetMock = vi.hoisted(() => vi.fn());
const blockChildrenBatchDeleteMock = vi.hoisted(() => vi.fn());
const blockDescendantCreateMock = vi.hoisted(() => vi.fn());
const driveUploadAllMock = vi.hoisted(() => vi.fn());
const permissionMemberCreateMock = vi.hoisted(() => vi.fn());
const blockPatchMock = vi.hoisted(() => vi.fn());
const scopeListMock = vi.hoisted(() => vi.fn());
const toolAccountModule = await import("./tool-account.js");
const runtimeModule = await import("./runtime.js");
vi.spyOn(toolAccountModule, "createFeishuToolClient").mockImplementation(() =>
createFeishuClientMock(),
);
vi.spyOn(toolAccountModule, "resolveAnyEnabledFeishuToolsConfig").mockReturnValue({
doc: true,
chat: false,
wiki: false,
drive: false,
perm: false,
scopes: false,
});
vi.spyOn(toolAccountModule, "resolveFeishuToolAccount").mockImplementation((...args) =>
resolveFeishuToolAccountMock(...args),
);
vi.spyOn(runtimeModule, "getFeishuRuntime").mockImplementation(
() =>
({
channel: {
media: {
fetchRemoteMedia: fetchRemoteMediaMock,
saveMediaBuffer: vi.fn(),
},
},
media: {
loadWebMedia: loadWebMediaMock,
detectMime: vi.fn(async () => "application/octet-stream"),
mediaKindFromMime: vi.fn(() => "image"),
isVoiceCompatibleAudio: vi.fn(() => false),
getImageMetadata: vi.fn(async () => null),
resizeToJpeg: vi.fn(async () => Buffer.alloc(0)),
},
}) as unknown as ReturnType<typeof runtimeModule.getFeishuRuntime>,
);
const { registerFeishuDocTools } = await import("./docx.js");
type ToolResultWithDetails = {
details: Record<string, unknown>;
};
describe("feishu_doc image fetch hardening", () => {
beforeEach(() => {
vi.clearAllMocks();
createFeishuClientMock.mockReturnValue({
docx: {
document: {
convert: convertMock,
create: documentCreateMock,
},
documentBlock: {
list: blockListMock,
patch: blockPatchMock,
},
documentBlockChildren: {
create: blockChildrenCreateMock,
get: blockChildrenGetMock,
batchDelete: blockChildrenBatchDeleteMock,
},
documentBlockDescendant: {
create: blockDescendantCreateMock,
},
},
drive: {
media: {
uploadAll: driveUploadAllMock,
},
permissionMember: {
create: permissionMemberCreateMock,
},
},
application: {
scope: {
list: scopeListMock,
},
},
});
resolveFeishuToolAccountMock.mockReturnValue({
config: { mediaMaxMb: 30 },
});
convertMock.mockResolvedValue({
code: 0,
data: {
blocks: [{ block_type: 27 }],
first_level_block_ids: [],
},
});
blockListMock.mockResolvedValue({
code: 0,
data: {
items: [],
},
});
blockChildrenCreateMock.mockResolvedValue({
code: 0,
data: {
children: [{ block_type: 27, block_id: "img_block_1" }],
},
});
blockChildrenGetMock.mockResolvedValue({
code: 0,
data: { items: [{ block_id: "placeholder_block_1" }] },
});
blockChildrenBatchDeleteMock.mockResolvedValue({ code: 0 });
// write/append use Descendant API; return image block so processImages runs
blockDescendantCreateMock.mockResolvedValue({
code: 0,
data: { children: [{ block_type: 27, block_id: "img_block_1" }] },
});
driveUploadAllMock.mockResolvedValue({ file_token: "token_1" });
documentCreateMock.mockResolvedValue({
code: 0,
data: { document: { document_id: "doc_created", title: "Created Doc" } },
});
permissionMemberCreateMock.mockResolvedValue({ code: 0 });
blockPatchMock.mockResolvedValue({ code: 0 });
scopeListMock.mockResolvedValue({ code: 0, data: { scopes: [] } });
});
function resolveFeishuDocTool(context: Record<string, unknown> = {}) {
const harness = createToolFactoryHarness({
channels: {
feishu: {
enabled: true,
appId: "app_id",
appSecret: "app_secret",
},
},
});
registerFeishuDocTools(harness.api);
const tool = harness.resolveTool("feishu_doc", context);
expect(tool).toBeDefined();
return tool;
}
async function executeFeishuDocTool(
tool: ToolLike,
params: Record<string, unknown>,
): Promise<ToolResultWithDetails> {
return (await tool.execute("tool-call", params)) as ToolResultWithDetails;
}
it("inserts blocks sequentially to preserve document order", async () => {
const blocks = [
{ block_type: 3, block_id: "h1" },
{ block_type: 2, block_id: "t1" },
{ block_type: 3, block_id: "h2" },
];
convertMock.mockResolvedValue({
code: 0,
data: {
blocks,
first_level_block_ids: ["h1", "t1", "h2"],
},
});
blockListMock.mockResolvedValue({ code: 0, data: { items: [] } });
blockDescendantCreateMock.mockResolvedValueOnce({
code: 0,
data: { children: [{ block_type: 3, block_id: "h1" }] },
});
const feishuDocTool = resolveFeishuDocTool();
const result = await executeFeishuDocTool(feishuDocTool, {
action: "append",
doc_token: "doc_1",
content: "plain text body",
});
expect(blockDescendantCreateMock).toHaveBeenCalledTimes(1);
const call = blockDescendantCreateMock.mock.calls[0]?.[0];
expect(call?.data.children_id).toEqual(["h1", "t1", "h2"]);
expect(call?.data.descendants).toBeDefined();
expect(call?.data.descendants.length).toBeGreaterThanOrEqual(3);
expect(result.details.blocks_added).toBe(3);
});
it("reorders convert output by document tree instead of raw block array order", async () => {
const blocks = [
{ block_type: 13, block_id: "li2", parent_id: "list1" },
{ block_type: 4, block_id: "h2" },
{ block_type: 13, block_id: "li1", parent_id: "list1" },
{ block_type: 3, block_id: "h1" },
{ block_type: 12, block_id: "list1", children: ["li1", "li2"] },
{ block_type: 2, block_id: "p1" },
];
convertMock.mockResolvedValue({
code: 0,
data: {
blocks,
first_level_block_ids: ["h1", "p1", "h2", "list1"],
},
});
blockDescendantCreateMock.mockImplementationOnce(async ({ data }) => ({
code: 0,
data: {
children: (data.children_id as string[]).map((id) => ({ block_id: id })),
},
}));
const feishuDocTool = resolveFeishuDocTool();
await feishuDocTool.execute("tool-call", {
action: "append",
doc_token: "doc_1",
content: "tree reorder",
});
const call = blockDescendantCreateMock.mock.calls[0]?.[0];
expect(call?.data.children_id).toEqual(["h1", "p1", "h2", "list1"]);
expect((call?.data.descendants as Array<{ block_id: string }>).map((b) => b.block_id)).toEqual([
"h1",
"p1",
"h2",
"list1",
"li1",
"li2",
]);
});
it("falls back to size-based convert chunking for long no-heading markdown", async () => {
let successChunkCount = 0;
convertMock.mockImplementation(async ({ data }) => {
const content = data.content as string;
if (content.length > 280) {
return { code: 999, msg: "content too large" };
}
successChunkCount++;
const blockId = `b_${successChunkCount}`;
return {
code: 0,
data: {
blocks: [{ block_type: 2, block_id: blockId }],
first_level_block_ids: [blockId],
},
};
});
blockDescendantCreateMock.mockImplementation(async ({ data }) => ({
code: 0,
data: {
children: (data.children_id as string[]).map((id) => ({
block_id: id,
})),
},
}));
const feishuDocTool = resolveFeishuDocTool();
const longMarkdown = Array.from(
{ length: 120 },
(_, i) => `line ${i} with enough content to trigger fallback chunking`,
).join("\n");
const result = await executeFeishuDocTool(feishuDocTool, {
action: "append",
doc_token: "doc_1",
content: longMarkdown,
});
expect(convertMock.mock.calls.length).toBeGreaterThan(1);
expect(successChunkCount).toBeGreaterThan(1);
expect(result.details.blocks_added).toBe(successChunkCount);
});
it("keeps fenced code blocks balanced when size fallback split is needed", async () => {
const convertedChunks: string[] = [];
let successChunkCount = 0;
let failFirstConvert = true;
convertMock.mockImplementation(async ({ data }) => {
const content = data.content as string;
convertedChunks.push(content);
if (failFirstConvert) {
failFirstConvert = false;
return { code: 999, msg: "content too large" };
}
successChunkCount++;
const blockId = `c_${successChunkCount}`;
return {
code: 0,
data: {
blocks: [{ block_type: 2, block_id: blockId }],
first_level_block_ids: [blockId],
},
};
});
blockChildrenCreateMock.mockImplementation(async ({ data }) => ({
code: 0,
data: { children: data.children },
}));
const feishuDocTool = resolveFeishuDocTool();
const fencedMarkdown = [
"## Section",
"```ts",
"const alpha = 1;",
"const beta = 2;",
"const gamma = alpha + beta;",
"console.log(gamma);",
"```",
"",
"Tail paragraph one with enough text to exceed API limits when combined. ".repeat(8),
"Tail paragraph two with enough text to exceed API limits when combined. ".repeat(8),
"Tail paragraph three with enough text to exceed API limits when combined. ".repeat(8),
].join("\n");
const result = await executeFeishuDocTool(feishuDocTool, {
action: "append",
doc_token: "doc_1",
content: fencedMarkdown,
});
expect(convertMock.mock.calls.length).toBeGreaterThan(1);
expect(successChunkCount).toBeGreaterThan(1);
for (const chunk of convertedChunks) {
const fenceCount = chunk.match(/```/g)?.length ?? 0;
expect(fenceCount % 2).toBe(0);
}
expect(result.details.blocks_added).toBe(successChunkCount);
});
it("skips image upload when markdown image URL is blocked", async () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
fetchRemoteMediaMock.mockRejectedValueOnce(
new Error("Blocked: resolves to private/internal IP address"),
);
const feishuDocTool = resolveFeishuDocTool();
const result = await executeFeishuDocTool(feishuDocTool, {
action: "write",
doc_token: "doc_1",
content: "![x](https://x.test/image.png)",
});
expect(fetchRemoteMediaMock).toHaveBeenCalled();
expect(driveUploadAllMock).not.toHaveBeenCalled();
expect(blockPatchMock).not.toHaveBeenCalled();
expect(result.details.images_processed).toBe(0);
expect(consoleErrorSpy).toHaveBeenCalled();
consoleErrorSpy.mockRestore();
});
it("create grants permission only to trusted Feishu requester", async () => {
const feishuDocTool = resolveFeishuDocTool({
messageChannel: "feishu",
requesterSenderId: "ou_123",
});
const result = await executeFeishuDocTool(feishuDocTool, {
action: "create",
title: "Demo",
});
expect(result.details.document_id).toBe("doc_created");
expect(result.details.requester_permission_added).toBe(true);
expect(result.details.requester_open_id).toBe("ou_123");
expect(result.details.requester_perm_type).toBe("edit");
expect(permissionMemberCreateMock).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
member_type: "openid",
member_id: "ou_123",
perm: "edit",
}),
}),
);
});
it("create skips requester grant when trusted requester identity is unavailable", async () => {
const feishuDocTool = resolveFeishuDocTool({
messageChannel: "feishu",
});
const result = await executeFeishuDocTool(feishuDocTool, {
action: "create",
title: "Demo",
});
expect(permissionMemberCreateMock).not.toHaveBeenCalled();
expect(result.details.requester_permission_added).toBe(false);
expect(result.details.requester_permission_skipped_reason).toContain("trusted requester");
});
it("create never grants permissions when grant_to_requester is false", async () => {
const feishuDocTool = resolveFeishuDocTool({
messageChannel: "feishu",
requesterSenderId: "ou_123",
});
const result = await executeFeishuDocTool(feishuDocTool, {
action: "create",
title: "Demo",
grant_to_requester: false,
});
expect(permissionMemberCreateMock).not.toHaveBeenCalled();
expect(result.details.requester_permission_added).toBeUndefined();
});
it("returns an error when create response omits document_id", async () => {
documentCreateMock.mockResolvedValueOnce({
code: 0,
data: { document: { title: "Created Doc" } },
});
const feishuDocTool = resolveFeishuDocTool();
const result = await executeFeishuDocTool(feishuDocTool, {
action: "create",
title: "Demo",
});
expect(result.details.error).toContain("no document_id");
});
it("uploads local file to doc via upload_file action", async () => {
blockChildrenCreateMock.mockResolvedValueOnce({
code: 0,
data: {
children: [{ block_type: 23, block_id: "file_block_1" }],
},
});
loadWebMediaMock.mockResolvedValueOnce({
buffer: Buffer.from("hello from local file", "utf8"),
fileName: "test-local.txt",
});
const feishuDocTool = resolveFeishuDocTool();
const result = await executeFeishuDocTool(feishuDocTool, {
action: "upload_file",
doc_token: "doc_1",
file_path: "/tmp/allowed/test-local.txt",
filename: "test-local.txt",
});
expect(result.details.success).toBe(true);
expect(result.details.file_token).toBe("token_1");
expect(result.details.file_name).toBe("test-local.txt");
// Without workspace-only policy, localRoots stays undefined so loadWebMedia
// applies its default managed-root access behavior.
expect(loadWebMediaMock).toHaveBeenCalledWith(
expect.stringContaining("test-local.txt"),
expect.objectContaining({ optimizeImages: false, localRoots: undefined }),
);
expect(driveUploadAllMock).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
parent_type: "docx_file",
parent_node: "doc_1",
file_name: "test-local.txt",
}),
}),
);
});
it("passes workspace localRoots for upload_file when workspace-only policy is active", async () => {
blockChildrenCreateMock.mockResolvedValueOnce({
code: 0,
data: {
children: [{ block_type: 23, block_id: "file_block_1" }],
},
});
loadWebMediaMock.mockResolvedValueOnce({
buffer: Buffer.from("hello from local file", "utf8"),
fileName: "test-local.txt",
});
const feishuDocTool = resolveFeishuDocTool({
workspaceDir: "/workspace",
fsPolicy: { workspaceOnly: true },
});
await executeFeishuDocTool(feishuDocTool, {
action: "upload_file",
doc_token: "doc_1",
file_path: "/tmp/openclaw-1000/test-local.txt",
filename: "test-local.txt",
});
expect(loadWebMediaMock).toHaveBeenCalledWith(
expect.stringContaining("test-local.txt"),
expect.objectContaining({ optimizeImages: false, localRoots: ["/workspace"] }),
);
});
it("passes empty localRoots when workspace-only policy is active without workspaceDir", async () => {
blockChildrenCreateMock.mockResolvedValueOnce({
code: 0,
data: {
children: [{ block_type: 23, block_id: "file_block_1" }],
},
});
loadWebMediaMock.mockResolvedValueOnce({
buffer: Buffer.from("hello from local file", "utf8"),
fileName: "test-local.txt",
});
const feishuDocTool = resolveFeishuDocTool({
fsPolicy: { workspaceOnly: true },
});
await executeFeishuDocTool(feishuDocTool, {
action: "upload_file",
doc_token: "doc_1",
file_path: "/tmp/openclaw-1000/test-local.txt",
filename: "test-local.txt",
});
expect(loadWebMediaMock).toHaveBeenCalledWith(
expect.stringContaining("test-local.txt"),
expect.objectContaining({ optimizeImages: false, localRoots: [] }),
);
});
it("passes workspace localRoots for upload_image local paths when workspace-only policy is active", async () => {
loadWebMediaMock.mockResolvedValueOnce({
buffer: Buffer.from("hello from local file", "utf8"),
fileName: "test-local.png",
});
const feishuDocTool = resolveFeishuDocTool({
workspaceDir: "/workspace",
fsPolicy: { workspaceOnly: true },
});
await executeFeishuDocTool(feishuDocTool, {
action: "upload_image",
doc_token: "doc_1",
image: "./test-local.png",
filename: "test-local.png",
});
expect(loadWebMediaMock).toHaveBeenCalledWith(
expect.stringContaining("test-local.png"),
expect.objectContaining({ optimizeImages: false, localRoots: ["/workspace"] }),
);
});
it("passes workspace localRoots for upload_image absolute local paths when workspace-only policy is active", async () => {
const fixtureDir = path.join(process.cwd(), ".tmp-docx-upload-image-absolute");
const absoluteImagePath = path.join(fixtureDir, "absolute-image.png");
mkdirSync(fixtureDir, { recursive: true });
writeFileSync(absoluteImagePath, "not-real-image");
loadWebMediaMock.mockResolvedValueOnce({
buffer: Buffer.from("hello from local file", "utf8"),
fileName: "absolute-image.png",
});
const feishuDocTool = resolveFeishuDocTool({
workspaceDir: "/workspace",
fsPolicy: { workspaceOnly: true },
});
try {
await executeFeishuDocTool(feishuDocTool, {
action: "upload_image",
doc_token: "doc_1",
image: absoluteImagePath,
filename: "absolute-image.png",
});
expect(loadWebMediaMock).toHaveBeenCalledWith(
expect.stringContaining("absolute-image.png"),
expect.objectContaining({ optimizeImages: false, localRoots: ["/workspace"] }),
);
} finally {
rmSync(fixtureDir, { recursive: true, force: true });
}
});
it("returns an error when upload_file cannot list placeholder siblings", async () => {
blockChildrenCreateMock.mockResolvedValueOnce({
code: 0,
data: {
children: [{ block_type: 23, block_id: "file_block_1" }],
},
});
blockChildrenGetMock.mockResolvedValueOnce({
code: 999,
msg: "list failed",
data: { items: [] },
});
loadWebMediaMock.mockResolvedValueOnce({
buffer: Buffer.from("hello from local file", "utf8"),
fileName: "test-local.txt",
});
const feishuDocTool = resolveFeishuDocTool();
const result = await executeFeishuDocTool(feishuDocTool, {
action: "upload_file",
doc_token: "doc_1",
file_path: "/tmp/allowed/test-local.txt",
filename: "test-local.txt",
});
expect(result.details.error).toBe("list failed");
expect(driveUploadAllMock).not.toHaveBeenCalled();
});
it("rejects traversal paths in upload_file via loadWebMedia sandbox", async () => {
loadWebMediaMock.mockRejectedValueOnce(
new Error("Local media path is not under an allowed directory: /etc/passwd"),
);
const feishuDocTool = resolveFeishuDocTool();
const result = await executeFeishuDocTool(feishuDocTool, {
action: "upload_file",
doc_token: "doc_1",
file_path: "/etc/passwd",
});
expect(result.details.error).toContain("not under an allowed directory");
expect(driveUploadAllMock).not.toHaveBeenCalled();
});
it("rejects traversal paths in upload_image via loadWebMedia sandbox", async () => {
blockChildrenCreateMock.mockResolvedValueOnce({
code: 0,
data: {
children: [{ block_type: 27, block_id: "img_block_1" }],
},
});
loadWebMediaMock.mockRejectedValueOnce(
new Error(
"Local media path is not under an allowed directory: /home/admin/.openclaw/openclaw.json",
),
);
const feishuDocTool = resolveFeishuDocTool();
const result = await executeFeishuDocTool(feishuDocTool, {
action: "upload_image",
doc_token: "doc_1",
file_path: "/home/admin/.openclaw/openclaw.json",
});
expect(result.details.error).toContain("not under an allowed directory");
expect(driveUploadAllMock).not.toHaveBeenCalled();
});
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,92 @@
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"),
]);
const CommentFileType = Type.Union([
Type.Literal("doc"),
Type.Literal("docx"),
Type.Literal("sheet"),
Type.Literal("file"),
Type.Literal("slides"),
]);
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,
}),
Type.Object({
action: Type.Literal("list_comments"),
file_token: Type.String({ description: "Document token" }),
file_type: Type.Optional(CommentFileType),
page_size: Type.Optional(Type.Integer({ minimum: 1, maximum: 100, description: "Page size" })),
page_token: Type.Optional(Type.String({ description: "Comment page token" })),
}),
Type.Object({
action: Type.Literal("list_comment_replies"),
file_token: Type.String({ description: "Document token" }),
file_type: Type.Optional(CommentFileType),
comment_id: Type.String({ description: "Comment id" }),
page_size: Type.Optional(Type.Integer({ minimum: 1, maximum: 100, description: "Page size" })),
page_token: Type.Optional(Type.String({ description: "Reply page token" })),
}),
Type.Object({
action: Type.Literal("add_comment"),
file_token: Type.String({ description: "Document token" }),
file_type: Type.Optional(
Type.Union([Type.Literal("doc"), Type.Literal("docx")], {
description: "Document type. Defaults to docx when omitted.",
}),
),
content: Type.String({ description: "Comment text content" }),
block_id: Type.Optional(
Type.String({
description:
"Optional docx block id for a local comment. Omit to create a full-document comment.",
}),
),
}),
Type.Object({
action: Type.Literal("reply_comment"),
file_token: Type.String({ description: "Document token" }),
file_type: Type.Optional(CommentFileType),
comment_id: Type.String({ description: "Comment id" }),
content: Type.String({ description: "Reply text content" }),
}),
]);
export type FeishuDriveParams = Static<typeof FeishuDriveSchema>;

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,849 @@
import type * as Lark from "@larksuiteoapi/node-sdk";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import type { OpenClawPluginApi } from "../runtime-api.js";
import { listEnabledFeishuAccounts } from "./accounts.js";
import { cleanupAmbientCommentTypingReaction } from "./comment-reaction.js";
import { encodeQuery, extractReplyText, isRecord, readString } from "./comment-shared.js";
import { parseFeishuCommentTarget, type CommentFileType } from "./comment-target.js";
import { FeishuDriveSchema, type FeishuDriveParams } from "./drive-schema.js";
import { createFeishuToolClient, resolveAnyEnabledFeishuToolsConfig } from "./tool-account.js";
import {
jsonToolResult,
toolExecutionErrorResult,
unknownToolActionResult,
} from "./tool-result.js";
// ============ Actions ============
type FeishuExplorerRootFolderMetaResponse = {
code: number;
msg?: string;
data?: {
token?: string;
};
};
type FeishuDriveInternalClient = Lark.Client & {
domain?: string;
httpInstance: Pick<Lark.HttpInstance, "get">;
request(params: {
method: "GET" | "POST";
url: string;
params?: Record<string, string | undefined>;
data: unknown;
timeout?: number;
}): Promise<unknown>;
};
type FeishuDriveApiResponse<T> = {
code: number;
log_id?: string;
msg?: string;
data?: T;
};
class FeishuReplyCommentError extends Error {
httpStatus?: number;
feishuCode?: number | string;
feishuMsg?: string;
feishuLogId?: string;
constructor(params: {
message: string;
httpStatus?: number;
feishuCode?: number | string;
feishuMsg?: string;
feishuLogId?: string;
}) {
super(params.message);
this.name = "FeishuReplyCommentError";
this.httpStatus = params.httpStatus;
this.feishuCode = params.feishuCode;
this.feishuMsg = params.feishuMsg;
this.feishuLogId = params.feishuLogId;
}
}
type FeishuDriveCommentReply = {
reply_id?: string;
user_id?: string;
create_time?: number;
update_time?: number;
content?: {
elements?: unknown[];
};
};
type FeishuDriveCommentCard = {
comment_id?: string;
user_id?: string;
create_time?: number;
update_time?: number;
is_solved?: boolean;
is_whole?: boolean;
has_more?: boolean;
page_token?: string;
quote?: string;
reply_list?: {
replies?: FeishuDriveCommentReply[];
};
};
type FeishuDriveListCommentsResponse = FeishuDriveApiResponse<{
has_more?: boolean;
items?: FeishuDriveCommentCard[];
page_token?: string;
}>;
type FeishuDriveListRepliesResponse = FeishuDriveApiResponse<{
has_more?: boolean;
items?: FeishuDriveCommentReply[];
page_token?: string;
}>;
type FeishuDriveToolContext = {
deliveryContext?: {
channel?: string;
to?: string;
threadId?: string | number;
};
};
const FEISHU_DRIVE_REQUEST_TIMEOUT_MS = 30_000;
function getDriveInternalClient(client: Lark.Client): FeishuDriveInternalClient {
return client as FeishuDriveInternalClient;
}
function buildReplyElements(content: string) {
return [{ type: "text", text: content }];
}
async function requestDriveApi<T>(params: {
client: Lark.Client;
method: "GET" | "POST";
url: string;
query?: Record<string, string | undefined>;
data?: unknown;
}): Promise<T> {
const internalClient = getDriveInternalClient(params.client);
return (await internalClient.request({
method: params.method,
url: params.url,
params: params.query ?? {},
data: params.data ?? {},
timeout: FEISHU_DRIVE_REQUEST_TIMEOUT_MS,
})) as T;
}
function assertDriveApiSuccess<T extends { code: number; msg?: string }>(response: T): T {
if (response.code !== 0) {
throw new Error(response.msg ?? "Feishu Drive API request failed");
}
return response;
}
function normalizeCommentReply(reply: FeishuDriveCommentReply) {
return {
reply_id: reply.reply_id,
user_id: reply.user_id,
create_time: reply.create_time,
update_time: reply.update_time,
text: extractReplyText(reply),
};
}
function normalizeCommentCard(comment: FeishuDriveCommentCard) {
const replies = comment.reply_list?.replies ?? [];
const rootReply = replies[0];
return {
comment_id: comment.comment_id,
user_id: comment.user_id,
create_time: comment.create_time,
update_time: comment.update_time,
is_solved: comment.is_solved,
is_whole: comment.is_whole,
quote: comment.quote,
text: extractReplyText(rootReply),
has_more_replies: comment.has_more,
replies_page_token: comment.page_token,
replies: replies.slice(1).map(normalizeCommentReply),
};
}
function normalizeCommentPageSize(pageSize: number | undefined): string | undefined {
if (typeof pageSize !== "number" || !Number.isFinite(pageSize)) {
return undefined;
}
return String(Math.min(Math.max(Math.floor(pageSize), 1), 100));
}
function resolveAmbientCommentTarget(context: FeishuDriveToolContext | undefined) {
const deliveryContext = context?.deliveryContext;
if (deliveryContext?.channel && deliveryContext.channel !== "feishu") {
return null;
}
return parseFeishuCommentTarget(deliveryContext?.to);
}
function applyAmbientCommentDefaults<
T extends {
file_token?: string;
file_type?: CommentFileType;
comment_id?: string;
},
>(params: T, context: FeishuDriveToolContext | undefined): T {
const ambient = resolveAmbientCommentTarget(context);
if (!ambient) {
return params;
}
return {
...params,
file_token: params.file_token?.trim() || ambient.fileToken,
file_type: params.file_type ?? ambient.fileType,
comment_id: params.comment_id?.trim() || ambient.commentId,
};
}
function applyAddCommentAmbientDefaults<
T extends {
file_token?: string;
file_type?: "doc" | "docx";
},
>(params: T, context: FeishuDriveToolContext | undefined): T {
const ambient = resolveAmbientCommentTarget(context);
if (!ambient || (ambient.fileType !== "doc" && ambient.fileType !== "docx")) {
return params;
}
return {
...params,
file_token: params.file_token?.trim() || ambient.fileToken,
file_type: params.file_type ?? ambient.fileType,
};
}
function applyAddCommentDefaults<
T extends {
file_token?: string;
file_type?: "doc" | "docx";
},
>(params: T): T & { file_type: "doc" | "docx" } {
const fileType = params.file_type ?? "docx";
if (!params.file_type) {
console.info(
`[feishu_drive] add_comment missing file_type; defaulting to docx ` +
`file_token=${params.file_token ?? "unknown"}`,
);
}
return {
...params,
file_type: fileType,
};
}
function applyCommentFileTypeDefault<
T extends {
file_token?: string;
file_type?: CommentFileType;
},
>(
params: T,
action: "list_comments" | "list_comment_replies" | "reply_comment",
): T & {
file_type: CommentFileType;
} {
const fileType = params.file_type ?? "docx";
if (!params.file_type) {
console.info(
`[feishu_drive] ${action} missing file_type; defaulting to docx ` +
`file_token=${params.file_token ?? "unknown"}`,
);
}
return {
...params,
file_type: fileType,
};
}
function formatDriveApiError(error: unknown): string {
if (!isRecord(error)) {
return typeof error === "string" ? error : JSON.stringify(error);
}
const response = isRecord(error.response) ? error.response : undefined;
const responseData = isRecord(response?.data) ? response?.data : undefined;
return JSON.stringify({
message:
typeof error.message === "string"
? error.message
: typeof error === "string"
? error
: JSON.stringify(error),
code: readString(error.code),
method: readString(isRecord(error.config) ? error.config.method : undefined),
url: readString(isRecord(error.config) ? error.config.url : undefined),
params: isRecord(error.config) ? error.config.params : undefined,
http_status: typeof response?.status === "number" ? response.status : undefined,
feishu_code:
typeof responseData?.code === "number" ? responseData.code : readString(responseData?.code),
feishu_msg: readString(responseData?.msg),
feishu_log_id: readString(responseData?.log_id),
});
}
function extractDriveApiErrorMeta(error: unknown): {
message: string;
httpStatus?: number;
feishuCode?: number | string;
feishuMsg?: string;
feishuLogId?: string;
} {
if (!isRecord(error)) {
return { message: typeof error === "string" ? error : JSON.stringify(error) };
}
const response = isRecord(error.response) ? error.response : undefined;
const responseData = isRecord(response?.data) ? response?.data : undefined;
return {
message:
typeof error.message === "string"
? error.message
: typeof error === "string"
? error
: JSON.stringify(error),
httpStatus: typeof response?.status === "number" ? response.status : undefined,
feishuCode:
typeof responseData?.code === "number" ? responseData.code : readString(responseData?.code),
feishuMsg: readString(responseData?.msg),
feishuLogId: readString(responseData?.log_id),
};
}
function isReplyNotAllowedError(error: unknown): boolean {
if (!(error instanceof FeishuReplyCommentError)) {
return false;
}
return error.feishuCode === 1069302;
}
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 internalClient = getDriveInternalClient(client);
const domain = internalClient.domain ?? "https://open.feishu.cn";
const res = (await internalClient.httpInstance.get(
`${domain}/open-apis/drive/explorer/v2/root_folder/meta`,
)) as FeishuExplorerRootFolderMetaResponse;
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,
};
}
async function listComments(
client: Lark.Client,
params: {
file_token: string;
file_type: CommentFileType;
page_size?: number;
page_token?: string;
},
) {
const response = assertDriveApiSuccess(
await requestDriveApi<FeishuDriveListCommentsResponse>({
client,
method: "GET",
url:
`/open-apis/drive/v1/files/${encodeURIComponent(params.file_token)}/comments` +
encodeQuery({
file_type: params.file_type,
page_size: normalizeCommentPageSize(params.page_size),
page_token: params.page_token,
user_id_type: "open_id",
}),
}),
);
return {
has_more: response.data?.has_more ?? false,
page_token: response.data?.page_token,
comments: (response.data?.items ?? []).map(normalizeCommentCard),
};
}
async function listCommentReplies(
client: Lark.Client,
params: {
file_token: string;
file_type: CommentFileType;
comment_id: string;
page_size?: number;
page_token?: string;
},
) {
const response = assertDriveApiSuccess(
await requestDriveApi<FeishuDriveListRepliesResponse>({
client,
method: "GET",
url:
`/open-apis/drive/v1/files/${encodeURIComponent(params.file_token)}/comments/${encodeURIComponent(
params.comment_id,
)}/replies` +
encodeQuery({
file_type: params.file_type,
page_size: normalizeCommentPageSize(params.page_size),
page_token: params.page_token,
user_id_type: "open_id",
}),
}),
);
return {
has_more: response.data?.has_more ?? false,
page_token: response.data?.page_token,
replies: (response.data?.items ?? []).map(normalizeCommentReply),
};
}
async function addComment(
client: Lark.Client,
params: {
file_token: string;
file_type: "doc" | "docx";
content: string;
block_id?: string;
},
): Promise<{ success: true } & Record<string, unknown>> {
if (params.block_id?.trim() && params.file_type !== "docx") {
throw new Error("block_id is only supported for docx comments");
}
const response = assertDriveApiSuccess(
await requestDriveApi<FeishuDriveApiResponse<Record<string, unknown>>>({
client,
method: "POST",
url: `/open-apis/drive/v1/files/${encodeURIComponent(params.file_token)}/new_comments`,
data: {
file_type: params.file_type,
reply_elements: buildReplyElements(params.content),
...(params.block_id?.trim() ? { anchor: { block_id: params.block_id.trim() } } : {}),
},
}),
);
return {
success: true,
...response.data,
};
}
// Fetch comment metadata via batch_query because the single-comment endpoint
// does not support partial comments.
async function queryCommentById(
client: Lark.Client,
params: {
file_token: string;
file_type: CommentFileType;
comment_id: string;
},
) {
const response = assertDriveApiSuccess(
await requestDriveApi<FeishuDriveListCommentsResponse>({
client,
method: "POST",
url:
`/open-apis/drive/v1/files/${encodeURIComponent(params.file_token)}/comments/batch_query` +
encodeQuery({
file_type: params.file_type,
user_id_type: "open_id",
}),
data: {
comment_ids: [params.comment_id],
},
}),
);
return response.data?.items?.find((comment) => comment.comment_id?.trim() === params.comment_id);
}
export async function replyComment(
client: Lark.Client,
params: {
file_token: string;
file_type: CommentFileType;
comment_id: string;
content: string;
},
): Promise<{ success: true; reply_id?: string } & Record<string, unknown>> {
const url = `/open-apis/drive/v1/files/${encodeURIComponent(params.file_token)}/comments/${encodeURIComponent(
params.comment_id,
)}/replies`;
const query = { file_type: params.file_type };
try {
const response = await requestDriveApi<FeishuDriveApiResponse<Record<string, unknown>>>({
client,
method: "POST",
url,
query,
data: {
content: {
elements: [
{
type: "text_run",
text_run: {
text: params.content,
},
},
],
},
},
});
if (response.code === 0) {
return {
success: true,
...response.data,
};
}
console.warn(
`[feishu_drive] replyComment failed ` +
`comment=${params.comment_id} file_type=${params.file_type} ` +
`code=${response.code ?? "unknown"} ` +
`msg=${response.msg ?? "unknown"} log_id=${response.log_id ?? "unknown"}`,
);
throw new FeishuReplyCommentError({
message: response.msg ?? "Feishu Drive reply comment failed",
feishuCode: response.code,
feishuMsg: response.msg,
feishuLogId: response.log_id,
});
} catch (error) {
if (error instanceof FeishuReplyCommentError) {
throw error;
}
const meta = extractDriveApiErrorMeta(error);
console.warn(
`[feishu_drive] replyComment threw ` +
`comment=${params.comment_id} file_type=${params.file_type} ` +
`error=${formatDriveApiError(error)}`,
);
throw new FeishuReplyCommentError({
message: meta.message,
httpStatus: meta.httpStatus,
feishuCode: meta.feishuCode,
feishuMsg: meta.feishuMsg,
feishuLogId: meta.feishuLogId,
});
}
}
export async function deliverCommentThreadText(
client: Lark.Client,
params: {
file_token: string;
file_type: CommentFileType;
comment_id: string;
content: string;
is_whole_comment?: boolean;
},
): Promise<
| ({ success: true; reply_id?: string } & Record<string, unknown> & {
delivery_mode: "reply_comment";
})
| ({ success: true; comment_id?: string } & Record<string, unknown> & {
delivery_mode: "add_comment";
})
> {
let isWholeComment = params.is_whole_comment;
if (isWholeComment === undefined) {
try {
const comment = await queryCommentById(client, params);
isWholeComment = comment?.is_whole === true;
} catch (error) {
console.warn(
`[feishu_drive] comment metadata preflight failed ` +
`comment=${params.comment_id} file_type=${params.file_type} ` +
`error=${formatErrorMessage(error)}`,
);
isWholeComment = false;
}
}
if (isWholeComment) {
if (params.file_type !== "doc" && params.file_type !== "docx") {
throw new Error(
`Whole-document comment follow-ups are only supported for doc/docx (got ${params.file_type})`,
);
}
const wholeCommentFileType: "doc" | "docx" = params.file_type;
console.info(
`[feishu_drive] whole-comment compatibility path ` +
`comment=${params.comment_id} file_type=${params.file_type} mode=add_comment`,
);
return {
delivery_mode: "add_comment",
...(await addComment(client, {
file_token: params.file_token,
file_type: wholeCommentFileType,
content: params.content,
})),
};
}
try {
return {
delivery_mode: "reply_comment",
...(await replyComment(client, params)),
};
} catch (error) {
if (error instanceof FeishuReplyCommentError && isReplyNotAllowedError(error)) {
if (params.file_type !== "doc" && params.file_type !== "docx") {
throw error;
}
const fallbackFileType: "doc" | "docx" = params.file_type;
console.info(
`[feishu_drive] reply-not-allowed compatibility path ` +
`comment=${params.comment_id} file_type=${params.file_type} mode=add_comment ` +
`log_id=${error.feishuLogId ?? "unknown"}`,
);
return {
delivery_mode: "add_comment",
...(await addComment(client, {
file_token: params.file_token,
file_type: fallbackFileType,
content: params.content,
})),
};
}
throw error;
}
}
// ============ Tool Registration ============
export function registerFeishuDriveTools(api: OpenClawPluginApi) {
if (!api.config) {
api.logger.debug?.("feishu_drive: No config available, skipping drive tools");
return;
}
const accounts = listEnabledFeishuAccounts(api.config);
if (accounts.length === 0) {
api.logger.debug?.("feishu_drive: No Feishu accounts configured, skipping drive tools");
return;
}
const toolsCfg = resolveAnyEnabledFeishuToolsConfig(accounts);
if (!toolsCfg.drive) {
api.logger.debug?.("feishu_drive: drive tool disabled in config");
return;
}
type FeishuDriveExecuteParams = FeishuDriveParams & { accountId?: string };
api.registerTool(
(ctx) => {
const defaultAccountId = ctx.agentAccountId;
return {
name: "feishu_drive",
label: "Feishu Drive",
description:
"Feishu cloud storage operations. Actions: list, info, create_folder, move, delete, list_comments, list_comment_replies, add_comment, reply_comment",
parameters: FeishuDriveSchema,
async execute(_toolCallId, params) {
const p = params as FeishuDriveExecuteParams;
try {
const client = createFeishuToolClient({
api,
executeParams: p,
defaultAccountId,
});
switch (p.action) {
case "list":
return jsonToolResult(await listFolder(client, p.folder_token));
case "info":
return jsonToolResult(await getFileInfo(client, p.file_token));
case "create_folder":
return jsonToolResult(await createFolder(client, p.name, p.folder_token));
case "move":
return jsonToolResult(await moveFile(client, p.file_token, p.type, p.folder_token));
case "delete":
return jsonToolResult(await deleteFile(client, p.file_token, p.type));
case "list_comments": {
const resolved = applyCommentFileTypeDefault(
applyAmbientCommentDefaults(p, ctx),
"list_comments",
);
return jsonToolResult(await listComments(client, resolved));
}
case "list_comment_replies": {
const resolved = applyCommentFileTypeDefault(
applyAmbientCommentDefaults(p, ctx),
"list_comment_replies",
);
return jsonToolResult(await listCommentReplies(client, resolved));
}
case "add_comment": {
const resolved = applyAddCommentDefaults(applyAddCommentAmbientDefaults(p, ctx));
try {
return jsonToolResult(await addComment(client, resolved));
} finally {
void cleanupAmbientCommentTypingReaction({
client: getDriveInternalClient(client),
deliveryContext: ctx.deliveryContext,
});
}
}
case "reply_comment": {
const resolved = applyCommentFileTypeDefault(
applyAmbientCommentDefaults(p, ctx),
"reply_comment",
);
try {
return jsonToolResult(await deliverCommentThreadText(client, resolved));
} finally {
void cleanupAmbientCommentTypingReaction({
client: getDriveInternalClient(client),
deliveryContext: ctx.deliveryContext,
});
}
}
default:
return unknownToolActionResult((p as { action?: unknown }).action);
}
} catch (err) {
return toolExecutionErrorResult(err);
}
},
};
},
{ name: "feishu_drive" },
);
api.logger.debug?.(`feishu_drive: Registered feishu_drive tool`);
}

View file

@ -0,0 +1,131 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { OpenClawConfig, PluginRuntime } from "../runtime-api.js";
import type { DynamicAgentCreationConfig } from "./types.js";
export type MaybeCreateDynamicAgentResult = {
created: boolean;
updatedCfg: OpenClawConfig;
agentId?: string;
};
/**
* Check if a dynamic agent should be created for a DM user and create it if needed.
* This creates a unique agent instance with its own workspace for each DM user.
*/
export async function maybeCreateDynamicAgent(params: {
cfg: OpenClawConfig;
runtime: PluginRuntime;
senderOpenId: string;
dynamicCfg: DynamicAgentCreationConfig;
log: (msg: string) => void;
}): Promise<MaybeCreateDynamicAgentResult> {
const { cfg, runtime, senderOpenId, dynamicCfg, log } = params;
// Check if there's already a binding for this user
const existingBindings = cfg.bindings ?? [];
const hasBinding = existingBindings.some(
(b) =>
b.match?.channel === "feishu" &&
b.match?.peer?.kind === "direct" &&
b.match?.peer?.id === senderOpenId,
);
if (hasBinding) {
return { created: false, updatedCfg: cfg };
}
// Check maxAgents limit if configured
if (dynamicCfg.maxAgents !== undefined) {
const feishuAgentCount = (cfg.agents?.list ?? []).filter((a) =>
a.id.startsWith("feishu-"),
).length;
if (feishuAgentCount >= dynamicCfg.maxAgents) {
log(
`feishu: maxAgents limit (${dynamicCfg.maxAgents}) reached, not creating agent for ${senderOpenId}`,
);
return { created: false, updatedCfg: cfg };
}
}
// Use full OpenID as agent ID suffix (OpenID format: ou_xxx is already filesystem-safe)
const agentId = `feishu-${senderOpenId}`;
// Check if agent already exists (but binding was missing)
const existingAgent = (cfg.agents?.list ?? []).find((a) => a.id === agentId);
if (existingAgent) {
// Agent exists but binding doesn't - just add the binding
log(`feishu: agent "${agentId}" exists, adding missing binding for ${senderOpenId}`);
const updatedCfg: OpenClawConfig = {
...cfg,
bindings: [
...existingBindings,
{
agentId,
match: {
channel: "feishu",
peer: { kind: "direct", id: senderOpenId },
},
},
],
};
await runtime.config.writeConfigFile(updatedCfg);
return { created: true, updatedCfg, agentId };
}
// Resolve path templates with substitutions
const workspaceTemplate = dynamicCfg.workspaceTemplate ?? "~/.openclaw/workspace-{agentId}";
const agentDirTemplate = dynamicCfg.agentDirTemplate ?? "~/.openclaw/agents/{agentId}/agent";
const workspace = resolveUserPath(
workspaceTemplate.replace("{userId}", senderOpenId).replace("{agentId}", agentId),
);
const agentDir = resolveUserPath(
agentDirTemplate.replace("{userId}", senderOpenId).replace("{agentId}", agentId),
);
log(`feishu: creating dynamic agent "${agentId}" for user ${senderOpenId}`);
log(` workspace: ${workspace}`);
log(` agentDir: ${agentDir}`);
// Create directories
await fs.promises.mkdir(workspace, { recursive: true });
await fs.promises.mkdir(agentDir, { recursive: true });
// Update configuration with new agent and binding
const updatedCfg: OpenClawConfig = {
...cfg,
agents: {
...cfg.agents,
list: [...(cfg.agents?.list ?? []), { id: agentId, workspace, agentDir }],
},
bindings: [
...existingBindings,
{
agentId,
match: {
channel: "feishu",
peer: { kind: "direct", id: senderOpenId },
},
},
],
};
// Write updated config using PluginRuntime API
await runtime.config.writeConfigFile(updatedCfg);
return { created: true, updatedCfg, agentId };
}
/**
* Resolve a path that may start with ~ to the user's home directory.
*/
function resolveUserPath(p: string): string {
if (p.startsWith("~/")) {
return path.join(os.homedir(), p.slice(2));
}
return p;
}

View file

@ -0,0 +1,43 @@
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;
thread_id?: string;
chat_id: string;
chat_type: "p2p" | "group" | "private";
message_type: string;
content: string;
create_time?: 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;
};

View file

@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { normalizeFeishuExternalKey } from "./external-keys.js";
describe("normalizeFeishuExternalKey", () => {
it("accepts a normal feishu key and trims surrounding spaces", () => {
expect(normalizeFeishuExternalKey(" img_v3_01abcDEF123 ")).toBe("img_v3_01abcDEF123");
});
it("rejects traversal and path separator patterns", () => {
expect(normalizeFeishuExternalKey("../etc/passwd")).toBeUndefined();
expect(normalizeFeishuExternalKey("a/../../b")).toBeUndefined();
expect(normalizeFeishuExternalKey("a\\..\\b")).toBeUndefined();
});
it("rejects empty, non-string, and control-char values", () => {
expect(normalizeFeishuExternalKey(" ")).toBeUndefined();
expect(normalizeFeishuExternalKey(123)).toBeUndefined();
expect(normalizeFeishuExternalKey("abc\u0000def")).toBeUndefined();
});
});

View file

@ -0,0 +1,19 @@
const CONTROL_CHARS_RE = /\p{Cc}/u;
const MAX_EXTERNAL_KEY_LENGTH = 512;
export function normalizeFeishuExternalKey(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const normalized = value.trim();
if (!normalized || normalized.length > MAX_EXTERNAL_KEY_LENGTH) {
return undefined;
}
if (CONTROL_CHARS_RE.test(normalized)) {
return undefined;
}
if (normalized.includes("/") || normalized.includes("\\") || normalized.includes("..")) {
return undefined;
}
return normalized;
}

View file

@ -0,0 +1,150 @@
import { vi, type Mock } from "vitest";
type BoundConversation = {
bindingId: string;
targetSessionKey: string;
};
type UnknownMock = Mock<(...args: unknown[]) => unknown>;
type AsyncUnknownMock = Mock<(...args: unknown[]) => Promise<unknown>>;
type FinalizeInboundContextMock = Mock<
(ctx: Record<string, unknown>, opts?: unknown) => Record<string, unknown>
>;
type DispatchReplyCounts = {
final: number;
block?: number;
tool?: number;
};
type DispatchReplyContext = Record<string, unknown> & {
SessionKey?: string;
};
type DispatchReplyDispatcher = {
sendFinalReply: (payload: { text: string }) => unknown;
};
type DispatchReplyFromConfigMock = Mock<
(params: {
ctx: DispatchReplyContext;
dispatcher: DispatchReplyDispatcher;
}) => Promise<{ queuedFinal: boolean; counts: DispatchReplyCounts }>
>;
type WithReplyDispatcherMock = Mock<(params: { run: () => unknown }) => Promise<unknown>>;
type FeishuLifecycleTestMocks = {
createEventDispatcherMock: UnknownMock;
monitorWebSocketMock: AsyncUnknownMock;
monitorWebhookMock: AsyncUnknownMock;
createFeishuThreadBindingManagerMock: UnknownMock;
createFeishuReplyDispatcherMock: UnknownMock;
resolveBoundConversationMock: Mock<() => BoundConversation | null>;
touchBindingMock: UnknownMock;
resolveAgentRouteMock: UnknownMock;
resolveConfiguredBindingRouteMock: UnknownMock;
ensureConfiguredBindingRouteReadyMock: UnknownMock;
dispatchReplyFromConfigMock: DispatchReplyFromConfigMock;
withReplyDispatcherMock: WithReplyDispatcherMock;
finalizeInboundContextMock: FinalizeInboundContextMock;
getMessageFeishuMock: AsyncUnknownMock;
listFeishuThreadMessagesMock: AsyncUnknownMock;
sendMessageFeishuMock: AsyncUnknownMock;
sendCardFeishuMock: AsyncUnknownMock;
};
const feishuLifecycleTestMocks = vi.hoisted(
(): FeishuLifecycleTestMocks => ({
createEventDispatcherMock: vi.fn(),
monitorWebSocketMock: vi.fn(async () => {}),
monitorWebhookMock: vi.fn(async () => {}),
createFeishuThreadBindingManagerMock: vi.fn(() => ({ stop: vi.fn() })),
createFeishuReplyDispatcherMock: vi.fn(),
resolveBoundConversationMock: vi.fn<() => BoundConversation | null>(() => null),
touchBindingMock: vi.fn(),
resolveAgentRouteMock: vi.fn(),
resolveConfiguredBindingRouteMock: vi.fn(),
ensureConfiguredBindingRouteReadyMock: vi.fn(),
dispatchReplyFromConfigMock: vi.fn(),
withReplyDispatcherMock: vi.fn(),
finalizeInboundContextMock: vi.fn((ctx) => ctx),
getMessageFeishuMock: vi.fn(async () => null),
listFeishuThreadMessagesMock: vi.fn(async () => []),
sendMessageFeishuMock: vi.fn(async () => ({ messageId: "om_sent", chatId: "chat_default" })),
sendCardFeishuMock: vi.fn(async () => ({ messageId: "om_card", chatId: "chat_default" })),
}),
);
export function getFeishuLifecycleTestMocks(): FeishuLifecycleTestMocks {
return feishuLifecycleTestMocks;
}
const {
createEventDispatcherMock,
monitorWebSocketMock,
monitorWebhookMock,
createFeishuThreadBindingManagerMock,
createFeishuReplyDispatcherMock,
resolveBoundConversationMock,
touchBindingMock,
resolveConfiguredBindingRouteMock,
ensureConfiguredBindingRouteReadyMock,
getMessageFeishuMock,
listFeishuThreadMessagesMock,
sendMessageFeishuMock,
sendCardFeishuMock,
} = feishuLifecycleTestMocks;
vi.mock("./client.js", async () => {
const actual = await vi.importActual<typeof import("./client.js")>("./client.js");
return {
...actual,
createEventDispatcher: createEventDispatcherMock,
};
});
vi.mock("./monitor.transport.js", () => ({
monitorWebSocket: monitorWebSocketMock,
monitorWebhook: monitorWebhookMock,
}));
vi.mock("./thread-bindings.js", () => ({
createFeishuThreadBindingManager: createFeishuThreadBindingManagerMock,
}));
vi.mock("./reply-dispatcher.js", () => ({
createFeishuReplyDispatcher: createFeishuReplyDispatcherMock,
}));
vi.mock("./send.js", () => ({
sendCardFeishu: sendCardFeishuMock,
getMessageFeishu: getMessageFeishuMock,
listFeishuThreadMessages: listFeishuThreadMessagesMock,
sendMessageFeishu: sendMessageFeishuMock,
}));
vi.mock("openclaw/plugin-sdk/conversation-runtime", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/conversation-runtime")>(
"openclaw/plugin-sdk/conversation-runtime",
);
return {
...actual,
resolveConfiguredBindingRoute: (
params: Parameters<typeof actual.resolveConfiguredBindingRoute>[0],
) =>
resolveConfiguredBindingRouteMock.getMockImplementation()
? resolveConfiguredBindingRouteMock(params)
: actual.resolveConfiguredBindingRoute(params),
ensureConfiguredBindingRouteReady: (
params: Parameters<typeof actual.ensureConfiguredBindingRouteReady>[0],
) =>
ensureConfiguredBindingRouteReadyMock.getMockImplementation()
? ensureConfiguredBindingRouteReadyMock(params)
: actual.ensureConfiguredBindingRouteReady(params),
getSessionBindingService: () => ({
resolveByConversation: resolveBoundConversationMock,
touch: touchBindingMock,
}),
};
});
vi.mock("../../../src/infra/outbound/session-binding-service.js", () => ({
getSessionBindingService: () => ({
resolveByConversation: resolveBoundConversationMock,
touch: touchBindingMock,
}),
}));

View file

@ -0,0 +1,613 @@
import fs from "node:fs/promises";
import path from "node:path";
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig } from "../runtime-api.js";
const createFeishuClientMock = vi.hoisted(() => vi.fn());
const resolveFeishuAccountMock = vi.hoisted(() => vi.fn());
const normalizeFeishuTargetMock = vi.hoisted(() => vi.fn());
const resolveReceiveIdTypeMock = vi.hoisted(() => vi.fn());
const loadWebMediaMock = vi.hoisted(() => vi.fn());
const fileCreateMock = vi.hoisted(() => vi.fn());
const imageCreateMock = vi.hoisted(() => vi.fn());
const imageGetMock = vi.hoisted(() => vi.fn());
const messageCreateMock = vi.hoisted(() => vi.fn());
const messageResourceGetMock = vi.hoisted(() => vi.fn());
const messageReplyMock = vi.hoisted(() => vi.fn());
const FEISHU_MEDIA_HTTP_TIMEOUT_MS = 120_000;
const emptyConfig: ClawdbotConfig = {};
vi.mock("./client.js", () => ({
createFeishuClient: createFeishuClientMock,
}));
vi.mock("./accounts.js", () => ({
resolveFeishuAccount: resolveFeishuAccountMock,
resolveFeishuRuntimeAccount: resolveFeishuAccountMock,
}));
vi.mock("./targets.js", () => ({
normalizeFeishuTarget: normalizeFeishuTargetMock,
resolveReceiveIdType: resolveReceiveIdTypeMock,
}));
vi.mock("./runtime.js", () => ({
getFeishuRuntime: () => ({
media: {
loadWebMedia: loadWebMediaMock,
},
}),
}));
vi.mock("../../../src/channels/plugins/bundled.js", () => ({
bundledChannelPlugins: [],
bundledChannelSetupPlugins: [],
}));
let downloadImageFeishu: typeof import("./media.js").downloadImageFeishu;
let downloadMessageResourceFeishu: typeof import("./media.js").downloadMessageResourceFeishu;
let sanitizeFileNameForUpload: typeof import("./media.js").sanitizeFileNameForUpload;
let sendMediaFeishu: typeof import("./media.js").sendMediaFeishu;
function expectPathIsolatedToTmpRoot(pathValue: string, key: string): void {
expect(pathValue).not.toContain(key);
expect(pathValue).not.toContain("..");
const tmpRoot = path.resolve(resolvePreferredOpenClawTmpDir());
const resolved = path.resolve(pathValue);
const rel = path.relative(tmpRoot, resolved);
expect(rel === ".." || rel.startsWith(`..${path.sep}`)).toBe(false);
}
function expectMediaTimeoutClientConfigured(): void {
expect(createFeishuClientMock).toHaveBeenCalledWith(
expect.objectContaining({
httpTimeoutMs: FEISHU_MEDIA_HTTP_TIMEOUT_MS,
}),
);
}
function mockResolvedFeishuAccount() {
resolveFeishuAccountMock.mockReturnValue({
configured: true,
accountId: "main",
config: {},
appId: "app_id",
appSecret: "app_secret",
domain: "feishu",
});
}
describe("sendMediaFeishu msg_type routing", () => {
beforeAll(async () => {
({
downloadImageFeishu,
downloadMessageResourceFeishu,
sanitizeFileNameForUpload,
sendMediaFeishu,
} = await import("./media.js"));
});
beforeEach(() => {
vi.clearAllMocks();
mockResolvedFeishuAccount();
normalizeFeishuTargetMock.mockReturnValue("ou_target");
resolveReceiveIdTypeMock.mockReturnValue("open_id");
createFeishuClientMock.mockReturnValue({
im: {
file: {
create: fileCreateMock,
},
image: {
create: imageCreateMock,
get: imageGetMock,
},
message: {
create: messageCreateMock,
reply: messageReplyMock,
},
messageResource: {
get: messageResourceGetMock,
},
},
});
fileCreateMock.mockResolvedValue({
code: 0,
data: { file_key: "file_key_1" },
});
imageCreateMock.mockResolvedValue({
code: 0,
data: { image_key: "image_key_1" },
});
messageCreateMock.mockResolvedValue({
code: 0,
data: { message_id: "msg_1" },
});
messageReplyMock.mockResolvedValue({
code: 0,
data: { message_id: "reply_1" },
});
loadWebMediaMock.mockResolvedValue({
buffer: Buffer.from("remote-audio"),
fileName: "remote.opus",
kind: "audio",
contentType: "audio/ogg",
});
imageGetMock.mockResolvedValue(Buffer.from("image-bytes"));
messageResourceGetMock.mockResolvedValue(Buffer.from("resource-bytes"));
});
it("uses msg_type=media for mp4 video", async () => {
await sendMediaFeishu({
cfg: emptyConfig,
to: "user:ou_target",
mediaBuffer: Buffer.from("video"),
fileName: "clip.mp4",
});
expect(fileCreateMock).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ file_type: "mp4" }),
}),
);
expect(messageCreateMock).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ msg_type: "media" }),
}),
);
});
it("uses msg_type=audio for opus", async () => {
await sendMediaFeishu({
cfg: emptyConfig,
to: "user:ou_target",
mediaBuffer: Buffer.from("audio"),
fileName: "voice.opus",
});
expect(fileCreateMock).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ file_type: "opus" }),
}),
);
expect(messageCreateMock).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ msg_type: "audio" }),
}),
);
});
it("uses msg_type=file for documents", async () => {
await sendMediaFeishu({
cfg: emptyConfig,
to: "user:ou_target",
mediaBuffer: Buffer.from("doc"),
fileName: "paper.pdf",
});
expect(fileCreateMock).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ file_type: "pdf" }),
}),
);
expect(messageCreateMock).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ msg_type: "file" }),
}),
);
});
it("uses msg_type=media for remote mp4 content even when the filename is generic", async () => {
loadWebMediaMock.mockResolvedValueOnce({
buffer: Buffer.from("remote-video"),
fileName: "download",
kind: "video",
contentType: "video/mp4",
});
await sendMediaFeishu({
cfg: emptyConfig,
to: "user:ou_target",
mediaUrl: "https://example.com/video",
});
expect(fileCreateMock).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ file_type: "mp4" }),
}),
);
expect(messageCreateMock).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ msg_type: "media" }),
}),
);
});
it("falls back to generic file for unsupported audio formats", async () => {
loadWebMediaMock.mockResolvedValueOnce({
buffer: Buffer.from("remote-mp3"),
fileName: "song.mp3",
kind: "audio",
contentType: "audio/mpeg",
});
await sendMediaFeishu({
cfg: emptyConfig,
to: "user:ou_target",
mediaUrl: "https://example.com/song.mp3",
});
expect(fileCreateMock).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ file_type: "stream" }),
}),
);
expect(messageCreateMock).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ msg_type: "file" }),
}),
);
});
it("configures the media client timeout for image uploads", async () => {
await sendMediaFeishu({
cfg: emptyConfig,
to: "user:ou_target",
mediaBuffer: Buffer.from("image"),
fileName: "photo.png",
});
expectMediaTimeoutClientConfigured();
expect(messageCreateMock).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ msg_type: "image" }),
}),
);
});
it("uses msg_type=media when replying with mp4", async () => {
await sendMediaFeishu({
cfg: emptyConfig,
to: "user:ou_target",
mediaBuffer: Buffer.from("video"),
fileName: "reply.mp4",
replyToMessageId: "om_parent",
});
expect(messageReplyMock).toHaveBeenCalledWith(
expect.objectContaining({
path: { message_id: "om_parent" },
data: expect.objectContaining({ msg_type: "media" }),
}),
);
expect(messageCreateMock).not.toHaveBeenCalled();
});
it("passes reply_in_thread when replyInThread is true", async () => {
await sendMediaFeishu({
cfg: emptyConfig,
to: "user:ou_target",
mediaBuffer: Buffer.from("video"),
fileName: "reply.mp4",
replyToMessageId: "om_parent",
replyInThread: true,
});
expect(messageReplyMock).toHaveBeenCalledWith(
expect.objectContaining({
path: { message_id: "om_parent" },
data: expect.objectContaining({
msg_type: "media",
reply_in_thread: true,
}),
}),
);
});
it("omits reply_in_thread when replyInThread is false", async () => {
await sendMediaFeishu({
cfg: emptyConfig,
to: "user:ou_target",
mediaBuffer: Buffer.from("video"),
fileName: "reply.mp4",
replyToMessageId: "om_parent",
replyInThread: false,
});
const callData = messageReplyMock.mock.calls[0][0].data;
expect(callData).not.toHaveProperty("reply_in_thread");
});
it("passes mediaLocalRoots as localRoots to loadWebMedia for local paths (#27884)", async () => {
loadWebMediaMock.mockResolvedValue({
buffer: Buffer.from("local-file"),
fileName: "doc.pdf",
kind: "document",
contentType: "application/pdf",
});
const roots = ["/allowed/workspace", "/tmp/openclaw"];
await sendMediaFeishu({
cfg: emptyConfig,
to: "user:ou_target",
mediaUrl: "/allowed/workspace/file.pdf",
mediaLocalRoots: roots,
});
expect(loadWebMediaMock).toHaveBeenCalledWith(
"/allowed/workspace/file.pdf",
expect.objectContaining({
maxBytes: expect.any(Number),
optimizeImages: false,
localRoots: roots,
}),
);
});
it("fails closed when media URL fetch is blocked", async () => {
loadWebMediaMock.mockRejectedValueOnce(
new Error("Blocked: resolves to private/internal IP address"),
);
await expect(
sendMediaFeishu({
cfg: emptyConfig,
to: "user:ou_target",
mediaUrl: "https://x/img",
fileName: "voice.opus",
}),
).rejects.toThrow(/private\/internal/i);
expect(fileCreateMock).not.toHaveBeenCalled();
expect(messageCreateMock).not.toHaveBeenCalled();
expect(messageReplyMock).not.toHaveBeenCalled();
});
it("uses isolated temp paths for image downloads", async () => {
const imageKey = "img_v3_01abc123";
let capturedPath: string | undefined;
imageGetMock.mockResolvedValueOnce({
writeFile: async (tmpPath: string) => {
capturedPath = tmpPath;
await fs.writeFile(tmpPath, Buffer.from("image-data"));
},
});
const result = await downloadImageFeishu({
cfg: emptyConfig,
imageKey,
});
expect(imageGetMock).toHaveBeenCalledWith(
expect.objectContaining({
path: { image_key: imageKey },
}),
);
expectMediaTimeoutClientConfigured();
expect(result.buffer).toEqual(Buffer.from("image-data"));
expect(capturedPath).toBeDefined();
expectPathIsolatedToTmpRoot(capturedPath as string, imageKey);
});
it("uses isolated temp paths for message resource downloads", async () => {
const fileKey = "file_v3_01abc123";
let capturedPath: string | undefined;
messageResourceGetMock.mockResolvedValueOnce({
writeFile: async (tmpPath: string) => {
capturedPath = tmpPath;
await fs.writeFile(tmpPath, Buffer.from("resource-data"));
},
});
const result = await downloadMessageResourceFeishu({
cfg: emptyConfig,
messageId: "om_123",
fileKey,
type: "image",
});
expect(result.buffer).toEqual(Buffer.from("resource-data"));
expect(capturedPath).toBeDefined();
expectPathIsolatedToTmpRoot(capturedPath as string, fileKey);
});
it("rejects invalid image keys before calling feishu api", async () => {
await expect(
downloadImageFeishu({
cfg: emptyConfig,
imageKey: "a/../../bad",
}),
).rejects.toThrow("invalid image_key");
expect(imageGetMock).not.toHaveBeenCalled();
});
it("rejects invalid file keys before calling feishu api", async () => {
await expect(
downloadMessageResourceFeishu({
cfg: emptyConfig,
messageId: "om_123",
fileKey: "x/../../bad",
type: "file",
}),
).rejects.toThrow("invalid file_key");
expect(messageResourceGetMock).not.toHaveBeenCalled();
});
it("preserves Chinese filenames for file uploads", async () => {
await sendMediaFeishu({
cfg: emptyConfig,
to: "user:ou_target",
mediaBuffer: Buffer.from("doc"),
fileName: "测试文档.pdf",
});
const createCall = fileCreateMock.mock.calls[0][0];
expect(createCall.data.file_name).toBe("测试文档.pdf");
});
it("preserves ASCII filenames unchanged for file uploads", async () => {
await sendMediaFeishu({
cfg: emptyConfig,
to: "user:ou_target",
mediaBuffer: Buffer.from("doc"),
fileName: "report-2026.pdf",
});
const createCall = fileCreateMock.mock.calls[0][0];
expect(createCall.data.file_name).toBe("report-2026.pdf");
});
it("preserves special Unicode characters (em-dash, full-width brackets) in filenames", async () => {
await sendMediaFeishu({
cfg: emptyConfig,
to: "user:ou_target",
mediaBuffer: Buffer.from("doc"),
fileName: "报告—详情(2026).md",
});
const createCall = fileCreateMock.mock.calls[0][0];
expect(createCall.data.file_name).toBe("报告—详情(2026).md");
});
});
describe("sanitizeFileNameForUpload", () => {
it("returns ASCII filenames unchanged", () => {
expect(sanitizeFileNameForUpload("report.pdf")).toBe("report.pdf");
expect(sanitizeFileNameForUpload("my-file_v2.txt")).toBe("my-file_v2.txt");
});
it("preserves Chinese characters", () => {
expect(sanitizeFileNameForUpload("测试文件.md")).toBe("测试文件.md");
expect(sanitizeFileNameForUpload("武汉15座山登山信息汇总.csv")).toBe(
"武汉15座山登山信息汇总.csv",
);
});
it("preserves em-dash and full-width brackets", () => {
expect(sanitizeFileNameForUpload("文件—说明(v2).pdf")).toBe("文件—说明(v2).pdf");
});
it("preserves single quotes and parentheses", () => {
expect(sanitizeFileNameForUpload("文件'(test).txt")).toBe("文件'(test).txt");
});
it("preserves filenames without extension", () => {
expect(sanitizeFileNameForUpload("测试文件")).toBe("测试文件");
});
it("preserves mixed ASCII and non-ASCII", () => {
expect(sanitizeFileNameForUpload("Report_报告_2026.xlsx")).toBe("Report_报告_2026.xlsx");
});
it("preserves emoji filenames", () => {
expect(sanitizeFileNameForUpload("report_😀.txt")).toBe("report_😀.txt");
});
it("strips control characters", () => {
expect(sanitizeFileNameForUpload("bad\x00file.txt")).toBe("bad_file.txt");
expect(sanitizeFileNameForUpload("inject\r\nheader.txt")).toBe("inject__header.txt");
});
it("strips quotes and backslashes to prevent header injection", () => {
expect(sanitizeFileNameForUpload('file"name.txt')).toBe("file_name.txt");
expect(sanitizeFileNameForUpload("file\\name.txt")).toBe("file_name.txt");
});
});
describe("downloadMessageResourceFeishu", () => {
beforeEach(() => {
vi.clearAllMocks();
mockResolvedFeishuAccount();
createFeishuClientMock.mockReturnValue({
im: {
messageResource: {
get: messageResourceGetMock,
},
},
});
messageResourceGetMock.mockResolvedValue(Buffer.from("fake-audio-data"));
});
// Regression: Feishu API only supports type=image|file for messageResource.get.
// Audio/video resources must use type=file, not type=audio (#8746).
it("forwards provided type=file for non-image resources", async () => {
const result = await downloadMessageResourceFeishu({
cfg: emptyConfig,
messageId: "om_audio_msg",
fileKey: "file_key_audio",
type: "file",
});
expect(messageResourceGetMock).toHaveBeenCalledWith(
expect.objectContaining({
path: { message_id: "om_audio_msg", file_key: "file_key_audio" },
params: { type: "file" },
}),
);
expectMediaTimeoutClientConfigured();
expect(result.buffer).toBeInstanceOf(Buffer);
});
it("image uses type=image", async () => {
messageResourceGetMock.mockResolvedValue(Buffer.from("fake-image-data"));
const result = await downloadMessageResourceFeishu({
cfg: emptyConfig,
messageId: "om_img_msg",
fileKey: "img_key_1",
type: "image",
});
expect(messageResourceGetMock).toHaveBeenCalledWith(
expect.objectContaining({
path: { message_id: "om_img_msg", file_key: "img_key_1" },
params: { type: "image" },
}),
);
expectMediaTimeoutClientConfigured();
expect(result.buffer).toBeInstanceOf(Buffer);
});
it("extracts content-type and filename metadata from download headers", async () => {
messageResourceGetMock.mockResolvedValueOnce({
data: Buffer.from("fake-video-data"),
headers: {
"content-type": "video/mp4",
"content-disposition": `attachment; filename="clip.mp4"`,
},
});
const result = await downloadMessageResourceFeishu({
cfg: emptyConfig,
messageId: "om_video_msg",
fileKey: "file_key_video",
type: "file",
});
expect(result).toMatchObject({
buffer: Buffer.from("fake-video-data"),
contentType: "video/mp4",
fileName: "clip.mp4",
});
});
});

View file

@ -0,0 +1,648 @@
import fs from "fs";
import path from "path";
import { Readable } from "stream";
import type * as Lark from "@larksuiteoapi/node-sdk";
import { mediaKindFromMime } from "openclaw/plugin-sdk/media-runtime";
import { withTempDownloadPath } from "openclaw/plugin-sdk/temp-path";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import type { ClawdbotConfig } from "../runtime-api.js";
import { resolveFeishuRuntimeAccount } from "./accounts.js";
import { createFeishuClient } from "./client.js";
import { normalizeFeishuExternalKey } from "./external-keys.js";
import { getFeishuRuntime } from "./runtime.js";
import { assertFeishuMessageApiSuccess, toFeishuSendResult } from "./send-result.js";
import { resolveFeishuSendTarget } from "./send-target.js";
const FEISHU_MEDIA_HTTP_TIMEOUT_MS = 120_000;
export type DownloadImageResult = {
buffer: Buffer;
contentType?: string;
};
export type DownloadMessageResourceResult = {
buffer: Buffer;
contentType?: string;
fileName?: string;
};
function createConfiguredFeishuMediaClient(params: { cfg: ClawdbotConfig; accountId?: string }): {
account: ReturnType<typeof resolveFeishuRuntimeAccount>;
client: ReturnType<typeof createFeishuClient>;
} {
const account = resolveFeishuRuntimeAccount({ cfg: params.cfg, accountId: params.accountId });
if (!account.configured) {
throw new Error(`Feishu account "${account.accountId}" not configured`);
}
return {
account,
client: createFeishuClient({
...account,
httpTimeoutMs: FEISHU_MEDIA_HTTP_TIMEOUT_MS,
}),
};
}
type FeishuUploadResponse =
| Awaited<ReturnType<Lark.Client["im"]["image"]["create"]>>
| Awaited<ReturnType<Lark.Client["im"]["file"]["create"]>>;
type FeishuDownloadResponse =
| Awaited<ReturnType<Lark.Client["im"]["image"]["get"]>>
| Awaited<ReturnType<Lark.Client["im"]["file"]["get"]>>
| Awaited<ReturnType<Lark.Client["im"]["messageResource"]["get"]>>;
type FeishuHeaderMap = Record<string, string | string[]>;
function asHeaderMap(value: object | undefined): FeishuHeaderMap | undefined {
if (!value) {
return undefined;
}
const entries = Object.entries(value);
if (entries.every(([, entry]) => typeof entry === "string" || Array.isArray(entry))) {
return Object.fromEntries(entries) as FeishuHeaderMap;
}
return undefined;
}
function extractFeishuUploadKey(
response: FeishuUploadResponse,
params: {
key: "image_key" | "file_key";
errorPrefix: string;
},
): string {
if (!response) {
throw new Error(`${params.errorPrefix}: empty response`);
}
const wrappedResponse = response as {
image_key?: string;
file_key?: string;
code?: number;
msg?: string;
data?: Partial<Record<"image_key" | "file_key", string>>;
};
if (wrappedResponse.code !== undefined && wrappedResponse.code !== 0) {
throw new Error(
`${params.errorPrefix}: ${wrappedResponse.msg || `code ${wrappedResponse.code}`}`,
);
}
const key =
params.key === "image_key"
? (wrappedResponse.image_key ?? wrappedResponse.data?.image_key)
: (wrappedResponse.file_key ?? wrappedResponse.data?.file_key);
if (!key) {
throw new Error(`${params.errorPrefix}: no ${params.key} returned`);
}
return key;
}
function readHeaderValue(
headers: Record<string, unknown> | undefined,
name: string,
): string | undefined {
if (!headers) {
return undefined;
}
for (const [key, value] of Object.entries(headers)) {
if (normalizeLowercaseStringOrEmpty(key) !== normalizeLowercaseStringOrEmpty(name)) {
continue;
}
if (typeof value === "string" && value.trim()) {
return value.trim();
}
if (Array.isArray(value)) {
const first = value.find((entry) => typeof entry === "string" && entry.trim());
if (typeof first === "string") {
return first.trim();
}
}
}
return undefined;
}
function decodeDispositionFileName(value: string): string | undefined {
const utf8Match = value.match(/filename\*=UTF-8''([^;]+)/i);
if (utf8Match?.[1]) {
try {
return decodeURIComponent(utf8Match[1].trim().replace(/^"(.*)"$/, "$1"));
} catch {
return utf8Match[1].trim().replace(/^"(.*)"$/, "$1");
}
}
const plainMatch = value.match(/filename="?([^";]+)"?/i);
return plainMatch?.[1]?.trim();
}
function extractFeishuDownloadMetadata(response: FeishuDownloadResponse): {
contentType?: string;
fileName?: string;
} {
const responseWithOptionalFields = response as FeishuDownloadResponse & {
header?: object;
contentType?: string;
mime_type?: string;
data?: {
contentType?: string;
mime_type?: string;
file_name?: string;
fileName?: string;
};
file_name?: string;
fileName?: string;
};
const headers =
asHeaderMap(responseWithOptionalFields.headers) ??
asHeaderMap(responseWithOptionalFields.header);
const contentType =
readHeaderValue(headers, "content-type") ??
responseWithOptionalFields.contentType ??
responseWithOptionalFields.mime_type ??
responseWithOptionalFields.data?.contentType ??
responseWithOptionalFields.data?.mime_type;
const disposition = readHeaderValue(headers, "content-disposition");
const fileName =
(disposition ? decodeDispositionFileName(disposition) : undefined) ??
responseWithOptionalFields.file_name ??
responseWithOptionalFields.fileName ??
responseWithOptionalFields.data?.file_name ??
responseWithOptionalFields.data?.fileName;
return { contentType, fileName };
}
async function readReadableBuffer(stream: Readable): Promise<Buffer> {
const chunks: Buffer[] = [];
for await (const chunk of stream) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
return Buffer.concat(chunks);
}
async function readFeishuResponseBuffer(params: {
response: FeishuDownloadResponse;
tmpDirPrefix: string;
errorPrefix: string;
}): Promise<Buffer> {
const { response } = params;
if (Buffer.isBuffer(response)) {
return response;
}
if (response instanceof ArrayBuffer) {
return Buffer.from(response);
}
const responseWithOptionalFields = response as FeishuDownloadResponse & {
code?: number;
msg?: string;
data?: Buffer | ArrayBuffer;
[Symbol.asyncIterator]?: () => AsyncIterator<Buffer | Uint8Array | string>;
};
if (responseWithOptionalFields.code !== undefined && responseWithOptionalFields.code !== 0) {
throw new Error(
`${params.errorPrefix}: ${responseWithOptionalFields.msg || `code ${responseWithOptionalFields.code}`}`,
);
}
if (responseWithOptionalFields.data && Buffer.isBuffer(responseWithOptionalFields.data)) {
return responseWithOptionalFields.data;
}
if (responseWithOptionalFields.data instanceof ArrayBuffer) {
return Buffer.from(responseWithOptionalFields.data);
}
if (typeof response.getReadableStream === "function") {
return readReadableBuffer(response.getReadableStream());
}
if (typeof response.writeFile === "function") {
return await withTempDownloadPath({ prefix: params.tmpDirPrefix }, async (tmpPath) => {
await response.writeFile(tmpPath);
return await fs.promises.readFile(tmpPath);
});
}
if (responseWithOptionalFields[Symbol.asyncIterator]) {
const asyncIterable = responseWithOptionalFields as AsyncIterable<Buffer | Uint8Array | string>;
const chunks: Buffer[] = [];
for await (const chunk of asyncIterable) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
return Buffer.concat(chunks);
}
if (response instanceof Readable) {
return readReadableBuffer(response);
}
const keys = Object.keys(response as object);
throw new Error(`${params.errorPrefix}: unexpected response format. Keys: [${keys.join(", ")}]`);
}
/**
* Download an image from Feishu using image_key.
* Used for downloading images sent in messages.
*/
export async function downloadImageFeishu(params: {
cfg: ClawdbotConfig;
imageKey: string;
accountId?: string;
}): Promise<DownloadImageResult> {
const { cfg, imageKey, accountId } = params;
const normalizedImageKey = normalizeFeishuExternalKey(imageKey);
if (!normalizedImageKey) {
throw new Error("Feishu image download failed: invalid image_key");
}
const { client } = createConfiguredFeishuMediaClient({ cfg, accountId });
const response = await client.im.image.get({
path: { image_key: normalizedImageKey },
});
const buffer = await readFeishuResponseBuffer({
response,
tmpDirPrefix: "openclaw-feishu-img-",
errorPrefix: "Feishu image download failed",
});
const meta = extractFeishuDownloadMetadata(response);
return { buffer, contentType: meta.contentType };
}
/**
* 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";
accountId?: string;
}): Promise<DownloadMessageResourceResult> {
const { cfg, messageId, fileKey, type, accountId } = params;
const normalizedFileKey = normalizeFeishuExternalKey(fileKey);
if (!normalizedFileKey) {
throw new Error("Feishu message resource download failed: invalid file_key");
}
const { client } = createConfiguredFeishuMediaClient({ cfg, accountId });
const response = await client.im.messageResource.get({
path: { message_id: messageId, file_key: normalizedFileKey },
params: { type },
});
const buffer = await readFeishuResponseBuffer({
response,
tmpDirPrefix: "openclaw-feishu-resource-",
errorPrefix: "Feishu message resource download failed",
});
return { buffer, ...extractFeishuDownloadMetadata(response) };
}
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";
accountId?: string;
}): Promise<UploadImageResult> {
const { cfg, image, imageType = "message", accountId } = params;
const { client } = createConfiguredFeishuMediaClient({ cfg, accountId });
// SDK accepts Buffer directly or fs.ReadStream for file paths
// Using Readable.from(buffer) causes issues with form-data library
// See: https://github.com/larksuite/node-sdk/issues/121
const imageData = typeof image === "string" ? fs.createReadStream(image) : image;
const response = await client.im.image.create({
data: {
image_type: imageType,
image: imageData,
},
});
return {
imageKey: extractFeishuUploadKey(response, {
key: "image_key",
errorPrefix: "Feishu image upload failed",
}),
};
}
/**
* Sanitize a filename for safe use in Feishu multipart/form-data uploads.
* Strips control characters and multipart-injection vectors (CWE-93) while
* preserving the original UTF-8 display name (Chinese, emoji, etc.).
*
* Previous versions percent-encoded non-ASCII characters, but the Feishu
* `im.file.create` API uses `file_name` as a literal display name — it does
* NOT decode percent-encoding — so encoded filenames appeared as garbled text
* in chat (regression in v2026.3.2).
*/
export function sanitizeFileNameForUpload(fileName: string): string {
return fileName.replace(/[\p{Cc}"\\]/gu, "_");
}
/**
* 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
accountId?: string;
}): Promise<UploadFileResult> {
const { cfg, file, fileName, fileType, duration, accountId } = params;
const { client } = createConfiguredFeishuMediaClient({ cfg, accountId });
// SDK accepts Buffer directly or fs.ReadStream for file paths
// Using Readable.from(buffer) causes issues with form-data library
// See: https://github.com/larksuite/node-sdk/issues/121
const fileData = typeof file === "string" ? fs.createReadStream(file) : file;
const safeFileName = sanitizeFileNameForUpload(fileName);
const response = await client.im.file.create({
data: {
file_type: fileType,
file_name: safeFileName,
file: fileData,
...(duration !== undefined && { duration }),
},
});
return {
fileKey: extractFeishuUploadKey(response, {
key: "file_key",
errorPrefix: "Feishu file upload failed",
}),
};
}
/**
* Send an image message using an image_key
*/
export async function sendImageFeishu(params: {
cfg: ClawdbotConfig;
to: string;
imageKey: string;
replyToMessageId?: string;
replyInThread?: boolean;
accountId?: string;
}): Promise<SendMediaResult> {
const { cfg, to, imageKey, replyToMessageId, replyInThread, accountId } = params;
const { client, receiveId, receiveIdType } = resolveFeishuSendTarget({
cfg,
to,
accountId,
});
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",
...(replyInThread ? { reply_in_thread: true } : {}),
},
});
assertFeishuMessageApiSuccess(response, "Feishu image reply failed");
return toFeishuSendResult(response, receiveId);
}
const response = await client.im.message.create({
params: { receive_id_type: receiveIdType },
data: {
receive_id: receiveId,
content,
msg_type: "image",
},
});
assertFeishuMessageApiSuccess(response, "Feishu image send failed");
return toFeishuSendResult(response, receiveId);
}
/**
* Send a file message using a file_key
*/
export async function sendFileFeishu(params: {
cfg: ClawdbotConfig;
to: string;
fileKey: string;
/** Use "audio" for audio, "media" for video (mp4), "file" for documents */
msgType?: "file" | "audio" | "media";
replyToMessageId?: string;
replyInThread?: boolean;
accountId?: string;
}): Promise<SendMediaResult> {
const { cfg, to, fileKey, replyToMessageId, replyInThread, accountId } = params;
const msgType = params.msgType ?? "file";
const { client, receiveId, receiveIdType } = resolveFeishuSendTarget({
cfg,
to,
accountId,
});
const content = JSON.stringify({ file_key: fileKey });
if (replyToMessageId) {
const response = await client.im.message.reply({
path: { message_id: replyToMessageId },
data: {
content,
msg_type: msgType,
...(replyInThread ? { reply_in_thread: true } : {}),
},
});
assertFeishuMessageApiSuccess(response, "Feishu file reply failed");
return toFeishuSendResult(response, receiveId);
}
const response = await client.im.message.create({
params: { receive_id_type: receiveIdType },
data: {
receive_id: receiveId,
content,
msg_type: msgType,
},
});
assertFeishuMessageApiSuccess(response, "Feishu file send failed");
return toFeishuSendResult(response, receiveId);
}
/**
* Helper to detect file type from extension
*/
export function detectFileType(
fileName: string,
): "opus" | "mp4" | "pdf" | "doc" | "xls" | "ppt" | "stream" {
const ext = normalizeLowercaseStringOrEmpty(path.extname(fileName));
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";
}
}
function resolveFeishuOutboundMediaKind(params: { fileName: string; contentType?: string }): {
fileType?: "opus" | "mp4" | "pdf" | "doc" | "xls" | "ppt" | "stream";
msgType: "image" | "file" | "audio" | "media";
} {
const { fileName, contentType } = params;
const ext = normalizeLowercaseStringOrEmpty(path.extname(fileName));
const mimeKind = mediaKindFromMime(contentType);
const isImageExt = [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".ico", ".tiff"].includes(
ext,
);
if (isImageExt || mimeKind === "image") {
return { msgType: "image" };
}
if (
ext === ".opus" ||
ext === ".ogg" ||
contentType === "audio/ogg" ||
contentType === "audio/opus"
) {
return { fileType: "opus", msgType: "audio" };
}
if (
[".mp4", ".mov", ".avi"].includes(ext) ||
contentType === "video/mp4" ||
contentType === "video/quicktime" ||
contentType === "video/x-msvideo"
) {
return { fileType: "mp4", msgType: "media" };
}
const fileType = detectFileType(fileName);
return {
fileType,
msgType:
fileType === "stream"
? "file"
: fileType === "opus"
? "audio"
: fileType === "mp4"
? "media"
: "file",
};
}
/**
* Upload and send media (image or file) from URL, local path, or buffer.
* When mediaUrl is a local path, mediaLocalRoots (from core outbound context)
* must be passed so loadWebMedia allows the path (post CVE-2026-26321).
*/
export async function sendMediaFeishu(params: {
cfg: ClawdbotConfig;
to: string;
mediaUrl?: string;
mediaBuffer?: Buffer;
fileName?: string;
replyToMessageId?: string;
replyInThread?: boolean;
accountId?: string;
/** Allowed roots for local path reads; required for local filePath to work. */
mediaLocalRoots?: readonly string[];
}): Promise<SendMediaResult> {
const {
cfg,
to,
mediaUrl,
mediaBuffer,
fileName,
replyToMessageId,
replyInThread,
accountId,
mediaLocalRoots,
} = params;
const account = resolveFeishuRuntimeAccount({ cfg, accountId });
if (!account.configured) {
throw new Error(`Feishu account "${account.accountId}" not configured`);
}
const mediaMaxBytes = (account.config?.mediaMaxMb ?? 30) * 1024 * 1024;
let buffer: Buffer;
let name: string;
let contentType: string | undefined;
if (mediaBuffer) {
buffer = mediaBuffer;
name = fileName ?? "file";
} else if (mediaUrl) {
const loaded = await getFeishuRuntime().media.loadWebMedia(mediaUrl, {
maxBytes: mediaMaxBytes,
optimizeImages: false,
localRoots: mediaLocalRoots?.length ? mediaLocalRoots : undefined,
});
buffer = loaded.buffer;
name = fileName ?? loaded.fileName ?? "file";
contentType = loaded.contentType;
} else {
throw new Error("Either mediaUrl or mediaBuffer must be provided");
}
const routing = resolveFeishuOutboundMediaKind({ fileName: name, contentType });
if (routing.msgType === "image") {
const { imageKey } = await uploadImageFeishu({ cfg, image: buffer, accountId });
return sendImageFeishu({ cfg, to, imageKey, replyToMessageId, replyInThread, accountId });
} else {
const { fileKey } = await uploadFileFeishu({
cfg,
file: buffer,
fileName: name,
fileType: routing.fileType ?? "stream",
accountId,
});
return sendFileFeishu({
cfg,
to,
fileKey,
msgType: routing.msgType,
replyToMessageId,
replyInThread,
accountId,
});
}
}

View file

@ -0,0 +1,5 @@
export type MentionTarget = {
openId: string;
name: string;
key: string; // Placeholder in original message, e.g. @_user_1
};

View file

@ -0,0 +1,126 @@
import type { FeishuMessageEvent } from "./event-types.js";
export type { MentionTarget } from "./mention-target.types.js";
import type { MentionTarget } from "./mention-target.types.js";
/**
* Escape regex metacharacters so user-controlled mention fields are treated literally.
*/
export function escapeRegExp(input: string): string {
return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
/**
* 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 !== "group";
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(escapeRegExp(key), "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,13 @@
import type { ChannelMessageActionName } from "openclaw/plugin-sdk/channel-contract";
type MessageActionTargetAliasSpec = {
aliases: string[];
};
export const messageActionTargetAliases = {
read: { aliases: ["messageId"] },
pin: { aliases: ["messageId"] },
unpin: { aliases: ["messageId"] },
"list-pins": { aliases: ["chatId"] },
"channel-info": { aliases: ["chatId"] },
} satisfies Partial<Record<ChannelMessageActionName, MessageActionTargetAliasSpec>>;

View file

@ -0,0 +1,7 @@
export type { RuntimeEnv } from "openclaw/plugin-sdk/runtime";
export {
createFixedWindowRateLimiter,
createWebhookAnomalyTracker,
WEBHOOK_ANOMALY_COUNTER_DEFAULTS,
WEBHOOK_RATE_LIMIT_DEFAULTS,
} from "openclaw/plugin-sdk/webhook-ingress";

View file

@ -0,0 +1,7 @@
export type { RuntimeEnv } from "../runtime-api.js";
export { safeEqualSecret } from "openclaw/plugin-sdk/browser-security-runtime";
export { applyBasicWebhookRequestGuards } from "openclaw/plugin-sdk/webhook-ingress";
export {
installRequestBodyLimitGuard,
readWebhookBodyOrReject,
} from "openclaw/plugin-sdk/webhook-request-guards";

View file

@ -0,0 +1,922 @@
import * as crypto from "crypto";
import * as Lark from "@larksuiteoapi/node-sdk";
import type { ClawdbotConfig, RuntimeEnv, HistoryEntry } from "../runtime-api.js";
import { resolveFeishuAccount } from "./accounts.js";
import { raceWithTimeoutAndAbort } from "./async.js";
import {
handleFeishuMessage,
parseFeishuMessageEvent,
type FeishuMessageEvent,
type FeishuBotAddedEvent,
} from "./bot.js";
import { handleFeishuCardAction, type FeishuCardActionEvent } from "./card-action.js";
import { maybeHandleFeishuQuickActionMenu } from "./card-ux-launcher.js";
import { createEventDispatcher } from "./client.js";
import { handleFeishuCommentEvent } from "./comment-handler.js";
import { isRecord, readString } from "./comment-shared.js";
import {
claimUnprocessedFeishuMessage,
hasProcessedFeishuMessage,
recordProcessedFeishuMessage,
releaseFeishuMessageProcessing,
tryBeginFeishuMessageProcessing,
warmupDedupFromDisk,
} from "./dedup.js";
import { isMentionForwardRequest } from "./mention.js";
import { applyBotIdentityState, startBotIdentityRecovery } from "./monitor.bot-identity.js";
import { parseFeishuDriveCommentNoticeEventPayload } from "./monitor.comment.js";
import { fetchBotIdentityForMonitor } from "./monitor.startup.js";
import { botNames, botOpenIds } from "./monitor.state.js";
import { monitorWebhook, monitorWebSocket } from "./monitor.transport.js";
import { getFeishuRuntime } from "./runtime.js";
import { getMessageFeishu } from "./send.js";
import { getFeishuSequentialKey } from "./sequential-key.js";
import { createSequentialQueue } from "./sequential-queue.js";
import { createFeishuThreadBindingManager } from "./thread-bindings.js";
import type { FeishuChatType, ResolvedFeishuAccount } from "./types.js";
const FEISHU_REACTION_VERIFY_TIMEOUT_MS = 1_500;
export class FeishuRetryableSyntheticEventError extends Error {
constructor(message: string, options?: ErrorOptions) {
super(message, options);
this.name = "FeishuRetryableSyntheticEventError";
}
}
function isFeishuRetryableSyntheticEventError(
error: unknown,
): error is FeishuRetryableSyntheticEventError {
return (
error instanceof FeishuRetryableSyntheticEventError ||
(typeof error === "object" &&
error !== null &&
"name" in error &&
error.name === "FeishuRetryableSyntheticEventError")
);
}
export type FeishuReactionCreatedEvent = {
message_id: string;
chat_id?: string;
chat_type?: string;
reaction_type?: { emoji_type?: string };
operator_type?: string;
user_id?: { open_id?: string; user_id?: string };
action_time?: string;
};
export type FeishuReactionDeletedEvent = FeishuReactionCreatedEvent & {
reaction_id?: string;
};
type ResolveReactionSyntheticEventParams = {
cfg: ClawdbotConfig;
accountId: string;
event: FeishuReactionCreatedEvent;
botOpenId?: string;
fetchMessage?: typeof getMessageFeishu;
verificationTimeoutMs?: number;
logger?: (message: string) => void;
uuid?: () => string;
action?: "created" | "deleted";
};
export async function resolveReactionSyntheticEvent(
params: ResolveReactionSyntheticEventParams,
): Promise<FeishuMessageEvent | null> {
const {
cfg,
accountId,
event,
botOpenId,
fetchMessage = getMessageFeishu,
verificationTimeoutMs = FEISHU_REACTION_VERIFY_TIMEOUT_MS,
logger,
uuid = () => crypto.randomUUID(),
action = "created",
} = params;
const emoji = event.reaction_type?.emoji_type;
const messageId = event.message_id;
const senderId = event.user_id?.open_id;
const senderUserId = event.user_id?.user_id;
if (!emoji || !messageId || !senderId) {
return null;
}
const account = resolveFeishuAccount({ cfg, accountId });
const reactionNotifications = account.config.reactionNotifications ?? "own";
if (reactionNotifications === "off") {
return null;
}
if (event.operator_type === "app" || senderId === botOpenId) {
return null;
}
if (emoji === "Typing") {
return null;
}
if (reactionNotifications === "own" && !botOpenId) {
logger?.(
`feishu[${accountId}]: bot open_id unavailable, skipping reaction ${emoji} on ${messageId}`,
);
return null;
}
const reactedMsg = await raceWithTimeoutAndAbort(fetchMessage({ cfg, messageId, accountId }), {
timeoutMs: verificationTimeoutMs,
})
.then((result) => (result.status === "resolved" ? result.value : null))
.catch(() => null);
const isBotMessage = reactedMsg?.senderType === "app" || reactedMsg?.senderOpenId === botOpenId;
if (!reactedMsg || (reactionNotifications === "own" && !isBotMessage)) {
logger?.(
`feishu[${accountId}]: ignoring reaction on non-bot/unverified message ${messageId} ` +
`(sender: ${reactedMsg?.senderOpenId ?? "unknown"})`,
);
return null;
}
const fallbackChatType = reactedMsg.chatType;
const normalizedEventChatType = normalizeFeishuChatType(event.chat_type);
const resolvedChatType = normalizedEventChatType ?? fallbackChatType;
if (!resolvedChatType) {
logger?.(
`feishu[${accountId}]: skipping reaction ${emoji} on ${messageId} without chat type context`,
);
return null;
}
const syntheticChatIdRaw = event.chat_id ?? reactedMsg.chatId;
const syntheticChatId = syntheticChatIdRaw?.trim() ? syntheticChatIdRaw : `p2p:${senderId}`;
const syntheticChatType: FeishuChatType = resolvedChatType;
return {
sender: {
sender_id: {
open_id: senderId,
...(senderUserId ? { user_id: senderUserId } : {}),
},
sender_type: "user",
},
message: {
message_id: `${messageId}:reaction:${emoji}:${uuid()}`,
chat_id: syntheticChatId,
chat_type: syntheticChatType,
message_type: "text",
content: JSON.stringify({
text:
action === "deleted"
? `[removed reaction ${emoji} from message ${messageId}]`
: `[reacted with ${emoji} to message ${messageId}]`,
}),
},
};
}
function normalizeFeishuChatType(value: unknown): FeishuChatType | undefined {
return value === "group" || value === "private" || value === "p2p" ? value : undefined;
}
type RegisterEventHandlersContext = {
cfg: ClawdbotConfig;
accountId: string;
runtime?: RuntimeEnv;
chatHistories: Map<string, HistoryEntry[]>;
fireAndForget?: boolean;
};
type FeishuBotMenuEvent = {
event_key?: string;
timestamp?: string | number;
operator?: {
operator_name?: string;
operator_id?: { open_id?: string; user_id?: string; union_id?: string };
};
};
function readStringOrNumber(value: unknown): string | number | undefined {
return typeof value === "string" || typeof value === "number" ? value : undefined;
}
function parseFeishuMessageEventPayload(value: unknown): FeishuMessageEvent | null {
if (!isRecord(value)) {
return null;
}
const sender = value.sender;
const message = value.message;
if (!isRecord(sender) || !isRecord(message)) {
return null;
}
const senderId = sender.sender_id;
if (!isRecord(senderId)) {
return null;
}
const messageId = readString(message.message_id);
const chatId = readString(message.chat_id);
const chatType = normalizeFeishuChatType(message.chat_type);
const messageType = readString(message.message_type);
const content = readString(message.content);
if (!messageId || !chatId || !chatType || !messageType || !content) {
return null;
}
return value as FeishuMessageEvent;
}
function parseFeishuBotAddedEventPayload(value: unknown): FeishuBotAddedEvent | null {
if (!isRecord(value) || !readString(value.chat_id) || !isRecord(value.operator_id)) {
return null;
}
return value as FeishuBotAddedEvent;
}
function parseFeishuBotRemovedChatId(value: unknown): string | null {
if (!isRecord(value)) {
return null;
}
return readString(value.chat_id) ?? null;
}
function parseFeishuBotMenuEvent(value: unknown): FeishuBotMenuEvent | null {
if (!isRecord(value)) {
return null;
}
const operator = value.operator;
if (operator !== undefined && !isRecord(operator)) {
return null;
}
return {
event_key: readString(value.event_key),
timestamp: readStringOrNumber(value.timestamp),
operator: operator
? {
operator_name: readString(operator.operator_name),
operator_id: isRecord(operator.operator_id)
? {
open_id: readString(operator.operator_id.open_id),
user_id: readString(operator.operator_id.user_id),
union_id: readString(operator.operator_id.union_id),
}
: undefined,
}
: undefined,
};
}
function parseFeishuCardActionEventPayload(value: unknown): FeishuCardActionEvent | null {
if (!isRecord(value)) {
return null;
}
const operator = value.operator;
const action = value.action;
const context = value.context;
if (!isRecord(operator) || !isRecord(action) || !isRecord(context)) {
return null;
}
const token = readString(value.token);
const openId = readString(operator.open_id);
const userId = readString(operator.user_id);
const unionId = readString(operator.union_id);
const tag = readString(action.tag);
const actionValue = action.value;
const contextOpenId = readString(context.open_id);
const contextUserId = readString(context.user_id);
const chatId = readString(context.chat_id);
if (
!token ||
!openId ||
!userId ||
!unionId ||
!tag ||
!isRecord(actionValue) ||
!contextOpenId ||
!contextUserId ||
!chatId
) {
return null;
}
return {
operator: {
open_id: openId,
user_id: userId,
union_id: unionId,
},
token,
action: {
value: actionValue,
tag,
},
context: {
open_id: contextOpenId,
user_id: contextUserId,
chat_id: chatId,
},
};
}
function buildCommentNoticeQueueKey(event: {
notice_meta?: {
file_type?: string;
file_token?: string;
};
}): string {
const fileType = event.notice_meta?.file_type?.trim() || "unknown";
const fileToken = event.notice_meta?.file_token?.trim() || "unknown";
return `comment-doc:${fileType}:${fileToken}`;
}
function mergeFeishuDebounceMentions(
entries: FeishuMessageEvent[],
): FeishuMessageEvent["message"]["mentions"] | undefined {
const merged = new Map<string, NonNullable<FeishuMessageEvent["message"]["mentions"]>[number]>();
for (const entry of entries) {
for (const mention of entry.message.mentions ?? []) {
const stableId =
mention.id.open_id?.trim() || mention.id.user_id?.trim() || mention.id.union_id?.trim();
const mentionName = mention.name?.trim();
const mentionKey = mention.key?.trim();
const fallback =
mentionName && mentionKey ? `${mentionName}|${mentionKey}` : mentionName || mentionKey;
const key = stableId || fallback;
if (!key || merged.has(key)) {
continue;
}
merged.set(key, mention);
}
}
if (merged.size === 0) {
return undefined;
}
return Array.from(merged.values());
}
function dedupeFeishuDebounceEntriesByMessageId(
entries: FeishuMessageEvent[],
): FeishuMessageEvent[] {
const seen = new Set<string>();
const deduped: FeishuMessageEvent[] = [];
for (const entry of entries) {
const messageId = entry.message.message_id?.trim();
if (!messageId) {
deduped.push(entry);
continue;
}
if (seen.has(messageId)) {
continue;
}
seen.add(messageId);
deduped.push(entry);
}
return deduped;
}
function resolveFeishuDebounceMentions(params: {
entries: FeishuMessageEvent[];
botOpenId?: string;
}): FeishuMessageEvent["message"]["mentions"] | undefined {
const { entries, botOpenId } = params;
if (entries.length === 0) {
return undefined;
}
for (let index = entries.length - 1; index >= 0; index -= 1) {
const entry = entries[index];
if (isMentionForwardRequest(entry, botOpenId)) {
// Keep mention-forward semantics scoped to a single source message.
return mergeFeishuDebounceMentions([entry]);
}
}
const merged = mergeFeishuDebounceMentions(entries);
if (!merged) {
return undefined;
}
const normalizedBotOpenId = botOpenId?.trim();
if (!normalizedBotOpenId) {
return undefined;
}
const botMentions = merged.filter(
(mention) => mention.id.open_id?.trim() === normalizedBotOpenId,
);
return botMentions.length > 0 ? botMentions : undefined;
}
function registerEventHandlers(
eventDispatcher: Lark.EventDispatcher,
context: RegisterEventHandlersContext,
): void {
const { cfg, accountId, runtime, chatHistories, fireAndForget } = context;
const core = getFeishuRuntime();
const inboundDebounceMs = core.channel.debounce.resolveInboundDebounceMs({
cfg,
channel: "feishu",
});
const log = runtime?.log ?? console.log;
const error = runtime?.error ?? console.error;
// Keep normal Feishu traffic FIFO per chat while allowing explicit out-of-band
// commands like /btw and /stop to bypass the busy main-chat lane.
const enqueue = createSequentialQueue();
const runFeishuHandler = async (params: { task: () => Promise<void>; errorMessage: string }) => {
if (fireAndForget) {
void params.task().catch((err) => {
error(`${params.errorMessage}: ${String(err)}`);
});
return;
}
try {
await params.task();
} catch (err) {
error(`${params.errorMessage}: ${String(err)}`);
}
};
const dispatchFeishuMessage = async (event: FeishuMessageEvent) => {
const sequentialKey = getFeishuSequentialKey({
accountId,
event,
botOpenId: botOpenIds.get(accountId),
botName: botNames.get(accountId),
});
const task = () =>
handleFeishuMessage({
cfg,
event,
botOpenId: botOpenIds.get(accountId),
botName: botNames.get(accountId),
runtime,
chatHistories,
accountId,
processingClaimHeld: true,
});
await enqueue(sequentialKey, task);
};
const resolveSenderDebounceId = (event: FeishuMessageEvent): string | undefined => {
const senderId =
event.sender.sender_id.open_id?.trim() || event.sender.sender_id.user_id?.trim();
return senderId || undefined;
};
const resolveDebounceText = (event: FeishuMessageEvent): string => {
const botOpenId = botOpenIds.get(accountId);
const parsed = parseFeishuMessageEvent(event, botOpenId, botNames.get(accountId));
return parsed.content.trim();
};
const recordSuppressedMessageIds = async (
entries: FeishuMessageEvent[],
dispatchMessageId?: string,
) => {
const keepMessageId = dispatchMessageId?.trim();
const suppressedIds = new Set(
entries
.map((entry) => entry.message.message_id?.trim())
.filter((id): id is string => Boolean(id) && (!keepMessageId || id !== keepMessageId)),
);
if (suppressedIds.size === 0) {
return;
}
for (const messageId of suppressedIds) {
try {
await recordProcessedFeishuMessage(messageId, accountId, log);
} catch (err) {
error(
`feishu[${accountId}]: failed to record merged dedupe id ${messageId}: ${String(err)}`,
);
}
}
};
const isMessageAlreadyProcessed = async (entry: FeishuMessageEvent): Promise<boolean> => {
return await hasProcessedFeishuMessage(entry.message.message_id, accountId, log);
};
const inboundDebouncer = core.channel.debounce.createInboundDebouncer<FeishuMessageEvent>({
debounceMs: inboundDebounceMs,
buildKey: (event) => {
const chatId = event.message.chat_id?.trim();
const senderId = resolveSenderDebounceId(event);
if (!chatId || !senderId) {
return null;
}
const rootId = event.message.root_id?.trim();
const threadKey = rootId ? `thread:${rootId}` : "chat";
return `feishu:${accountId}:${chatId}:${threadKey}:${senderId}`;
},
shouldDebounce: (event) => {
if (event.message.message_type !== "text") {
return false;
}
const text = resolveDebounceText(event);
if (!text) {
return false;
}
return !core.channel.text.hasControlCommand(text, cfg);
},
onFlush: async (entries) => {
const last = entries.at(-1);
if (!last) {
return;
}
if (entries.length === 1) {
await dispatchFeishuMessage(last);
return;
}
const dedupedEntries = dedupeFeishuDebounceEntriesByMessageId(entries);
const freshEntries: FeishuMessageEvent[] = [];
for (const entry of dedupedEntries) {
if (!(await isMessageAlreadyProcessed(entry))) {
freshEntries.push(entry);
}
}
const dispatchEntry = freshEntries.at(-1);
if (!dispatchEntry) {
return;
}
await recordSuppressedMessageIds(dedupedEntries, dispatchEntry.message.message_id);
const combinedText = freshEntries
.map((entry) => resolveDebounceText(entry))
.filter(Boolean)
.join("\n");
const mergedMentions = resolveFeishuDebounceMentions({
entries: freshEntries,
botOpenId: botOpenIds.get(accountId),
});
if (!combinedText.trim()) {
await dispatchFeishuMessage({
...dispatchEntry,
message: {
...dispatchEntry.message,
mentions: mergedMentions ?? dispatchEntry.message.mentions,
},
});
return;
}
await dispatchFeishuMessage({
...dispatchEntry,
message: {
...dispatchEntry.message,
message_type: "text",
content: JSON.stringify({ text: combinedText }),
mentions: mergedMentions ?? dispatchEntry.message.mentions,
},
});
},
onError: (err, entries) => {
for (const entry of entries) {
releaseFeishuMessageProcessing(entry.message.message_id, accountId);
}
error(`feishu[${accountId}]: inbound debounce flush failed: ${String(err)}`);
},
});
eventDispatcher.register({
"im.message.receive_v1": async (data) => {
const event = parseFeishuMessageEventPayload(data);
if (!event) {
error(`feishu[${accountId}]: ignoring malformed message event payload`);
return;
}
const messageId = event.message?.message_id?.trim();
if (!tryBeginFeishuMessageProcessing(messageId, accountId)) {
log(`feishu[${accountId}]: dropping duplicate event for message ${messageId}`);
return;
}
const processMessage = async () => {
await inboundDebouncer.enqueue(event);
};
if (fireAndForget) {
void processMessage().catch((err) => {
releaseFeishuMessageProcessing(messageId, accountId);
error(`feishu[${accountId}]: error handling message: ${String(err)}`);
});
return;
}
try {
await processMessage();
} catch (err) {
releaseFeishuMessageProcessing(messageId, accountId);
error(`feishu[${accountId}]: error handling message: ${String(err)}`);
}
},
"im.message.message_read_v1": async () => {
// Ignore read receipts
},
"im.chat.member.bot.added_v1": async (data) => {
try {
const event = parseFeishuBotAddedEventPayload(data);
if (!event) {
return;
}
log(`feishu[${accountId}]: bot added to chat ${event.chat_id}`);
} catch (err) {
error(`feishu[${accountId}]: error handling bot added event: ${String(err)}`);
}
},
"im.chat.member.bot.deleted_v1": async (data) => {
try {
const chatId = parseFeishuBotRemovedChatId(data);
if (!chatId) {
return;
}
log(`feishu[${accountId}]: bot removed from chat ${chatId}`);
} catch (err) {
error(`feishu[${accountId}]: error handling bot removed event: ${String(err)}`);
}
},
"drive.notice.comment_add_v1": async (data: unknown) => {
await runFeishuHandler({
errorMessage: `feishu[${accountId}]: error handling drive comment notice`,
task: async () => {
const event = parseFeishuDriveCommentNoticeEventPayload(data);
if (!event) {
error(`feishu[${accountId}]: ignoring malformed drive comment notice payload`);
return;
}
const eventId = event.event_id?.trim();
const syntheticMessageId = eventId ? `drive-comment:${eventId}` : undefined;
if (syntheticMessageId) {
const claim = await claimUnprocessedFeishuMessage({
messageId: syntheticMessageId,
namespace: accountId,
log,
});
if (claim === "duplicate") {
log(`feishu[${accountId}]: dropping duplicate comment event ${syntheticMessageId}`);
return;
}
if (claim === "inflight") {
log(`feishu[${accountId}]: dropping in-flight comment event ${syntheticMessageId}`);
return;
}
}
log(
`feishu[${accountId}]: received drive comment notice ` +
`event=${event.event_id ?? "unknown"} ` +
`type=${event.notice_meta?.notice_type ?? "unknown"} ` +
`file=${event.notice_meta?.file_type ?? "unknown"}:${event.notice_meta?.file_token ?? "unknown"} ` +
`comment=${event.comment_id ?? "unknown"} ` +
`reply=${event.reply_id ?? "none"} ` +
`from=${event.notice_meta?.from_user_id?.open_id ?? "unknown"} ` +
`mentioned=${event.is_mentioned === true ? "yes" : "no"}`,
);
try {
await enqueue(buildCommentNoticeQueueKey(event), async () => {
await handleFeishuCommentEvent({
cfg,
accountId,
event,
botOpenId: botOpenIds.get(accountId),
runtime,
});
});
if (syntheticMessageId) {
await recordProcessedFeishuMessage(syntheticMessageId, accountId, log);
}
} catch (err) {
if (syntheticMessageId && !isFeishuRetryableSyntheticEventError(err)) {
await recordProcessedFeishuMessage(syntheticMessageId, accountId, log);
}
throw err;
} finally {
if (syntheticMessageId) {
releaseFeishuMessageProcessing(syntheticMessageId, accountId);
}
}
},
});
},
"im.message.reaction.created_v1": async (data) => {
await runFeishuHandler({
errorMessage: `feishu[${accountId}]: error handling reaction event`,
task: async () => {
const event = data as FeishuReactionCreatedEvent;
const myBotId = botOpenIds.get(accountId);
const syntheticEvent = await resolveReactionSyntheticEvent({
cfg,
accountId,
event,
botOpenId: myBotId,
logger: log,
});
if (!syntheticEvent) {
return;
}
const promise = handleFeishuMessage({
cfg,
event: syntheticEvent,
botOpenId: myBotId,
botName: botNames.get(accountId),
runtime,
chatHistories,
accountId,
});
await promise;
},
});
},
"im.message.reaction.deleted_v1": async (data) => {
await runFeishuHandler({
errorMessage: `feishu[${accountId}]: error handling reaction removal event`,
task: async () => {
const event = data as FeishuReactionDeletedEvent;
const myBotId = botOpenIds.get(accountId);
const syntheticEvent = await resolveReactionSyntheticEvent({
cfg,
accountId,
event,
botOpenId: myBotId,
logger: log,
action: "deleted",
});
if (!syntheticEvent) {
return;
}
const promise = handleFeishuMessage({
cfg,
event: syntheticEvent,
botOpenId: myBotId,
botName: botNames.get(accountId),
runtime,
chatHistories,
accountId,
});
await promise;
},
});
},
"application.bot.menu_v6": async (data) => {
try {
const event = parseFeishuBotMenuEvent(data);
if (!event) {
return;
}
const operatorOpenId = event.operator?.operator_id?.open_id?.trim();
const eventKey = event.event_key?.trim();
if (!operatorOpenId || !eventKey) {
return;
}
const syntheticEvent: FeishuMessageEvent = {
sender: {
sender_id: {
open_id: operatorOpenId,
user_id: event.operator?.operator_id?.user_id,
union_id: event.operator?.operator_id?.union_id,
},
sender_type: "user",
},
message: {
message_id: `bot-menu:${eventKey}:${event.timestamp ?? Date.now()}`,
chat_id: `p2p:${operatorOpenId}`,
chat_type: "p2p",
message_type: "text",
content: JSON.stringify({
text: `/menu ${eventKey}`,
}),
},
};
const syntheticMessageId = syntheticEvent.message.message_id;
const claim = await claimUnprocessedFeishuMessage({
messageId: syntheticMessageId,
namespace: accountId,
log,
});
if (claim === "duplicate") {
log(`feishu[${accountId}]: dropping duplicate bot-menu event for ${syntheticMessageId}`);
return;
}
if (claim === "inflight") {
log(`feishu[${accountId}]: dropping in-flight bot-menu event for ${syntheticMessageId}`);
return;
}
const handleLegacyMenu = () =>
handleFeishuMessage({
cfg,
event: syntheticEvent,
botOpenId: botOpenIds.get(accountId),
botName: botNames.get(accountId),
runtime,
chatHistories,
accountId,
processingClaimHeld: true,
});
const promise = maybeHandleFeishuQuickActionMenu({
cfg,
eventKey,
operatorOpenId,
runtime,
accountId,
})
.then(async (handledMenu) => {
if (handledMenu) {
await recordProcessedFeishuMessage(syntheticMessageId, accountId, log);
releaseFeishuMessageProcessing(syntheticMessageId, accountId);
return;
}
return await handleLegacyMenu();
})
.catch(async (err) => {
if (isFeishuRetryableSyntheticEventError(err)) {
releaseFeishuMessageProcessing(syntheticMessageId, accountId);
} else {
await recordProcessedFeishuMessage(syntheticMessageId, accountId, log);
}
throw err;
});
if (fireAndForget) {
promise.catch((err) => {
error(`feishu[${accountId}]: error handling bot menu event: ${String(err)}`);
});
return;
}
await promise;
} catch (err) {
error(`feishu[${accountId}]: error handling bot menu event: ${String(err)}`);
}
},
"card.action.trigger": async (data: unknown) => {
try {
const event = parseFeishuCardActionEventPayload(data);
if (!event) {
error(`feishu[${accountId}]: ignoring malformed card action payload`);
return;
}
const promise = handleFeishuCardAction({
cfg,
event,
botOpenId: botOpenIds.get(accountId),
runtime,
accountId,
});
if (fireAndForget) {
promise.catch((err) => {
error(`feishu[${accountId}]: error handling card action: ${String(err)}`);
});
} else {
await promise;
}
} catch (err) {
error(`feishu[${accountId}]: error handling card action: ${String(err)}`);
}
},
});
}
export type BotOpenIdSource =
| { kind: "prefetched"; botOpenId?: string; botName?: string }
| { kind: "fetch" };
export type MonitorSingleAccountParams = {
cfg: ClawdbotConfig;
account: ResolvedFeishuAccount;
runtime?: RuntimeEnv;
abortSignal?: AbortSignal;
botOpenIdSource?: BotOpenIdSource;
};
export async function monitorSingleAccount(params: MonitorSingleAccountParams): Promise<void> {
const { cfg, account, runtime, abortSignal } = params;
const { accountId } = account;
const log = runtime?.log ?? console.log;
const botOpenIdSource = params.botOpenIdSource ?? { kind: "fetch" };
const botIdentity =
botOpenIdSource.kind === "prefetched"
? { botOpenId: botOpenIdSource.botOpenId, botName: botOpenIdSource.botName }
: await fetchBotIdentityForMonitor(account, { runtime, abortSignal });
const { botOpenId } = applyBotIdentityState(accountId, botIdentity);
log(`feishu[${accountId}]: bot open_id resolved: ${botOpenId ?? "unknown"}`);
if (!botOpenId && !abortSignal?.aborted) {
startBotIdentityRecovery({ account, accountId, runtime, abortSignal });
}
const connectionMode = account.config.connectionMode ?? "websocket";
if (connectionMode === "webhook" && !account.verificationToken?.trim()) {
throw new Error(`Feishu account "${accountId}" webhook mode requires verificationToken`);
}
if (connectionMode === "webhook" && !account.encryptKey?.trim()) {
throw new Error(`Feishu account "${accountId}" webhook mode requires encryptKey`);
}
const warmupCount = await warmupDedupFromDisk(accountId, log);
if (warmupCount > 0) {
log(`feishu[${accountId}]: dedup warmup loaded ${warmupCount} entries from disk`);
}
let threadBindingManager: ReturnType<typeof createFeishuThreadBindingManager> | null = null;
try {
const eventDispatcher = createEventDispatcher(account);
const chatHistories = new Map<string, HistoryEntry[]>();
threadBindingManager = createFeishuThreadBindingManager({ accountId, cfg });
registerEventHandlers(eventDispatcher, {
cfg,
accountId,
runtime,
chatHistories,
fireAndForget: true,
});
if (connectionMode === "webhook") {
return await monitorWebhook({ account, accountId, runtime, abortSignal, eventDispatcher });
}
return await monitorWebSocket({ account, accountId, runtime, abortSignal, eventDispatcher });
} finally {
threadBindingManager?.stop();
}
}

View file

@ -0,0 +1,216 @@
import "./lifecycle.test-support.js";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createRuntimeEnv } from "../../../test/helpers/plugins/runtime-env.js";
import type { ClawdbotConfig } from "../runtime-api.js";
import { getFeishuLifecycleTestMocks } from "./lifecycle.test-support.js";
import {
createFeishuLifecycleFixture,
createFeishuTextMessageEvent,
expectFeishuSingleEffectAcrossReplay,
installFeishuLifecycleReplyRuntime,
restoreFeishuLifecycleStateDir,
setFeishuLifecycleStateDir,
setupFeishuLifecycleHandler,
} from "./test-support/lifecycle-test-support.js";
import type { ResolvedFeishuAccount } from "./types.js";
const {
createEventDispatcherMock,
dispatchReplyFromConfigMock,
ensureConfiguredBindingRouteReadyMock,
finalizeInboundContextMock,
resolveAgentRouteMock,
resolveBoundConversationMock,
resolveConfiguredBindingRouteMock,
sendMessageFeishuMock,
withReplyDispatcherMock,
} = getFeishuLifecycleTestMocks();
let _handlers: Record<string, (data: unknown) => Promise<void>> = {};
let lastRuntime: ReturnType<typeof createRuntimeEnv> | null = null;
const originalStateDir = process.env.OPENCLAW_STATE_DIR;
const { cfg: lifecycleConfig, account: lifecycleAccount } = createFeishuLifecycleFixture({
accountId: "acct-acp",
appId: "cli_test",
appSecret: "secret_test",
channelConfig: {
groupPolicy: "open",
allowFrom: ["ou_sender_1"],
},
accountConfig: {
groupPolicy: "open",
groups: {
oc_group_topic: {
requireMention: false,
groupSessionScope: "group_topic",
replyInThread: "enabled",
},
},
},
extraConfig: {
session: { mainKey: "main", scope: "per-sender" },
},
}) as {
cfg: ClawdbotConfig;
account: ResolvedFeishuAccount;
};
async function setupLifecycleMonitor() {
lastRuntime = createRuntimeEnv();
return setupFeishuLifecycleHandler({
createEventDispatcherMock,
onRegister: (registered) => {
_handlers = registered;
},
runtime: lastRuntime,
cfg: lifecycleConfig,
account: lifecycleAccount,
handlerKey: "im.message.receive_v1",
missingHandlerMessage: "missing im.message.receive_v1 handler",
});
}
describe("Feishu ACP-init failure lifecycle", () => {
beforeEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
_handlers = {};
lastRuntime = null;
setFeishuLifecycleStateDir("openclaw-feishu-acp-failure");
resolveBoundConversationMock.mockReturnValue(null);
resolveAgentRouteMock.mockReturnValue({
agentId: "main",
channel: "feishu",
accountId: "acct-acp",
sessionKey: "agent:main:feishu:group:oc_group_topic",
mainSessionKey: "agent:main:main",
matchedBy: "default",
});
resolveConfiguredBindingRouteMock.mockReturnValue({
bindingResolution: {
configuredBinding: {
spec: {
channel: "feishu",
accountId: "acct-acp",
conversationId: "oc_group_topic:topic:om_topic_root_1",
agentId: "codex",
mode: "persistent",
},
record: {
bindingId: "config:acp:feishu:acct-acp:oc_group_topic:topic:om_topic_root_1",
targetSessionKey: "agent:codex:acp:binding:feishu:acct-acp:abc123",
targetKind: "session",
conversation: {
channel: "feishu",
accountId: "acct-acp",
conversationId: "oc_group_topic:topic:om_topic_root_1",
parentConversationId: "oc_group_topic",
},
status: "active",
boundAt: 0,
metadata: { source: "config" },
},
},
statefulTarget: {
kind: "stateful",
driverId: "acp",
sessionKey: "agent:codex:acp:binding:feishu:acct-acp:abc123",
agentId: "codex",
},
},
configuredBinding: {
spec: {
channel: "feishu",
accountId: "acct-acp",
conversationId: "oc_group_topic:topic:om_topic_root_1",
agentId: "codex",
mode: "persistent",
},
},
route: {
agentId: "codex",
channel: "feishu",
accountId: "acct-acp",
sessionKey: "agent:codex:acp:binding:feishu:acct-acp:abc123",
mainSessionKey: "agent:codex:main",
matchedBy: "binding.channel",
},
});
ensureConfiguredBindingRouteReadyMock.mockResolvedValue({
ok: false,
error: "runtime unavailable",
});
dispatchReplyFromConfigMock.mockResolvedValue({
queuedFinal: false,
counts: { final: 0 },
});
withReplyDispatcherMock.mockImplementation(async ({ run }) => await run());
installFeishuLifecycleReplyRuntime({
resolveAgentRouteMock,
finalizeInboundContextMock,
dispatchReplyFromConfigMock,
withReplyDispatcherMock,
storePath: "/tmp/feishu-acp-failure-sessions.json",
});
});
afterEach(() => {
vi.useRealTimers();
restoreFeishuLifecycleStateDir(originalStateDir);
});
it("sends one ACP failure notice to the topic root across replay", async () => {
const onMessage = await setupLifecycleMonitor();
const event = createFeishuTextMessageEvent({
messageId: "om_topic_msg_1",
chatId: "oc_group_topic",
rootId: "om_topic_root_1",
threadId: "omt_topic_1",
text: "hello topic",
});
await expectFeishuSingleEffectAcrossReplay({
handler: onMessage,
event,
effectMock: sendMessageFeishuMock,
});
expect(lastRuntime?.error).not.toHaveBeenCalled();
expect(resolveConfiguredBindingRouteMock).toHaveBeenCalledTimes(1);
expect(ensureConfiguredBindingRouteReadyMock).toHaveBeenCalledTimes(1);
expect(sendMessageFeishuMock).toHaveBeenCalledTimes(1);
expect(sendMessageFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
accountId: "acct-acp",
to: "chat:oc_group_topic",
replyToMessageId: "om_topic_root_1",
replyInThread: true,
text: expect.stringContaining("runtime unavailable"),
}),
);
expect(dispatchReplyFromConfigMock).not.toHaveBeenCalled();
});
it("does not duplicate the ACP failure notice after the first send succeeds", async () => {
const onMessage = await setupLifecycleMonitor();
const event = createFeishuTextMessageEvent({
messageId: "om_topic_msg_2",
chatId: "oc_group_topic",
rootId: "om_topic_root_1",
threadId: "omt_topic_1",
text: "hello topic",
});
await expectFeishuSingleEffectAcrossReplay({
handler: onMessage,
event,
effectMock: sendMessageFeishuMock,
});
expect(sendMessageFeishuMock).toHaveBeenCalledTimes(1);
expect(lastRuntime?.error).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,86 @@
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import type { RuntimeEnv } from "../runtime-api.js";
import { waitForAbortableDelay } from "./async.js";
import { fetchBotIdentityForMonitor, type FeishuMonitorBotIdentity } from "./monitor.startup.js";
import { botNames, botOpenIds } from "./monitor.state.js";
import type { ResolvedFeishuAccount } from "./types.js";
// Delays must be >= PROBE_ERROR_TTL_MS (60s) so each retry makes a real network request
// instead of silently hitting the probe error cache.
export const BOT_IDENTITY_RETRY_DELAYS_MS = [60_000, 120_000, 300_000, 600_000, 900_000];
export function applyBotIdentityState(
accountId: string,
identity: FeishuMonitorBotIdentity,
): { botOpenId?: string; botName?: string } {
const botOpenId = normalizeOptionalString(identity.botOpenId);
const botName = normalizeOptionalString(identity.botName);
botOpenIds.set(accountId, botOpenId ?? "");
if (botName) {
botNames.set(accountId, botName);
} else {
botNames.delete(accountId);
}
return { botOpenId, botName };
}
async function retryBotIdentityProbe(
account: ResolvedFeishuAccount,
accountId: string,
runtime: RuntimeEnv | undefined,
abortSignal: AbortSignal | undefined,
): Promise<void> {
const log = runtime?.log ?? console.log;
const error = runtime?.error ?? console.error;
for (let i = 0; i < BOT_IDENTITY_RETRY_DELAYS_MS.length; i += 1) {
if (abortSignal?.aborted) {
return;
}
const delayElapsed = await waitForAbortableDelay(BOT_IDENTITY_RETRY_DELAYS_MS[i], abortSignal);
if (!delayElapsed) {
return;
}
const identity = await fetchBotIdentityForMonitor(account, { runtime, abortSignal });
const resolved = applyBotIdentityState(accountId, identity);
if (resolved.botOpenId) {
log(
`feishu[${accountId}]: bot open_id recovered via background retry: ${resolved.botOpenId}`,
);
return;
}
const nextDelay = BOT_IDENTITY_RETRY_DELAYS_MS[i + 1];
error(
`feishu[${accountId}]: bot identity background retry ${i + 1}/${BOT_IDENTITY_RETRY_DELAYS_MS.length} failed` +
(nextDelay ? `; next attempt in ${nextDelay / 1000}s` : ""),
);
}
error(
`feishu[${accountId}]: bot identity background retry exhausted; requireMention group messages may be skipped until restart`,
);
}
export function startBotIdentityRecovery(params: {
account: ResolvedFeishuAccount;
accountId: string;
runtime?: RuntimeEnv;
abortSignal?: AbortSignal;
}): void {
const { account, accountId, runtime, abortSignal } = params;
const log = runtime?.log ?? console.log;
log(
`feishu[${accountId}]: bot open_id unknown; starting background retry (delays: ${BOT_IDENTITY_RETRY_DELAYS_MS.map((delay) => `${delay / 1000}s`).join(", ")})`,
);
log(
`feishu[${accountId}]: requireMention group messages stay gated until bot identity recovery succeeds`,
);
void retryBotIdentityProbe(account, accountId, runtime, abortSignal);
}

View file

@ -0,0 +1,218 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createRuntimeEnv } from "../../../test/helpers/plugins/runtime-env.js";
import "./lifecycle.test-support.js";
import { getFeishuLifecycleTestMocks } from "./lifecycle.test-support.js";
import {
createFeishuLifecycleConfig,
createFeishuLifecycleReplyDispatcher,
createResolvedFeishuLifecycleAccount,
expectFeishuReplyDispatcherSentFinalReplyOnce,
expectFeishuReplyPipelineDedupedAcrossReplay,
expectFeishuReplyPipelineDedupedAfterPostSendFailure,
expectFeishuSingleEffectAcrossReplay,
installFeishuLifecycleReplyRuntime,
mockFeishuReplyOnceDispatch,
restoreFeishuLifecycleStateDir,
setFeishuLifecycleStateDir,
setupFeishuLifecycleHandler,
} from "./test-support/lifecycle-test-support.js";
const {
createEventDispatcherMock,
createFeishuReplyDispatcherMock,
dispatchReplyFromConfigMock,
finalizeInboundContextMock,
resolveAgentRouteMock,
resolveBoundConversationMock,
sendCardFeishuMock,
touchBindingMock,
withReplyDispatcherMock,
} = getFeishuLifecycleTestMocks();
let _handlers: Record<string, (data: unknown) => Promise<void>> = {};
let lastRuntime: ReturnType<typeof createRuntimeEnv> | null = null;
const originalStateDir = process.env.OPENCLAW_STATE_DIR;
const lifecycleConfig = createFeishuLifecycleConfig({
accountId: "acct-menu",
appId: "cli_test",
appSecret: "secret_test",
channelConfig: {
dmPolicy: "open",
},
accountConfig: {
dmPolicy: "open",
},
});
const lifecycleAccount = createResolvedFeishuLifecycleAccount({
accountId: "acct-menu",
appId: "cli_test",
appSecret: "secret_test",
config: {
dmPolicy: "open",
},
});
function createBotMenuEvent(params: { eventKey: string; timestamp: string }) {
return {
event_key: params.eventKey,
timestamp: params.timestamp,
operator: {
operator_id: {
open_id: "ou_user1",
user_id: "user_1",
union_id: "union_1",
},
},
};
}
async function setupLifecycleMonitor() {
lastRuntime = createRuntimeEnv();
return setupFeishuLifecycleHandler({
createEventDispatcherMock,
onRegister: (registered) => {
_handlers = registered;
},
runtime: lastRuntime,
cfg: lifecycleConfig,
account: lifecycleAccount,
handlerKey: "application.bot.menu_v6",
missingHandlerMessage: "missing application.bot.menu_v6 handler",
});
}
describe("Feishu bot-menu lifecycle", () => {
beforeEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
_handlers = {};
lastRuntime = null;
setFeishuLifecycleStateDir("openclaw-feishu-bot-menu");
createFeishuReplyDispatcherMock.mockReturnValue(createFeishuLifecycleReplyDispatcher());
resolveBoundConversationMock.mockImplementation(() => ({
bindingId: "binding-menu",
targetSessionKey: "agent:bound-agent:feishu:direct:ou_user1",
}));
resolveAgentRouteMock.mockReturnValue({
agentId: "main",
channel: "feishu",
accountId: "acct-menu",
sessionKey: "agent:main:feishu:direct:ou_user1",
mainSessionKey: "agent:main:main",
matchedBy: "default",
});
mockFeishuReplyOnceDispatch({
dispatchReplyFromConfigMock,
replyText: "menu reply once",
});
withReplyDispatcherMock.mockImplementation(async ({ run }) => await run());
installFeishuLifecycleReplyRuntime({
resolveAgentRouteMock,
finalizeInboundContextMock,
dispatchReplyFromConfigMock,
withReplyDispatcherMock,
storePath: "/tmp/feishu-bot-menu-sessions.json",
});
});
afterEach(() => {
vi.useRealTimers();
restoreFeishuLifecycleStateDir(originalStateDir);
});
it("opens one launcher card across duplicate quick-actions replay", async () => {
const onBotMenu = await setupLifecycleMonitor();
const event = createBotMenuEvent({
eventKey: "quick-actions",
timestamp: "1700000000000",
});
await expectFeishuSingleEffectAcrossReplay({
handler: onBotMenu,
event,
effectMock: sendCardFeishuMock,
});
expect(lastRuntime?.error).not.toHaveBeenCalled();
expect(sendCardFeishuMock).toHaveBeenCalledTimes(1);
expect(sendCardFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
accountId: "acct-menu",
to: "user:ou_user1",
}),
);
expect(dispatchReplyFromConfigMock).not.toHaveBeenCalled();
expect(createFeishuReplyDispatcherMock).not.toHaveBeenCalled();
});
it("falls back once to the legacy routed reply path when launcher rendering fails", async () => {
const onBotMenu = await setupLifecycleMonitor();
const event = createBotMenuEvent({
eventKey: "quick-actions",
timestamp: "1700000000001",
});
sendCardFeishuMock.mockRejectedValueOnce(new Error("boom"));
await expectFeishuReplyPipelineDedupedAcrossReplay({
handler: onBotMenu,
event,
dispatchReplyFromConfigMock,
createFeishuReplyDispatcherMock,
waitTimeoutMs: 5_000,
});
expect(lastRuntime?.error).not.toHaveBeenCalled();
expect(sendCardFeishuMock).toHaveBeenCalledTimes(1);
expect(dispatchReplyFromConfigMock).toHaveBeenCalledTimes(1);
expect(createFeishuReplyDispatcherMock).toHaveBeenCalledTimes(1);
expect(createFeishuReplyDispatcherMock).toHaveBeenCalledWith(
expect.objectContaining({
accountId: "acct-menu",
chatId: "p2p:ou_user1",
replyToMessageId: "bot-menu:quick-actions:1700000000001",
}),
);
expect(finalizeInboundContextMock).toHaveBeenCalledWith(
expect.objectContaining({
AccountId: "acct-menu",
SessionKey: "agent:bound-agent:feishu:direct:ou_user1",
MessageSid: "bot-menu:quick-actions:1700000000001",
}),
);
expect(touchBindingMock).toHaveBeenCalledWith("binding-menu");
expectFeishuReplyDispatcherSentFinalReplyOnce({ createFeishuReplyDispatcherMock });
});
it("does not duplicate delivery when launcher fallback hits a post-send failure", async () => {
const onBotMenu = await setupLifecycleMonitor();
const event = createBotMenuEvent({
eventKey: "quick-actions",
timestamp: "1700000000002",
});
sendCardFeishuMock.mockRejectedValueOnce(new Error("boom"));
dispatchReplyFromConfigMock.mockImplementationOnce(async ({ dispatcher }) => {
await dispatcher.sendFinalReply({ text: "menu reply once" });
throw new Error("post-send failure");
});
await expectFeishuReplyPipelineDedupedAfterPostSendFailure({
handler: onBotMenu,
event,
dispatchReplyFromConfigMock,
runtimeErrorMock: lastRuntime?.error as ReturnType<typeof vi.fn>,
waitTimeoutMs: 5_000,
});
expect(sendCardFeishuMock).toHaveBeenCalledTimes(1);
expect(dispatchReplyFromConfigMock).toHaveBeenCalledTimes(1);
expectFeishuReplyDispatcherSentFinalReplyOnce({ createFeishuReplyDispatcherMock });
});
});

View file

@ -0,0 +1,258 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig, RuntimeEnv } from "../runtime-api.js";
import { monitorSingleAccount } from "./monitor.account.js";
import { setFeishuRuntime } from "./runtime.js";
import type { ResolvedFeishuAccount } from "./types.js";
const createEventDispatcherMock = vi.hoisted(() => vi.fn());
const monitorWebSocketMock = vi.hoisted(() => vi.fn(async () => {}));
const monitorWebhookMock = vi.hoisted(() => vi.fn(async () => {}));
const handleFeishuMessageMock = vi.hoisted(() => vi.fn(async () => {}));
const parseFeishuMessageEventMock = vi.hoisted(() => vi.fn());
const sendCardFeishuMock = vi.hoisted(() => vi.fn(async () => ({ messageId: "m1", chatId: "c1" })));
const getMessageFeishuMock = vi.hoisted(() => vi.fn());
const createFeishuThreadBindingManagerMock = vi.hoisted(() => vi.fn(() => ({ stop: vi.fn() })));
let handlers: Record<string, (data: unknown) => Promise<void>> = {};
const originalStateDir = process.env.OPENCLAW_STATE_DIR;
const hasControlCommand = () => false;
const resolveInboundDebounceMs = () => 0;
const createInboundDebouncer = () => ({
run: async <T>(fn: () => Promise<T>) => await fn(),
});
const createMonitorRuntime = () =>
({
channel: {
debounce: {
createInboundDebouncer,
resolveInboundDebounceMs,
},
text: {
hasControlCommand,
},
},
}) as never;
vi.mock("./client.js", () => ({
createEventDispatcher: createEventDispatcherMock,
}));
vi.mock("./monitor.transport.js", () => ({
monitorWebSocket: monitorWebSocketMock,
monitorWebhook: monitorWebhookMock,
}));
vi.mock("./bot.js", () => {
return {
handleFeishuMessage: handleFeishuMessageMock,
parseFeishuMessageEvent: parseFeishuMessageEventMock,
};
});
vi.mock("./send.js", () => {
return {
sendCardFeishu: sendCardFeishuMock,
getMessageFeishu: getMessageFeishuMock,
};
});
vi.mock("./thread-bindings.js", () => ({
createFeishuThreadBindingManager: createFeishuThreadBindingManagerMock,
}));
function buildAccount(): ResolvedFeishuAccount {
return {
accountId: "default",
enabled: true,
configured: true,
appId: "cli_test",
appSecret: "secret_test", // pragma: allowlist secret
domain: "feishu",
config: {
enabled: true,
connectionMode: "websocket",
},
} as ResolvedFeishuAccount;
}
function createBotMenuEvent(params: { eventKey: string; timestamp: string }) {
return {
event_key: params.eventKey,
timestamp: params.timestamp,
operator: {
operator_id: {
open_id: "ou_user1",
user_id: "user_1",
union_id: "union_1",
},
},
};
}
async function registerHandlers() {
setFeishuRuntime(createMonitorRuntime());
const register = vi.fn((registered: Record<string, (data: unknown) => Promise<void>>) => {
handlers = registered;
});
createEventDispatcherMock.mockReturnValue({ register });
await monitorSingleAccount({
cfg: {} as ClawdbotConfig,
account: buildAccount(),
runtime: {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
} as RuntimeEnv,
botOpenIdSource: {
kind: "prefetched",
botOpenId: "ou_bot",
botName: "Bot",
},
});
const onBotMenu = handlers["application.bot.menu_v6"];
if (!onBotMenu) {
throw new Error("missing application.bot.menu_v6 handler");
}
return onBotMenu;
}
describe("Feishu bot menu handler", () => {
beforeEach(() => {
handlers = {};
vi.clearAllMocks();
process.env.OPENCLAW_STATE_DIR = `/tmp/openclaw-feishu-bot-menu-test-${Date.now()}-${Math.random().toString(36).slice(2)}`;
});
afterEach(() => {
if (originalStateDir === undefined) {
delete process.env.OPENCLAW_STATE_DIR;
return;
}
process.env.OPENCLAW_STATE_DIR = originalStateDir;
});
it("opens the quick-action launcher card at the webhook/event layer", async () => {
const onBotMenu = await registerHandlers();
await onBotMenu(createBotMenuEvent({ eventKey: "quick-actions", timestamp: "1700000000000" }));
expect(sendCardFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
to: "user:ou_user1",
card: expect.objectContaining({
config: expect.objectContaining({
width_mode: "fill",
}),
header: expect.objectContaining({
title: expect.objectContaining({ content: "Quick actions" }),
}),
}),
}),
);
expect(handleFeishuMessageMock).not.toHaveBeenCalled();
});
it("does not block bot-menu handling on quick-action launcher send", async () => {
const onBotMenu = await registerHandlers();
let resolveSend: (() => void) | undefined;
sendCardFeishuMock.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveSend = () => resolve({ messageId: "m1", chatId: "c1" });
}),
);
const pending = onBotMenu(
createBotMenuEvent({ eventKey: "quick-actions", timestamp: "1700000000001" }),
);
let settled = false;
void pending.finally(() => {
settled = true;
});
await vi.waitFor(() => {
expect(settled).toBe(true);
});
resolveSend?.();
await pending;
});
it("falls back to the legacy /menu synthetic message path for unrelated bot menu keys", async () => {
const onBotMenu = await registerHandlers();
await onBotMenu(createBotMenuEvent({ eventKey: "custom-key", timestamp: "1700000000002" }));
expect(handleFeishuMessageMock).toHaveBeenCalledWith(
expect.objectContaining({
event: expect.objectContaining({
message: expect.objectContaining({
content: '{"text":"/menu custom-key"}',
}),
}),
}),
);
expect(sendCardFeishuMock).not.toHaveBeenCalled();
});
it("falls back to the legacy /menu path when launcher rendering fails", async () => {
const onBotMenu = await registerHandlers();
sendCardFeishuMock.mockRejectedValueOnce(new Error("boom"));
await onBotMenu(createBotMenuEvent({ eventKey: "quick-actions", timestamp: "1700000000003" }));
await vi.waitFor(() => {
expect(handleFeishuMessageMock).toHaveBeenCalledWith(
expect.objectContaining({
event: expect.objectContaining({
message: expect.objectContaining({
content: '{"text":"/menu quick-actions"}',
}),
}),
}),
);
});
const firstSendArg = (sendCardFeishuMock.mock.calls as unknown[][]).at(0)?.[0] as
| {
card?: {
config?: {
width_mode?: string;
wide_screen_mode?: boolean;
enable_forward?: boolean;
};
};
}
| undefined;
const sentCard = firstSendArg?.card;
expect(sentCard).toBeDefined();
expect(sentCard?.config?.wide_screen_mode).toBeUndefined();
expect(sentCard?.config?.enable_forward).toBeUndefined();
});
it("reopens replay for explicit retryable fallback failures", async () => {
const onBotMenu = await registerHandlers();
sendCardFeishuMock
.mockImplementationOnce(async () => {
throw new Error("boom");
})
.mockImplementationOnce(async () => {
throw new Error("boom");
});
handleFeishuMessageMock
.mockRejectedValueOnce(
Object.assign(new Error("retry me"), {
name: "FeishuRetryableSyntheticEventError",
}),
)
.mockResolvedValueOnce(undefined);
await onBotMenu(createBotMenuEvent({ eventKey: "quick-actions", timestamp: "1700000000004" }));
await onBotMenu(createBotMenuEvent({ eventKey: "quick-actions", timestamp: "1700000000004" }));
expect(sendCardFeishuMock).toHaveBeenCalledTimes(2);
expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1);
});
});

Some files were not shown because too many files have changed in this diff Show more