mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-11 12:10:45 +08:00
重构主控编排与运行时预热链路,统一工作区提示词/专家调度协议并补齐 wiki 记忆注入与写回闭环。
同时收敛启动与运维脚本默认行为(含 wiki worker)、更新 Admin 可观测性与相关测试,降低首轮时延并提高运行稳定性。 Made-with: Cursor
This commit is contained in:
parent
4a23b715a2
commit
dbbe3add6a
14438 changed files with 2693620 additions and 2546 deletions
20
openclaw/extensions/feishu/api.ts
Normal file
20
openclaw/extensions/feishu/api.ts
Normal 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;
|
||||
20
openclaw/extensions/feishu/channel-entry.ts
Normal file
20
openclaw/extensions/feishu/channel-entry.ts
Normal 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",
|
||||
},
|
||||
});
|
||||
16
openclaw/extensions/feishu/contract-api.ts
Normal file
16
openclaw/extensions/feishu/contract-api.ts
Normal 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;
|
||||
101
openclaw/extensions/feishu/index.ts
Normal file
101
openclaw/extensions/feishu/index.ts
Normal 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);
|
||||
},
|
||||
});
|
||||
18
openclaw/extensions/feishu/openclaw.plugin.json
Normal file
18
openclaw/extensions/feishu/openclaw.plugin.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
60
openclaw/extensions/feishu/package.json
Normal file
60
openclaw/extensions/feishu/package.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
52
openclaw/extensions/feishu/runtime-api.ts
Normal file
52
openclaw/extensions/feishu/runtime-api.ts
Normal 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";
|
||||
5
openclaw/extensions/feishu/secret-contract-api.ts
Normal file
5
openclaw/extensions/feishu/secret-contract-api.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export {
|
||||
channelSecrets,
|
||||
collectRuntimeConfigAssignments,
|
||||
secretTargetRegistryEntries,
|
||||
} from "./src/secret-contract.js";
|
||||
81
openclaw/extensions/feishu/security-contract-api.ts
Normal file
81
openclaw/extensions/feishu/security-contract-api.ts
Normal 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.",
|
||||
},
|
||||
];
|
||||
}
|
||||
1
openclaw/extensions/feishu/session-key-api.ts
Normal file
1
openclaw/extensions/feishu/session-key-api.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { resolveFeishuSessionConversation as resolveSessionConversation } from "./src/session-conversation.js";
|
||||
2
openclaw/extensions/feishu/setup-api.ts
Normal file
2
openclaw/extensions/feishu/setup-api.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { feishuSetupAdapter } from "./src/setup-core.js";
|
||||
export { feishuSetupWizard } from "./src/setup-surface.js";
|
||||
13
openclaw/extensions/feishu/setup-entry.ts
Normal file
13
openclaw/extensions/feishu/setup-entry.ts
Normal 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",
|
||||
},
|
||||
});
|
||||
211
openclaw/extensions/feishu/skills/feishu-doc/SKILL.md
Normal file
211
openclaw/extensions/feishu/skills/feishu-doc/SKILL.md
Normal 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 (`` 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`
|
||||
|
|
@ -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.
|
||||
97
openclaw/extensions/feishu/skills/feishu-drive/SKILL.md
Normal file
97
openclaw/extensions/feishu/skills/feishu-drive/SKILL.md
Normal 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
|
||||
119
openclaw/extensions/feishu/skills/feishu-perm/SKILL.md
Normal file
119
openclaw/extensions/feishu/skills/feishu-perm/SKILL.md
Normal 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`
|
||||
111
openclaw/extensions/feishu/skills/feishu-wiki/SKILL.md
Normal file
111
openclaw/extensions/feishu/skills/feishu-wiki/SKILL.md
Normal 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`
|
||||
459
openclaw/extensions/feishu/src/accounts.test.ts
Normal file
459
openclaw/extensions/feishu/src/accounts.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
326
openclaw/extensions/feishu/src/accounts.ts
Normal file
326
openclaw/extensions/feishu/src/accounts.ts
Normal 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);
|
||||
}
|
||||
332
openclaw/extensions/feishu/src/app-registration.ts
Normal file
332
openclaw/extensions/feishu/src/app-registration.ts
Normal 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));
|
||||
}
|
||||
24
openclaw/extensions/feishu/src/approval-auth.test.ts
Normal file
24
openclaw/extensions/feishu/src/approval-auth.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
25
openclaw/extensions/feishu/src/approval-auth.ts
Normal file
25
openclaw/extensions/feishu/src/approval-auth.ts
Normal 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),
|
||||
});
|
||||
27
openclaw/extensions/feishu/src/async.test.ts
Normal file
27
openclaw/extensions/feishu/src/async.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
86
openclaw/extensions/feishu/src/async.ts
Normal file
86
openclaw/extensions/feishu/src/async.ts
Normal 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 });
|
||||
});
|
||||
}
|
||||
737
openclaw/extensions/feishu/src/bitable.ts
Normal file
737
openclaw/extensions/feishu/src/bitable.ts
Normal 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");
|
||||
}
|
||||
455
openclaw/extensions/feishu/src/bot-content.ts
Normal file
455
openclaw/extensions/feishu/src/bot-content.ts
Normal 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, "<").replace(/>/g, ">");
|
||||
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;
|
||||
}
|
||||
12
openclaw/extensions/feishu/src/bot-runtime-api.ts
Normal file
12
openclaw/extensions/feishu/src/bot-runtime-api.ts
Normal 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";
|
||||
127
openclaw/extensions/feishu/src/bot-sender-name.ts
Normal file
127
openclaw/extensions/feishu/src/bot-sender-name.ts
Normal 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 {};
|
||||
}
|
||||
}
|
||||
373
openclaw/extensions/feishu/src/bot.broadcast.test.ts
Normal file
373
openclaw/extensions/feishu/src/bot.broadcast.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
608
openclaw/extensions/feishu/src/bot.card-action.test.ts
Normal file
608
openclaw/extensions/feishu/src/bot.card-action.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
193
openclaw/extensions/feishu/src/bot.checkBotMentioned.test.ts
Normal file
193
openclaw/extensions/feishu/src/bot.checkBotMentioned.test.ts
Normal 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]");
|
||||
});
|
||||
});
|
||||
94
openclaw/extensions/feishu/src/bot.helpers.test.ts
Normal file
94
openclaw/extensions/feishu/src/bot.helpers.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
126
openclaw/extensions/feishu/src/bot.stripBotMention.test.ts
Normal file
126
openclaw/extensions/feishu/src/bot.stripBotMention.test.ts
Normal 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"><script></at> test');
|
||||
});
|
||||
});
|
||||
2706
openclaw/extensions/feishu/src/bot.test.ts
Normal file
2706
openclaw/extensions/feishu/src/bot.test.ts
Normal file
File diff suppressed because it is too large
Load diff
1253
openclaw/extensions/feishu/src/bot.ts
Normal file
1253
openclaw/extensions/feishu/src/bot.ts
Normal file
File diff suppressed because it is too large
Load diff
442
openclaw/extensions/feishu/src/card-action.ts
Normal file
442
openclaw/extensions/feishu/src/card-action.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
129
openclaw/extensions/feishu/src/card-interaction.test.ts
Normal file
129
openclaw/extensions/feishu/src/card-interaction.test.ts
Normal 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" });
|
||||
});
|
||||
});
|
||||
166
openclaw/extensions/feishu/src/card-interaction.ts
Normal file
166
openclaw/extensions/feishu/src/card-interaction.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
65
openclaw/extensions/feishu/src/card-ux-approval.ts
Normal file
65
openclaw/extensions/feishu/src/card-ux-approval.ts
Normal 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,
|
||||
}),
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
129
openclaw/extensions/feishu/src/card-ux-launcher.test.ts
Normal file
129
openclaw/extensions/feishu/src/card-ux-launcher.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
121
openclaw/extensions/feishu/src/card-ux-launcher.ts
Normal file
121
openclaw/extensions/feishu/src/card-ux-launcher.ts
Normal 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;
|
||||
}
|
||||
33
openclaw/extensions/feishu/src/card-ux-shared.ts
Normal file
33
openclaw/extensions/feishu/src/card-ux-shared.ts
Normal 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 } : {}),
|
||||
};
|
||||
}
|
||||
16
openclaw/extensions/feishu/src/channel-runtime-api.ts
Normal file
16
openclaw/extensions/feishu/src/channel-runtime-api.ts
Normal 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";
|
||||
47
openclaw/extensions/feishu/src/channel.runtime.ts
Normal file
47
openclaw/extensions/feishu/src/channel.runtime.ts
Normal 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,
|
||||
};
|
||||
945
openclaw/extensions/feishu/src/channel.test.ts
Normal file
945
openclaw/extensions/feishu/src/channel.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
1229
openclaw/extensions/feishu/src/channel.ts
Normal file
1229
openclaw/extensions/feishu/src/channel.ts
Normal file
File diff suppressed because it is too large
Load diff
25
openclaw/extensions/feishu/src/chat-schema.ts
Normal file
25
openclaw/extensions/feishu/src/chat-schema.ts
Normal 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>;
|
||||
145
openclaw/extensions/feishu/src/chat.test.ts
Normal file
145
openclaw/extensions/feishu/src/chat.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
193
openclaw/extensions/feishu/src/chat.ts
Normal file
193
openclaw/extensions/feishu/src/chat.ts
Normal 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");
|
||||
}
|
||||
390
openclaw/extensions/feishu/src/client.test.ts
Normal file
390
openclaw/extensions/feishu/src/client.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
290
openclaw/extensions/feishu/src/client.ts
Normal file
290
openclaw/extensions/feishu/src/client.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
export {
|
||||
createReplyPrefixContext,
|
||||
type ClawdbotConfig,
|
||||
type ReplyPayload,
|
||||
type RuntimeEnv,
|
||||
} from "../runtime-api.js";
|
||||
160
openclaw/extensions/feishu/src/comment-dispatcher.test.ts
Normal file
160
openclaw/extensions/feishu/src/comment-dispatcher.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
107
openclaw/extensions/feishu/src/comment-dispatcher.ts
Normal file
107
openclaw/extensions/feishu/src/comment-dispatcher.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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";
|
||||
441
openclaw/extensions/feishu/src/comment-handler.test.ts
Normal file
441
openclaw/extensions/feishu/src/comment-handler.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
259
openclaw/extensions/feishu/src/comment-handler.ts
Normal file
259
openclaw/extensions/feishu/src/comment-handler.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
190
openclaw/extensions/feishu/src/comment-reaction.test.ts
Normal file
190
openclaw/extensions/feishu/src/comment-reaction.test.ts
Normal 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",
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
281
openclaw/extensions/feishu/src/comment-reaction.ts
Normal file
281
openclaw/extensions/feishu/src/comment-reaction.ts
Normal 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,
|
||||
}),
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
182
openclaw/extensions/feishu/src/comment-shared.test.ts
Normal file
182
openclaw/extensions/feishu/src/comment-shared.test.ts
Normal 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,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
337
openclaw/extensions/feishu/src/comment-shared.ts
Normal file
337
openclaw/extensions/feishu/src/comment-shared.ts
Normal 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;
|
||||
}
|
||||
44
openclaw/extensions/feishu/src/comment-target.ts
Normal file
44
openclaw/extensions/feishu/src/comment-target.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
270
openclaw/extensions/feishu/src/config-schema.test.ts
Normal file
270
openclaw/extensions/feishu/src/config-schema.test.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
314
openclaw/extensions/feishu/src/config-schema.ts
Normal file
314
openclaw/extensions/feishu/src/config-schema.ts
Normal 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 "*"',
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
199
openclaw/extensions/feishu/src/conversation-id.ts
Normal file
199
openclaw/extensions/feishu/src/conversation-id.ts
Normal 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 [];
|
||||
}
|
||||
5
openclaw/extensions/feishu/src/dedup-runtime-api.ts
Normal file
5
openclaw/extensions/feishu/src/dedup-runtime-api.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export {
|
||||
createDedupeCache,
|
||||
createPersistentDedupe,
|
||||
readJsonFileWithFallback,
|
||||
} from "../runtime-api.js";
|
||||
166
openclaw/extensions/feishu/src/dedup.ts
Normal file
166
openclaw/extensions/feishu/src/dedup.ts
Normal 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)}`);
|
||||
});
|
||||
}
|
||||
61
openclaw/extensions/feishu/src/directory.static.ts
Normal file
61
openclaw/extensions/feishu/src/directory.static.ts
Normal 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));
|
||||
}
|
||||
136
openclaw/extensions/feishu/src/directory.test.ts
Normal file
136
openclaw/extensions/feishu/src/directory.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
126
openclaw/extensions/feishu/src/directory.ts
Normal file
126
openclaw/extensions/feishu/src/directory.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
182
openclaw/extensions/feishu/src/doc-schema.ts
Normal file
182
openclaw/extensions/feishu/src/doc-schema.ts
Normal 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>;
|
||||
97
openclaw/extensions/feishu/src/docx-batch-insert.test.ts
Normal file
97
openclaw/extensions/feishu/src/docx-batch-insert.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
223
openclaw/extensions/feishu/src/docx-batch-insert.ts
Normal file
223
openclaw/extensions/feishu/src/docx-batch-insert.ts
Normal 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: [] };
|
||||
}
|
||||
154
openclaw/extensions/feishu/src/docx-color-text.ts
Normal file
154
openclaw/extensions/feishu/src/docx-color-text.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
53
openclaw/extensions/feishu/src/docx-table-ops.test.ts
Normal file
53
openclaw/extensions/feishu/src/docx-table-ops.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
319
openclaw/extensions/feishu/src/docx-table-ops.ts
Normal file
319
openclaw/extensions/feishu/src/docx-table-ops.ts
Normal 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 };
|
||||
}
|
||||
38
openclaw/extensions/feishu/src/docx-types.ts
Normal file
38
openclaw/extensions/feishu/src/docx-types.ts
Normal 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;
|
||||
};
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
685
openclaw/extensions/feishu/src/docx.test.ts
Normal file
685
openclaw/extensions/feishu/src/docx.test.ts
Normal 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: "",
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
1622
openclaw/extensions/feishu/src/docx.ts
Normal file
1622
openclaw/extensions/feishu/src/docx.ts
Normal file
File diff suppressed because it is too large
Load diff
92
openclaw/extensions/feishu/src/drive-schema.ts
Normal file
92
openclaw/extensions/feishu/src/drive-schema.ts
Normal 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>;
|
||||
1219
openclaw/extensions/feishu/src/drive.test.ts
Normal file
1219
openclaw/extensions/feishu/src/drive.test.ts
Normal file
File diff suppressed because it is too large
Load diff
849
openclaw/extensions/feishu/src/drive.ts
Normal file
849
openclaw/extensions/feishu/src/drive.ts
Normal 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`);
|
||||
}
|
||||
131
openclaw/extensions/feishu/src/dynamic-agent.ts
Normal file
131
openclaw/extensions/feishu/src/dynamic-agent.ts
Normal 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;
|
||||
}
|
||||
43
openclaw/extensions/feishu/src/event-types.ts
Normal file
43
openclaw/extensions/feishu/src/event-types.ts
Normal 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;
|
||||
};
|
||||
20
openclaw/extensions/feishu/src/external-keys.test.ts
Normal file
20
openclaw/extensions/feishu/src/external-keys.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
19
openclaw/extensions/feishu/src/external-keys.ts
Normal file
19
openclaw/extensions/feishu/src/external-keys.ts
Normal 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;
|
||||
}
|
||||
150
openclaw/extensions/feishu/src/lifecycle.test-support.ts
Normal file
150
openclaw/extensions/feishu/src/lifecycle.test-support.ts
Normal 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,
|
||||
}),
|
||||
}));
|
||||
613
openclaw/extensions/feishu/src/media.test.ts
Normal file
613
openclaw/extensions/feishu/src/media.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
648
openclaw/extensions/feishu/src/media.ts
Normal file
648
openclaw/extensions/feishu/src/media.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
5
openclaw/extensions/feishu/src/mention-target.types.ts
Normal file
5
openclaw/extensions/feishu/src/mention-target.types.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export type MentionTarget = {
|
||||
openId: string;
|
||||
name: string;
|
||||
key: string; // Placeholder in original message, e.g. @_user_1
|
||||
};
|
||||
126
openclaw/extensions/feishu/src/mention.ts
Normal file
126
openclaw/extensions/feishu/src/mention.ts
Normal 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}`;
|
||||
}
|
||||
13
openclaw/extensions/feishu/src/message-action-contract.ts
Normal file
13
openclaw/extensions/feishu/src/message-action-contract.ts
Normal 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>>;
|
||||
|
|
@ -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";
|
||||
|
|
@ -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";
|
||||
922
openclaw/extensions/feishu/src/monitor.account.ts
Normal file
922
openclaw/extensions/feishu/src/monitor.account.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
86
openclaw/extensions/feishu/src/monitor.bot-identity.ts
Normal file
86
openclaw/extensions/feishu/src/monitor.bot-identity.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
258
openclaw/extensions/feishu/src/monitor.bot-menu.test.ts
Normal file
258
openclaw/extensions/feishu/src/monitor.bot-menu.test.ts
Normal 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
Loading…
Add table
Add a link
Reference in a new issue