From bd1ae9d4119c3f79809fe09fad8ecf0a4c2dc950 Mon Sep 17 00:00:00 2001 From: m1heng <18018422+m1heng@users.noreply.github.com> Date: Fri, 30 Jan 2026 23:56:51 +0800 Subject: [PATCH 1/3] feat(feishu): add Feishu/Lark channel plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Feishu (飞书) channel plugin for enterprise messaging in China/International. Features: - WebSocket and Webhook connection modes - DM and group chat support with @mention gating - Inbound media support (images, files, PDFs) - Card render mode with syntax highlighting - Typing indicator via emoji reactions - Pairing flow for DM approval Co-Authored-By: Claude Opus 4.5 --- docs/channels/feishu.md | 187 +++++++ extensions/feishu/index.ts | 41 ++ extensions/feishu/openclaw.plugin.json | 9 + extensions/feishu/package.json | 14 + extensions/feishu/src/accounts.ts | 53 ++ extensions/feishu/src/bot.ts | 654 ++++++++++++++++++++++ extensions/feishu/src/channel.ts | 224 ++++++++ extensions/feishu/src/client.ts | 66 +++ extensions/feishu/src/config-schema.ts | 107 ++++ extensions/feishu/src/directory.ts | 159 ++++++ extensions/feishu/src/media.ts | 515 +++++++++++++++++ extensions/feishu/src/monitor.ts | 151 +++++ extensions/feishu/src/onboarding.ts | 358 ++++++++++++ extensions/feishu/src/outbound.ts | 40 ++ extensions/feishu/src/policy.ts | 92 +++ extensions/feishu/src/probe.ts | 46 ++ extensions/feishu/src/reactions.ts | 157 ++++++ extensions/feishu/src/reply-dispatcher.ts | 156 ++++++ extensions/feishu/src/runtime.ts | 14 + extensions/feishu/src/send.ts | 308 ++++++++++ extensions/feishu/src/targets.ts | 58 ++ extensions/feishu/src/types.ts | 50 ++ extensions/feishu/src/typing.ts | 73 +++ pnpm-lock.yaml | 49 ++ 24 files changed, 3581 insertions(+) create mode 100644 docs/channels/feishu.md create mode 100644 extensions/feishu/index.ts create mode 100644 extensions/feishu/openclaw.plugin.json create mode 100644 extensions/feishu/package.json create mode 100644 extensions/feishu/src/accounts.ts create mode 100644 extensions/feishu/src/bot.ts create mode 100644 extensions/feishu/src/channel.ts create mode 100644 extensions/feishu/src/client.ts create mode 100644 extensions/feishu/src/config-schema.ts create mode 100644 extensions/feishu/src/directory.ts create mode 100644 extensions/feishu/src/media.ts create mode 100644 extensions/feishu/src/monitor.ts create mode 100644 extensions/feishu/src/onboarding.ts create mode 100644 extensions/feishu/src/outbound.ts create mode 100644 extensions/feishu/src/policy.ts create mode 100644 extensions/feishu/src/probe.ts create mode 100644 extensions/feishu/src/reactions.ts create mode 100644 extensions/feishu/src/reply-dispatcher.ts create mode 100644 extensions/feishu/src/runtime.ts create mode 100644 extensions/feishu/src/send.ts create mode 100644 extensions/feishu/src/targets.ts create mode 100644 extensions/feishu/src/types.ts create mode 100644 extensions/feishu/src/typing.ts diff --git a/docs/channels/feishu.md b/docs/channels/feishu.md new file mode 100644 index 000000000..0da237008 --- /dev/null +++ b/docs/channels/feishu.md @@ -0,0 +1,187 @@ +--- +summary: "Feishu/Lark (飞书) bot support status, capabilities, and configuration" +read_when: + - Working on Feishu features or integration +--- +# Feishu (飞书 / Lark) + +Status: production-ready for bot DMs + groups via WebSocket long connection. + +## Quick setup + +1. Create a self-built app on [Feishu Open Platform](https://open.feishu.cn) (or [Lark Developer](https://open.larksuite.com) for international). +2. Get your App ID and App Secret from the Credentials page. +3. Enable required permissions (see below). +4. Configure event subscriptions (see below). +5. Set the credentials: + +```bash +openclaw config set channels.feishu.appId "cli_xxxxx" +openclaw config set channels.feishu.appSecret "your_app_secret" +openclaw config set channels.feishu.enabled true +``` + +Minimal config: + +```json5 +{ + channels: { + feishu: { + enabled: true, + appId: "cli_xxxxx", + appSecret: "secret", + dmPolicy: "pairing" + } + } +} +``` + +## Required permissions + +| Permission | Scope | Description | +|------------|-------|-------------| +| `contact:user.base:readonly` | User info | Get basic user info (required to resolve sender display names) | +| `im:message` | Messaging | Send and receive messages | +| `im:message.p2p_msg:readonly` | DM | Read direct messages to bot | +| `im:message.group_at_msg:readonly` | Group | Receive @mention messages in groups | +| `im:message:send_as_bot` | Send | Send messages as the bot | +| `im:resource` | Media | Upload and download images/files | + +## Optional permissions + +| Permission | Scope | Description | +|------------|-------|-------------| +| `im:message.group_msg` | Group | Read all group messages (sensitive) | +| `im:message:readonly` | Read | Get message history | +| `im:message:update` | Edit | Update/edit sent messages | +| `im:message:recall` | Recall | Recall sent messages | +| `im:message.reactions:read` | Reactions | View message reactions | + +## Event subscriptions + +In the Feishu Open Platform console, go to **Events & Callbacks**: + +1. **Event configuration**: Select **Long connection** (recommended). +2. **Add event subscriptions**: + +| Event | Description | +|-------|-------------| +| `im.message.receive_v1` | Receive messages (required) | +| `im.message.message_read_v1` | Message read receipts | +| `im.chat.member.bot.added_v1` | Bot added to group | +| `im.chat.member.bot.deleted_v1` | Bot removed from group | + +3. Ensure the event permissions are approved. + +## Configuration options + +```json5 +{ + channels: { + feishu: { + enabled: true, + appId: "cli_xxxxx", + appSecret: "secret", + // Domain: "feishu" (China) or "lark" (International) + domain: "feishu", + // Connection mode: "websocket" (recommended) or "webhook" + connectionMode: "websocket", + // DM policy: "pairing" | "open" | "allowlist" + dmPolicy: "pairing", + // Group policy: "open" | "allowlist" | "disabled" + groupPolicy: "allowlist", + // Require @mention in groups + requireMention: true, + // Max media size in MB (default: 30) + mediaMaxMb: 30, + // Render mode for bot replies: "auto" | "raw" | "card" + renderMode: "auto" + } + } +} +``` + +### Render mode + +| Mode | Description | +|------|-------------| +| `auto` | (Default) Automatically detect: use card for messages with code blocks or tables, plain text otherwise. | +| `raw` | Always send replies as plain text. Markdown tables are converted to ASCII. | +| `card` | Always send replies as interactive cards with full markdown rendering (syntax highlighting, tables, clickable links). | + +### Domain setting + +- `feishu`: China mainland (open.feishu.cn) +- `lark`: International (open.larksuite.com) + +## Features + +- WebSocket and Webhook connection modes +- Direct messages and group chats +- Message replies and quoted message context +- Inbound media support: AI can see images, read files (PDF, Excel, etc.), and process rich text with embedded images +- Image and file uploads (outbound) +- Typing indicator (via emoji reactions) +- Pairing flow for DM approval +- User and group directory lookup +- Card render mode with syntax highlighting + +## Access control + +### DM access + +- Default: `channels.feishu.dmPolicy = "pairing"`. Unknown senders receive a pairing code; messages are ignored until approved. +- Approve via: + - `openclaw pairing list feishu` + - `openclaw pairing approve feishu ` + +### Group access + +- `channels.feishu.groupPolicy`: `open | allowlist | disabled` (default: allowlist) +- `channels.feishu.requireMention`: Require @bot mention in groups (default: true) + +## Troubleshooting + +### Bot cannot receive messages + +Check the following: +1. Have you configured event subscriptions? +2. Is the event configuration set to **long connection**? +3. Did you add the `im.message.receive_v1` event? +4. Are the permissions approved? + +### 403 error when sending messages + +Ensure `im:message:send_as_bot` permission is approved. + +### How to clear history / start new conversation + +Send `/new` command in the chat. + +### Why is the output not streaming + +Feishu API has rate limits. Streaming updates can easily trigger throttling. OpenClaw uses complete-then-send approach for stability. + +### Cannot find the bot in Feishu + +1. Ensure the app is published (at least to test version). +2. Search for the bot name in Feishu search box. +3. Check if your account is in the app's availability scope. + +## Configuration reference + +Full configuration: [Configuration](/gateway/configuration) + +Provider options: +- `channels.feishu.enabled`: enable/disable channel startup. +- `channels.feishu.appId`: Feishu app ID. +- `channels.feishu.appSecret`: Feishu app secret. +- `channels.feishu.domain`: `feishu` (China) or `lark` (International). +- `channels.feishu.connectionMode`: `websocket` (default) or `webhook`. +- `channels.feishu.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing). +- `channels.feishu.allowFrom`: DM allowlist (user IDs). +- `channels.feishu.groupPolicy`: `open | allowlist | disabled` (default: allowlist). +- `channels.feishu.groupAllowFrom`: group sender allowlist. +- `channels.feishu.requireMention`: require @mention in groups (default: true). +- `channels.feishu.renderMode`: `auto | raw | card` (default: auto). +- `channels.feishu.mediaMaxMb`: inbound/outbound media cap (MB, default: 30). diff --git a/extensions/feishu/index.ts b/extensions/feishu/index.ts new file mode 100644 index 000000000..eb46325c1 --- /dev/null +++ b/extensions/feishu/index.ts @@ -0,0 +1,41 @@ +import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; +import { emptyPluginConfigSchema } from "openclaw/plugin-sdk"; +import { feishuPlugin } from "./src/channel.js"; +import { setFeishuRuntime } from "./src/runtime.js"; + +export { monitorFeishuProvider } from "./src/monitor.js"; +export { + sendMessageFeishu, + sendCardFeishu, + updateCardFeishu, + editMessageFeishu, + getMessageFeishu, +} from "./src/send.js"; +export { + uploadImageFeishu, + uploadFileFeishu, + sendImageFeishu, + sendFileFeishu, + sendMediaFeishu, +} from "./src/media.js"; +export { probeFeishu } from "./src/probe.js"; +export { + addReactionFeishu, + removeReactionFeishu, + listReactionsFeishu, + FeishuEmoji, +} from "./src/reactions.js"; +export { feishuPlugin } from "./src/channel.js"; + +const plugin = { + id: "feishu", + name: "Feishu", + description: "Feishu/Lark channel plugin", + configSchema: emptyPluginConfigSchema(), + register(api: OpenClawPluginApi) { + setFeishuRuntime(api.runtime); + api.registerChannel({ plugin: feishuPlugin }); + }, +}; + +export default plugin; diff --git a/extensions/feishu/openclaw.plugin.json b/extensions/feishu/openclaw.plugin.json new file mode 100644 index 000000000..93fb800f4 --- /dev/null +++ b/extensions/feishu/openclaw.plugin.json @@ -0,0 +1,9 @@ +{ + "id": "feishu", + "channels": ["feishu"], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/feishu/package.json b/extensions/feishu/package.json new file mode 100644 index 000000000..2103f2312 --- /dev/null +++ b/extensions/feishu/package.json @@ -0,0 +1,14 @@ +{ + "name": "@openclaw/feishu", + "version": "2026.1.29", + "type": "module", + "description": "OpenClaw Feishu/Lark channel plugin", + "openclaw": { + "extensions": [ + "./index.ts" + ] + }, + "dependencies": { + "@larksuiteoapi/node-sdk": "^1.30.0" + } +} diff --git a/extensions/feishu/src/accounts.ts b/extensions/feishu/src/accounts.ts new file mode 100644 index 000000000..2fbf8a285 --- /dev/null +++ b/extensions/feishu/src/accounts.ts @@ -0,0 +1,53 @@ +import type { ClawdbotConfig } from "openclaw/plugin-sdk"; +import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk"; +import type { FeishuConfig, FeishuDomain, ResolvedFeishuAccount } from "./types.js"; + +export function resolveFeishuCredentials(cfg?: FeishuConfig): { + appId: string; + appSecret: string; + encryptKey?: string; + verificationToken?: string; + domain: FeishuDomain; +} | null { + const appId = cfg?.appId?.trim(); + const appSecret = cfg?.appSecret?.trim(); + if (!appId || !appSecret) return null; + return { + appId, + appSecret, + encryptKey: cfg?.encryptKey?.trim() || undefined, + verificationToken: cfg?.verificationToken?.trim() || undefined, + domain: cfg?.domain ?? "feishu", + }; +} + +export function resolveFeishuAccount(params: { + cfg: ClawdbotConfig; + accountId?: string | null; +}): ResolvedFeishuAccount { + const feishuCfg = params.cfg.channels?.feishu as FeishuConfig | undefined; + const enabled = feishuCfg?.enabled !== false; + const creds = resolveFeishuCredentials(feishuCfg); + + return { + accountId: params.accountId?.trim() || DEFAULT_ACCOUNT_ID, + enabled, + configured: Boolean(creds), + appId: creds?.appId, + domain: creds?.domain ?? "feishu", + }; +} + +export function listFeishuAccountIds(_cfg: ClawdbotConfig): string[] { + return [DEFAULT_ACCOUNT_ID]; +} + +export function resolveDefaultFeishuAccountId(_cfg: ClawdbotConfig): string { + return DEFAULT_ACCOUNT_ID; +} + +export function listEnabledFeishuAccounts(cfg: ClawdbotConfig): ResolvedFeishuAccount[] { + return listFeishuAccountIds(cfg) + .map((accountId) => resolveFeishuAccount({ cfg, accountId })) + .filter((account) => account.enabled && account.configured); +} diff --git a/extensions/feishu/src/bot.ts b/extensions/feishu/src/bot.ts new file mode 100644 index 000000000..f8162d6e0 --- /dev/null +++ b/extensions/feishu/src/bot.ts @@ -0,0 +1,654 @@ +import type { ClawdbotConfig, RuntimeEnv } from "openclaw/plugin-sdk"; +import { + buildPendingHistoryContextFromMap, + recordPendingHistoryEntryIfEnabled, + clearHistoryEntriesIfEnabled, + DEFAULT_GROUP_HISTORY_LIMIT, + type HistoryEntry, +} from "openclaw/plugin-sdk"; +import type { FeishuConfig, FeishuMessageContext, FeishuMediaInfo } from "./types.js"; +import { getFeishuRuntime } from "./runtime.js"; +import { createFeishuClient } from "./client.js"; +import { + resolveFeishuGroupConfig, + resolveFeishuReplyPolicy, + resolveFeishuAllowlistMatch, + isFeishuGroupAllowed, +} from "./policy.js"; +import { createFeishuReplyDispatcher } from "./reply-dispatcher.js"; +import { getMessageFeishu } from "./send.js"; +import { downloadImageFeishu, downloadMessageResourceFeishu } from "./media.js"; + +// --- Sender name resolution (so the agent can distinguish who is speaking in group chats) --- +// Cache display names by open_id to avoid an API call on every message. +const SENDER_NAME_TTL_MS = 10 * 60 * 1000; +const senderNameCache = new Map(); + +async function resolveFeishuSenderName(params: { + feishuCfg?: FeishuConfig; + senderOpenId: string; + log: (...args: any[]) => void; +}): Promise { + const { feishuCfg, senderOpenId, log } = params; + if (!feishuCfg) return undefined; + if (!senderOpenId) return undefined; + + const cached = senderNameCache.get(senderOpenId); + const now = Date.now(); + if (cached && cached.expireAt > now) return cached.name; + + try { + const client = createFeishuClient(feishuCfg); + + // contact/v3/users/:user_id?user_id_type=open_id + const res: any = await client.contact.user.get({ + path: { user_id: senderOpenId }, + params: { user_id_type: "open_id" }, + }); + + const name: string | undefined = + res?.data?.user?.name || + res?.data?.user?.display_name || + res?.data?.user?.nickname || + res?.data?.user?.en_name; + + if (name && typeof name === "string") { + senderNameCache.set(senderOpenId, { name, expireAt: now + SENDER_NAME_TTL_MS }); + return name; + } + + return undefined; + } catch (err) { + // Best-effort. Don't fail message handling if name lookup fails. + log(`feishu: failed to resolve sender name for ${senderOpenId}: ${String(err)}`); + return undefined; + } +} + +export type FeishuMessageEvent = { + sender: { + sender_id: { + open_id?: string; + user_id?: string; + union_id?: string; + }; + sender_type?: string; + tenant_key?: string; + }; + message: { + message_id: string; + root_id?: string; + parent_id?: string; + chat_id: string; + chat_type: "p2p" | "group"; + message_type: string; + content: string; + mentions?: Array<{ + key: string; + id: { + open_id?: string; + user_id?: string; + union_id?: string; + }; + name: string; + tenant_key?: string; + }>; + }; +}; + +export type FeishuBotAddedEvent = { + chat_id: string; + operator_id: { + open_id?: string; + user_id?: string; + union_id?: string; + }; + external: boolean; + operator_tenant_key?: string; +}; + +function parseMessageContent(content: string, messageType: string): string { + try { + const parsed = JSON.parse(content); + if (messageType === "text") { + return parsed.text || ""; + } + if (messageType === "post") { + // Extract text content from rich text post + const { textContent } = parsePostContent(content); + return textContent; + } + return content; + } catch { + return content; + } +} + +function checkBotMentioned(event: FeishuMessageEvent, botOpenId?: string): boolean { + const mentions = event.message.mentions ?? []; + if (mentions.length === 0) return false; + if (!botOpenId) return mentions.length > 0; + return mentions.some((m) => m.id.open_id === botOpenId); +} + +function stripBotMention(text: string, mentions?: FeishuMessageEvent["message"]["mentions"]): string { + if (!mentions || mentions.length === 0) return text; + let result = text; + for (const mention of mentions) { + result = result.replace(new RegExp(`@${mention.name}\\s*`, "g"), "").trim(); + result = result.replace(new RegExp(mention.key, "g"), "").trim(); + } + return result; +} + +/** + * Parse media keys from message content based on message type. + */ +function parseMediaKeys( + content: string, + messageType: string, +): { + imageKey?: string; + fileKey?: string; + fileName?: string; +} { + try { + const parsed = JSON.parse(content); + switch (messageType) { + case "image": + return { imageKey: parsed.image_key }; + case "file": + return { fileKey: parsed.file_key, fileName: parsed.file_name }; + case "audio": + return { fileKey: parsed.file_key }; + case "video": + // Video has both file_key (video) and image_key (thumbnail) + return { fileKey: parsed.file_key, imageKey: parsed.image_key }; + case "sticker": + return { fileKey: parsed.file_key }; + default: + return {}; + } + } catch { + return {}; + } +} + +/** + * Parse post (rich text) content and extract embedded image keys. + * Post structure: { title?: string, content: [[{ tag, text?, image_key?, ... }]] } + */ +function parsePostContent(content: string): { + textContent: string; + imageKeys: string[]; +} { + try { + const parsed = JSON.parse(content); + const title = parsed.title || ""; + const contentBlocks = parsed.content || []; + let textContent = title ? `${title}\n\n` : ""; + const imageKeys: string[] = []; + + for (const paragraph of contentBlocks) { + if (Array.isArray(paragraph)) { + for (const element of paragraph) { + if (element.tag === "text") { + textContent += element.text || ""; + } else if (element.tag === "a") { + // Link: show text or href + textContent += element.text || element.href || ""; + } else if (element.tag === "at") { + // Mention: @username + textContent += `@${element.user_name || element.user_id || ""}`; + } else if (element.tag === "img" && element.image_key) { + // Embedded image + imageKeys.push(element.image_key); + } + } + textContent += "\n"; + } + } + + return { + textContent: textContent.trim() || "[富文本消息]", + imageKeys, + }; + } catch { + return { textContent: "[富文本消息]", imageKeys: [] }; + } +} + +/** + * Infer placeholder text based on message type. + */ +function inferPlaceholder(messageType: string): string { + switch (messageType) { + case "image": + return ""; + case "file": + return ""; + case "audio": + return ""; + case "video": + return ""; + case "sticker": + return ""; + default: + return ""; + } +} + +/** + * Resolve media from a Feishu message, downloading and saving to disk. + * Similar to Discord's resolveMediaList(). + */ +async function resolveFeishuMediaList(params: { + cfg: ClawdbotConfig; + messageId: string; + messageType: string; + content: string; + maxBytes: number; + log?: (msg: string) => void; +}): Promise { + const { cfg, messageId, messageType, content, maxBytes, log } = params; + + // Only process media message types (including post for embedded images) + const mediaTypes = ["image", "file", "audio", "video", "sticker", "post"]; + if (!mediaTypes.includes(messageType)) { + return []; + } + + const out: FeishuMediaInfo[] = []; + const core = getFeishuRuntime(); + + // Handle post (rich text) messages with embedded images + if (messageType === "post") { + const { imageKeys } = parsePostContent(content); + if (imageKeys.length === 0) { + return []; + } + + log?.(`feishu: post message contains ${imageKeys.length} embedded image(s)`); + + for (const imageKey of imageKeys) { + try { + // Embedded images in post use messageResource API with image_key as file_key + const result = await downloadMessageResourceFeishu({ + cfg, + messageId, + fileKey: imageKey, + type: "image", + }); + + let contentType = result.contentType; + if (!contentType) { + contentType = await core.media.detectMime({ buffer: result.buffer }); + } + + const saved = await core.channel.media.saveMediaBuffer( + result.buffer, + contentType, + "inbound", + maxBytes, + ); + + out.push({ + path: saved.path, + contentType: saved.contentType, + placeholder: "", + }); + + log?.(`feishu: downloaded embedded image ${imageKey}, saved to ${saved.path}`); + } catch (err) { + log?.(`feishu: failed to download embedded image ${imageKey}: ${String(err)}`); + } + } + + return out; + } + + // Handle other media types + const mediaKeys = parseMediaKeys(content, messageType); + if (!mediaKeys.imageKey && !mediaKeys.fileKey) { + return []; + } + + try { + let buffer: Buffer; + let contentType: string | undefined; + let fileName: string | undefined; + + // For message media, always use messageResource API + // The image.get API is only for images uploaded via im/v1/images, not for message attachments + const fileKey = mediaKeys.imageKey || mediaKeys.fileKey; + if (!fileKey) { + return []; + } + + const resourceType = messageType === "image" ? "image" : "file"; + const result = await downloadMessageResourceFeishu({ + cfg, + messageId, + fileKey, + type: resourceType, + }); + buffer = result.buffer; + contentType = result.contentType; + fileName = result.fileName || mediaKeys.fileName; + + // Detect mime type if not provided + if (!contentType) { + contentType = await core.media.detectMime({ buffer }); + } + + // Save to disk using core's saveMediaBuffer + const saved = await core.channel.media.saveMediaBuffer( + buffer, + contentType, + "inbound", + maxBytes, + fileName, + ); + + out.push({ + path: saved.path, + contentType: saved.contentType, + placeholder: inferPlaceholder(messageType), + }); + + log?.(`feishu: downloaded ${messageType} media, saved to ${saved.path}`); + } catch (err) { + log?.(`feishu: failed to download ${messageType} media: ${String(err)}`); + } + + return out; +} + +/** + * Build media payload for inbound context. + * Similar to Discord's buildDiscordMediaPayload(). + */ +function buildFeishuMediaPayload( + mediaList: FeishuMediaInfo[], +): { + MediaPath?: string; + MediaType?: string; + MediaUrl?: string; + MediaPaths?: string[]; + MediaUrls?: string[]; + MediaTypes?: string[]; +} { + const first = mediaList[0]; + const mediaPaths = mediaList.map((media) => media.path); + const mediaTypes = mediaList.map((media) => media.contentType).filter(Boolean) as string[]; + return { + MediaPath: first?.path, + MediaType: first?.contentType, + MediaUrl: first?.path, + MediaPaths: mediaPaths.length > 0 ? mediaPaths : undefined, + MediaUrls: mediaPaths.length > 0 ? mediaPaths : undefined, + MediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined, + }; +} + +export function parseFeishuMessageEvent( + event: FeishuMessageEvent, + botOpenId?: string, +): FeishuMessageContext { + const rawContent = parseMessageContent(event.message.content, event.message.message_type); + const mentionedBot = checkBotMentioned(event, botOpenId); + const content = stripBotMention(rawContent, event.message.mentions); + + return { + chatId: event.message.chat_id, + messageId: event.message.message_id, + senderId: event.sender.sender_id.user_id || event.sender.sender_id.open_id || "", + senderOpenId: event.sender.sender_id.open_id || "", + chatType: event.message.chat_type, + mentionedBot, + rootId: event.message.root_id || undefined, + parentId: event.message.parent_id || undefined, + content, + contentType: event.message.message_type, + }; +} + +export async function handleFeishuMessage(params: { + cfg: ClawdbotConfig; + event: FeishuMessageEvent; + botOpenId?: string; + runtime?: RuntimeEnv; + chatHistories?: Map; +}): Promise { + const { cfg, event, botOpenId, runtime, chatHistories } = params; + const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; + const log = runtime?.log ?? console.log; + const error = runtime?.error ?? console.error; + + let ctx = parseFeishuMessageEvent(event, botOpenId); + const isGroup = ctx.chatType === "group"; + + // Resolve sender display name (best-effort) so the agent can attribute messages correctly. + const senderName = await resolveFeishuSenderName({ + feishuCfg, + senderOpenId: ctx.senderOpenId, + log, + }); + if (senderName) ctx = { ...ctx, senderName }; + + log(`feishu: received message from ${ctx.senderOpenId} in ${ctx.chatId} (${ctx.chatType})`); + + const historyLimit = Math.max( + 0, + feishuCfg?.historyLimit ?? cfg.messages?.groupChat?.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT, + ); + + if (isGroup) { + const groupPolicy = feishuCfg?.groupPolicy ?? "open"; + const groupAllowFrom = feishuCfg?.groupAllowFrom ?? []; + const groupConfig = resolveFeishuGroupConfig({ cfg: feishuCfg, groupId: ctx.chatId }); + + const senderAllowFrom = groupConfig?.allowFrom ?? groupAllowFrom; + const allowed = isFeishuGroupAllowed({ + groupPolicy, + allowFrom: senderAllowFrom, + senderId: ctx.senderOpenId, + senderName: ctx.senderName, + }); + + if (!allowed) { + log(`feishu: sender ${ctx.senderOpenId} not in group allowlist`); + return; + } + + const { requireMention } = resolveFeishuReplyPolicy({ + isDirectMessage: false, + globalConfig: feishuCfg, + groupConfig, + }); + + if (requireMention && !ctx.mentionedBot) { + log(`feishu: message in group ${ctx.chatId} did not mention bot, recording to history`); + if (chatHistories) { + recordPendingHistoryEntryIfEnabled({ + historyMap: chatHistories, + historyKey: ctx.chatId, + limit: historyLimit, + entry: { + sender: ctx.senderOpenId, + body: `${ctx.senderName ?? ctx.senderOpenId}: ${ctx.content}`, + timestamp: Date.now(), + messageId: ctx.messageId, + }, + }); + } + return; + } + } else { + const dmPolicy = feishuCfg?.dmPolicy ?? "pairing"; + const allowFrom = feishuCfg?.allowFrom ?? []; + + if (dmPolicy === "allowlist") { + const match = resolveFeishuAllowlistMatch({ + allowFrom, + senderId: ctx.senderOpenId, + }); + if (!match.allowed) { + log(`feishu: sender ${ctx.senderOpenId} not in DM allowlist`); + return; + } + } + } + + try { + const core = getFeishuRuntime(); + + // In group chats, the session is scoped to the group, but the *speaker* is the sender. + // Using a group-scoped From causes the agent to treat different users as the same person. + const feishuFrom = `feishu:${ctx.senderOpenId}`; + const feishuTo = isGroup ? `chat:${ctx.chatId}` : `user:${ctx.senderOpenId}`; + + const route = core.channel.routing.resolveAgentRoute({ + cfg, + channel: "feishu", + peer: { + kind: isGroup ? "group" : "dm", + id: isGroup ? ctx.chatId : ctx.senderOpenId, + }, + }); + + const preview = ctx.content.replace(/\s+/g, " ").slice(0, 160); + const inboundLabel = isGroup + ? `Feishu message in group ${ctx.chatId}` + : `Feishu DM from ${ctx.senderOpenId}`; + + core.system.enqueueSystemEvent(`${inboundLabel}: ${preview}`, { + sessionKey: route.sessionKey, + contextKey: `feishu:message:${ctx.chatId}:${ctx.messageId}`, + }); + + // Resolve media from message + const mediaMaxBytes = (feishuCfg?.mediaMaxMb ?? 30) * 1024 * 1024; // 30MB default + const mediaList = await resolveFeishuMediaList({ + cfg, + messageId: ctx.messageId, + messageType: event.message.message_type, + content: event.message.content, + maxBytes: mediaMaxBytes, + log, + }); + const mediaPayload = buildFeishuMediaPayload(mediaList); + + // Fetch quoted/replied message content if parentId exists + let quotedContent: string | undefined; + if (ctx.parentId) { + try { + const quotedMsg = await getMessageFeishu({ cfg, messageId: ctx.parentId }); + if (quotedMsg) { + quotedContent = quotedMsg.content; + log(`feishu: fetched quoted message: ${quotedContent?.slice(0, 100)}`); + } + } catch (err) { + log(`feishu: failed to fetch quoted message: ${String(err)}`); + } + } + + const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg); + + // Build message body with quoted content if available + let messageBody = ctx.content; + if (quotedContent) { + messageBody = `[Replying to: "${quotedContent}"]\n\n${ctx.content}`; + } + + // Include a readable speaker label so the model can attribute instructions. + // (DMs already have per-sender sessions, but the prefix is still useful for clarity.) + const speaker = ctx.senderName ?? ctx.senderOpenId; + messageBody = `${speaker}: ${messageBody}`; + + const envelopeFrom = isGroup ? `${ctx.chatId}:${ctx.senderOpenId}` : ctx.senderOpenId; + + const body = core.channel.reply.formatAgentEnvelope({ + channel: "Feishu", + from: envelopeFrom, + timestamp: new Date(), + envelope: envelopeOptions, + body: messageBody, + }); + + let combinedBody = body; + const historyKey = isGroup ? ctx.chatId : undefined; + + if (isGroup && historyKey && chatHistories) { + combinedBody = buildPendingHistoryContextFromMap({ + historyMap: chatHistories, + historyKey, + limit: historyLimit, + currentMessage: combinedBody, + formatEntry: (entry) => + core.channel.reply.formatAgentEnvelope({ + channel: "Feishu", + // Preserve speaker identity in group history as well. + from: `${ctx.chatId}:${entry.sender}`, + timestamp: entry.timestamp, + body: entry.body, + envelope: envelopeOptions, + }), + }); + } + + const ctxPayload = core.channel.reply.finalizeInboundContext({ + Body: combinedBody, + RawBody: ctx.content, + CommandBody: ctx.content, + From: feishuFrom, + To: feishuTo, + SessionKey: route.sessionKey, + AccountId: route.accountId, + ChatType: isGroup ? "group" : "direct", + GroupSubject: isGroup ? ctx.chatId : undefined, + SenderName: ctx.senderName ?? ctx.senderOpenId, + SenderId: ctx.senderOpenId, + Provider: "feishu" as const, + Surface: "feishu" as const, + MessageSid: ctx.messageId, + Timestamp: Date.now(), + WasMentioned: ctx.mentionedBot, + CommandAuthorized: true, + OriginatingChannel: "feishu" as const, + OriginatingTo: feishuTo, + ...mediaPayload, + }); + + const { dispatcher, replyOptions, markDispatchIdle } = createFeishuReplyDispatcher({ + cfg, + agentId: route.agentId, + runtime: runtime as RuntimeEnv, + chatId: ctx.chatId, + replyToMessageId: ctx.messageId, + }); + + log(`feishu: dispatching to agent (session=${route.sessionKey})`); + + const { queuedFinal, counts } = await core.channel.reply.dispatchReplyFromConfig({ + ctx: ctxPayload, + cfg, + dispatcher, + replyOptions, + }); + + markDispatchIdle(); + + if (isGroup && historyKey && chatHistories) { + clearHistoryEntriesIfEnabled({ + historyMap: chatHistories, + historyKey, + limit: historyLimit, + }); + } + + log(`feishu: dispatch complete (queuedFinal=${queuedFinal}, replies=${counts.final})`); + } catch (err) { + error(`feishu: failed to dispatch message: ${String(err)}`); + } +} diff --git a/extensions/feishu/src/channel.ts b/extensions/feishu/src/channel.ts new file mode 100644 index 000000000..b97f3ade4 --- /dev/null +++ b/extensions/feishu/src/channel.ts @@ -0,0 +1,224 @@ +import type { ChannelPlugin, ClawdbotConfig } from "openclaw/plugin-sdk"; +import { DEFAULT_ACCOUNT_ID, PAIRING_APPROVED_MESSAGE } from "openclaw/plugin-sdk"; +import type { ResolvedFeishuAccount, FeishuConfig } from "./types.js"; +import { resolveFeishuAccount, resolveFeishuCredentials } from "./accounts.js"; +import { feishuOutbound } from "./outbound.js"; +import { probeFeishu } from "./probe.js"; +import { resolveFeishuGroupToolPolicy } from "./policy.js"; +import { normalizeFeishuTarget, looksLikeFeishuId, formatFeishuTarget } from "./targets.js"; +import { sendMessageFeishu } from "./send.js"; +import { + listFeishuDirectoryPeers, + listFeishuDirectoryGroups, + listFeishuDirectoryPeersLive, + listFeishuDirectoryGroupsLive, +} from "./directory.js"; +import { feishuOnboardingAdapter } from "./onboarding.js"; + +const meta = { + id: "feishu", + label: "Feishu", + selectionLabel: "Feishu/Lark (飞书)", + docsPath: "/channels/feishu", + docsLabel: "feishu", + blurb: "飞书/Lark enterprise messaging.", + aliases: ["lark"], + order: 70, +} as const; + +export const feishuPlugin: ChannelPlugin = { + id: "feishu", + meta: { + ...meta, + }, + pairing: { + idLabel: "feishuUserId", + normalizeAllowEntry: (entry) => entry.replace(/^(feishu|user|open_id):/i, ""), + notifyApproval: async ({ cfg, id }) => { + await sendMessageFeishu({ + cfg, + to: id, + text: PAIRING_APPROVED_MESSAGE, + }); + }, + }, + capabilities: { + chatTypes: ["direct", "channel"], + polls: false, + threads: true, + media: true, + reactions: true, + edit: true, + reply: true, + }, + agentPrompt: { + messageToolHints: () => [ + "- Feishu targeting: omit `target` to reply to the current conversation (auto-inferred). Explicit targets: `user:open_id` or `chat:chat_id`.", + "- Feishu supports interactive cards for rich messages.", + ], + }, + groups: { + resolveToolPolicy: resolveFeishuGroupToolPolicy, + }, + reload: { configPrefixes: ["channels.feishu"] }, + configSchema: { + schema: { + type: "object", + additionalProperties: false, + properties: { + enabled: { type: "boolean" }, + appId: { type: "string" }, + appSecret: { type: "string" }, + encryptKey: { type: "string" }, + verificationToken: { type: "string" }, + domain: { type: "string", enum: ["feishu", "lark"] }, + connectionMode: { type: "string", enum: ["websocket", "webhook"] }, + webhookPath: { type: "string" }, + webhookPort: { type: "integer", minimum: 1 }, + dmPolicy: { type: "string", enum: ["open", "pairing", "allowlist"] }, + allowFrom: { type: "array", items: { oneOf: [{ type: "string" }, { type: "number" }] } }, + groupPolicy: { type: "string", enum: ["open", "allowlist", "disabled"] }, + groupAllowFrom: { type: "array", items: { oneOf: [{ type: "string" }, { type: "number" }] } }, + requireMention: { type: "boolean" }, + historyLimit: { type: "integer", minimum: 0 }, + dmHistoryLimit: { type: "integer", minimum: 0 }, + textChunkLimit: { type: "integer", minimum: 1 }, + chunkMode: { type: "string", enum: ["length", "newline"] }, + mediaMaxMb: { type: "number", minimum: 0 }, + renderMode: { type: "string", enum: ["auto", "raw", "card"] }, + }, + }, + }, + config: { + listAccountIds: () => [DEFAULT_ACCOUNT_ID], + resolveAccount: (cfg) => resolveFeishuAccount({ cfg }), + defaultAccountId: () => DEFAULT_ACCOUNT_ID, + setAccountEnabled: ({ cfg, enabled }) => ({ + ...cfg, + channels: { + ...cfg.channels, + feishu: { + ...cfg.channels?.feishu, + enabled, + }, + }, + }), + deleteAccount: ({ cfg }) => { + const next = { ...cfg } as ClawdbotConfig; + const nextChannels = { ...cfg.channels }; + delete (nextChannels as Record).feishu; + if (Object.keys(nextChannels).length > 0) { + next.channels = nextChannels; + } else { + delete next.channels; + } + return next; + }, + isConfigured: (_account, cfg) => + Boolean(resolveFeishuCredentials(cfg.channels?.feishu as FeishuConfig | undefined)), + describeAccount: (account) => ({ + accountId: account.accountId, + enabled: account.enabled, + configured: account.configured, + }), + resolveAllowFrom: ({ cfg }) => + (cfg.channels?.feishu as FeishuConfig | undefined)?.allowFrom ?? [], + formatAllowFrom: ({ allowFrom }) => + allowFrom + .map((entry) => String(entry).trim()) + .filter(Boolean) + .map((entry) => entry.toLowerCase()), + }, + security: { + collectWarnings: ({ cfg }) => { + const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; + const defaultGroupPolicy = (cfg.channels as Record | undefined)?.defaults?.groupPolicy; + const groupPolicy = feishuCfg?.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + if (groupPolicy !== "open") return []; + return [ + `- Feishu groups: groupPolicy="open" allows any member to trigger (mention-gated). Set channels.feishu.groupPolicy="allowlist" + channels.feishu.groupAllowFrom to restrict senders.`, + ]; + }, + }, + setup: { + resolveAccountId: () => DEFAULT_ACCOUNT_ID, + applyAccountConfig: ({ cfg }) => ({ + ...cfg, + channels: { + ...cfg.channels, + feishu: { + ...cfg.channels?.feishu, + enabled: true, + }, + }, + }), + }, + onboarding: feishuOnboardingAdapter, + messaging: { + normalizeTarget: normalizeFeishuTarget, + targetResolver: { + looksLikeId: looksLikeFeishuId, + hint: "", + }, + }, + directory: { + self: async () => null, + listPeers: async ({ cfg, query, limit }) => + listFeishuDirectoryPeers({ cfg, query, limit }), + listGroups: async ({ cfg, query, limit }) => + listFeishuDirectoryGroups({ cfg, query, limit }), + listPeersLive: async ({ cfg, query, limit }) => + listFeishuDirectoryPeersLive({ cfg, query, limit }), + listGroupsLive: async ({ cfg, query, limit }) => + listFeishuDirectoryGroupsLive({ cfg, query, limit }), + }, + outbound: feishuOutbound, + status: { + defaultRuntime: { + accountId: DEFAULT_ACCOUNT_ID, + running: false, + lastStartAt: null, + lastStopAt: null, + lastError: null, + port: null, + }, + buildChannelSummary: ({ snapshot }) => ({ + configured: snapshot.configured ?? false, + running: snapshot.running ?? false, + lastStartAt: snapshot.lastStartAt ?? null, + lastStopAt: snapshot.lastStopAt ?? null, + lastError: snapshot.lastError ?? null, + port: snapshot.port ?? null, + probe: snapshot.probe, + lastProbeAt: snapshot.lastProbeAt ?? null, + }), + probeAccount: async ({ cfg }) => + await probeFeishu(cfg.channels?.feishu as FeishuConfig | undefined), + buildAccountSnapshot: ({ account, runtime, probe }) => ({ + accountId: account.accountId, + enabled: account.enabled, + configured: account.configured, + running: runtime?.running ?? false, + lastStartAt: runtime?.lastStartAt ?? null, + lastStopAt: runtime?.lastStopAt ?? null, + lastError: runtime?.lastError ?? null, + port: runtime?.port ?? null, + probe, + }), + }, + gateway: { + startAccount: async (ctx) => { + const { monitorFeishuProvider } = await import("./monitor.js"); + const feishuCfg = ctx.cfg.channels?.feishu as FeishuConfig | undefined; + const port = feishuCfg?.webhookPort ?? null; + ctx.setStatus({ accountId: ctx.accountId, port }); + ctx.log?.info(`starting feishu provider (mode: ${feishuCfg?.connectionMode ?? "websocket"})`); + return monitorFeishuProvider({ + config: ctx.cfg, + runtime: ctx.runtime, + abortSignal: ctx.abortSignal, + accountId: ctx.accountId, + }); + }, + }, +}; diff --git a/extensions/feishu/src/client.ts b/extensions/feishu/src/client.ts new file mode 100644 index 000000000..474ffc9e3 --- /dev/null +++ b/extensions/feishu/src/client.ts @@ -0,0 +1,66 @@ +import * as Lark from "@larksuiteoapi/node-sdk"; +import type { FeishuConfig, FeishuDomain } from "./types.js"; +import { resolveFeishuCredentials } from "./accounts.js"; + +let cachedClient: Lark.Client | null = null; +let cachedConfig: { appId: string; appSecret: string; domain: FeishuDomain } | null = null; + +function resolveDomain(domain: FeishuDomain) { + return domain === "lark" ? Lark.Domain.Lark : Lark.Domain.Feishu; +} + +export function createFeishuClient(cfg: FeishuConfig): Lark.Client { + const creds = resolveFeishuCredentials(cfg); + if (!creds) { + throw new Error("Feishu credentials not configured (appId, appSecret required)"); + } + + if ( + cachedClient && + cachedConfig && + cachedConfig.appId === creds.appId && + cachedConfig.appSecret === creds.appSecret && + cachedConfig.domain === creds.domain + ) { + return cachedClient; + } + + const client = new Lark.Client({ + appId: creds.appId, + appSecret: creds.appSecret, + appType: Lark.AppType.SelfBuild, + domain: resolveDomain(creds.domain), + }); + + cachedClient = client; + cachedConfig = { appId: creds.appId, appSecret: creds.appSecret, domain: creds.domain }; + + return client; +} + +export function createFeishuWSClient(cfg: FeishuConfig): Lark.WSClient { + const creds = resolveFeishuCredentials(cfg); + if (!creds) { + throw new Error("Feishu credentials not configured (appId, appSecret required)"); + } + + return new Lark.WSClient({ + appId: creds.appId, + appSecret: creds.appSecret, + domain: resolveDomain(creds.domain), + loggerLevel: Lark.LoggerLevel.info, + }); +} + +export function createEventDispatcher(cfg: FeishuConfig): Lark.EventDispatcher { + const creds = resolveFeishuCredentials(cfg); + return new Lark.EventDispatcher({ + encryptKey: creds?.encryptKey, + verificationToken: creds?.verificationToken, + }); +} + +export function clearClientCache() { + cachedClient = null; + cachedConfig = null; +} diff --git a/extensions/feishu/src/config-schema.ts b/extensions/feishu/src/config-schema.ts new file mode 100644 index 000000000..108aae208 --- /dev/null +++ b/extensions/feishu/src/config-schema.ts @@ -0,0 +1,107 @@ +import { z } from "zod"; +export { z }; + +const DmPolicySchema = z.enum(["open", "pairing", "allowlist"]); +const GroupPolicySchema = z.enum(["open", "allowlist", "disabled"]); +const FeishuDomainSchema = z.enum(["feishu", "lark"]); +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(); + +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(); + +export const FeishuGroupSchema = z + .object({ + requireMention: z.boolean().optional(), + tools: ToolPolicySchema, + skills: z.array(z.string()).optional(), + enabled: z.boolean().optional(), + allowFrom: z.array(z.union([z.string(), z.number()])).optional(), + systemPrompt: z.string().optional(), + }) + .strict(); + +export const FeishuConfigSchema = z + .object({ + enabled: z.boolean().optional(), + appId: z.string().optional(), + appSecret: z.string().optional(), + encryptKey: z.string().optional(), + verificationToken: z.string().optional(), + domain: FeishuDomainSchema.optional().default("feishu"), + connectionMode: FeishuConnectionModeSchema.optional().default("websocket"), + webhookPath: z.string().optional().default("/feishu/events"), + webhookPort: z.number().int().positive().optional(), + capabilities: z.array(z.string()).optional(), + markdown: MarkdownConfigSchema, + configWrites: z.boolean().optional(), + dmPolicy: DmPolicySchema.optional().default("pairing"), + allowFrom: z.array(z.union([z.string(), z.number()])).optional(), + groupPolicy: GroupPolicySchema.optional().default("allowlist"), + groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(), + requireMention: z.boolean().optional().default(true), + groups: z.record(z.string(), FeishuGroupSchema.optional()).optional(), + historyLimit: z.number().int().min(0).optional(), + dmHistoryLimit: z.number().int().min(0).optional(), + dms: z.record(z.string(), DmConfigSchema).optional(), + textChunkLimit: z.number().int().positive().optional(), + chunkMode: z.enum(["length", "newline"]).optional(), + blockStreamingCoalesce: BlockStreamingCoalesceSchema, + mediaMaxMb: z.number().positive().optional(), + heartbeat: ChannelHeartbeatVisibilitySchema, + renderMode: RenderModeSchema, // raw = plain text (default), card = interactive card with markdown + }) + .strict() + .superRefine((value, ctx) => { + if (value.dmPolicy === "open") { + const allowFrom = value.allowFrom ?? []; + const hasWildcard = allowFrom.some((entry) => String(entry).trim() === "*"); + if (!hasWildcard) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["allowFrom"], + message: 'channels.feishu.dmPolicy="open" requires channels.feishu.allowFrom to include "*"', + }); + } + } + }); diff --git a/extensions/feishu/src/directory.ts b/extensions/feishu/src/directory.ts new file mode 100644 index 000000000..77b61e4fe --- /dev/null +++ b/extensions/feishu/src/directory.ts @@ -0,0 +1,159 @@ +import type { ClawdbotConfig } from "openclaw/plugin-sdk"; +import type { FeishuConfig } from "./types.js"; +import { createFeishuClient } from "./client.js"; +import { normalizeFeishuTarget } from "./targets.js"; + +export type FeishuDirectoryPeer = { + kind: "user"; + id: string; + name?: string; +}; + +export type FeishuDirectoryGroup = { + kind: "group"; + id: string; + name?: string; +}; + +export async function listFeishuDirectoryPeers(params: { + cfg: ClawdbotConfig; + query?: string; + limit?: number; +}): Promise { + const feishuCfg = params.cfg.channels?.feishu as FeishuConfig | undefined; + const q = params.query?.trim().toLowerCase() || ""; + const ids = new Set(); + + for (const entry of feishuCfg?.allowFrom ?? []) { + const trimmed = String(entry).trim(); + if (trimmed && trimmed !== "*") ids.add(trimmed); + } + + for (const userId of Object.keys(feishuCfg?.dms ?? {})) { + const trimmed = userId.trim(); + if (trimmed) ids.add(trimmed); + } + + return Array.from(ids) + .map((raw) => raw.trim()) + .filter(Boolean) + .map((raw) => normalizeFeishuTarget(raw) ?? raw) + .filter((id) => (q ? id.toLowerCase().includes(q) : true)) + .slice(0, params.limit && params.limit > 0 ? params.limit : undefined) + .map((id) => ({ kind: "user" as const, id })); +} + +export async function listFeishuDirectoryGroups(params: { + cfg: ClawdbotConfig; + query?: string; + limit?: number; +}): Promise { + const feishuCfg = params.cfg.channels?.feishu as FeishuConfig | undefined; + const q = params.query?.trim().toLowerCase() || ""; + const ids = new Set(); + + for (const groupId of Object.keys(feishuCfg?.groups ?? {})) { + const trimmed = groupId.trim(); + if (trimmed && trimmed !== "*") ids.add(trimmed); + } + + for (const entry of feishuCfg?.groupAllowFrom ?? []) { + const trimmed = String(entry).trim(); + if (trimmed && trimmed !== "*") ids.add(trimmed); + } + + return Array.from(ids) + .map((raw) => raw.trim()) + .filter(Boolean) + .filter((id) => (q ? id.toLowerCase().includes(q) : true)) + .slice(0, params.limit && params.limit > 0 ? params.limit : undefined) + .map((id) => ({ kind: "group" as const, id })); +} + +export async function listFeishuDirectoryPeersLive(params: { + cfg: ClawdbotConfig; + query?: string; + limit?: number; +}): Promise { + const feishuCfg = params.cfg.channels?.feishu as FeishuConfig | undefined; + if (!feishuCfg?.appId || !feishuCfg?.appSecret) { + return listFeishuDirectoryPeers(params); + } + + try { + const client = createFeishuClient(feishuCfg); + const peers: FeishuDirectoryPeer[] = []; + const limit = params.limit ?? 50; + + const response = await client.contact.user.list({ + params: { + page_size: Math.min(limit, 50), + }, + }); + + if (response.code === 0 && response.data?.items) { + for (const user of response.data.items) { + if (user.open_id) { + const q = params.query?.trim().toLowerCase() || ""; + const name = user.name || ""; + if (!q || user.open_id.toLowerCase().includes(q) || name.toLowerCase().includes(q)) { + peers.push({ + kind: "user", + id: user.open_id, + name: name || undefined, + }); + } + } + if (peers.length >= limit) break; + } + } + + return peers; + } catch { + return listFeishuDirectoryPeers(params); + } +} + +export async function listFeishuDirectoryGroupsLive(params: { + cfg: ClawdbotConfig; + query?: string; + limit?: number; +}): Promise { + const feishuCfg = params.cfg.channels?.feishu as FeishuConfig | undefined; + if (!feishuCfg?.appId || !feishuCfg?.appSecret) { + return listFeishuDirectoryGroups(params); + } + + try { + const client = createFeishuClient(feishuCfg); + const groups: FeishuDirectoryGroup[] = []; + const limit = params.limit ?? 50; + + const response = await client.im.chat.list({ + params: { + page_size: Math.min(limit, 100), + }, + }); + + if (response.code === 0 && response.data?.items) { + for (const chat of response.data.items) { + if (chat.chat_id) { + const q = params.query?.trim().toLowerCase() || ""; + const name = chat.name || ""; + if (!q || chat.chat_id.toLowerCase().includes(q) || name.toLowerCase().includes(q)) { + groups.push({ + kind: "group", + id: chat.chat_id, + name: name || undefined, + }); + } + } + if (groups.length >= limit) break; + } + } + + return groups; + } catch { + return listFeishuDirectoryGroups(params); + } +} diff --git a/extensions/feishu/src/media.ts b/extensions/feishu/src/media.ts new file mode 100644 index 000000000..8b376b046 --- /dev/null +++ b/extensions/feishu/src/media.ts @@ -0,0 +1,515 @@ +import type { ClawdbotConfig } from "openclaw/plugin-sdk"; +import type { FeishuConfig } from "./types.js"; +import { createFeishuClient } from "./client.js"; +import { resolveReceiveIdType, normalizeFeishuTarget } from "./targets.js"; +import fs from "fs"; +import path from "path"; +import os from "os"; +import { Readable } from "stream"; + +export type DownloadImageResult = { + buffer: Buffer; + contentType?: string; +}; + +export type DownloadMessageResourceResult = { + buffer: Buffer; + contentType?: string; + fileName?: string; +}; + +/** + * Download an image from Feishu using image_key. + * Used for downloading images sent in messages. + */ +export async function downloadImageFeishu(params: { + cfg: ClawdbotConfig; + imageKey: string; +}): Promise { + const { cfg, imageKey } = params; + const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; + if (!feishuCfg) { + throw new Error("Feishu channel not configured"); + } + + const client = createFeishuClient(feishuCfg); + + const response = await client.im.image.get({ + path: { image_key: imageKey }, + }); + + const responseAny = response as any; + if (responseAny.code !== undefined && responseAny.code !== 0) { + throw new Error(`Feishu image download failed: ${responseAny.msg || `code ${responseAny.code}`}`); + } + + // Handle various response formats from Feishu SDK + let buffer: Buffer; + + if (Buffer.isBuffer(response)) { + buffer = response; + } else if (response instanceof ArrayBuffer) { + buffer = Buffer.from(response); + } else if (responseAny.data && Buffer.isBuffer(responseAny.data)) { + buffer = responseAny.data; + } else if (responseAny.data instanceof ArrayBuffer) { + buffer = Buffer.from(responseAny.data); + } else if (typeof responseAny.getReadableStream === "function") { + // SDK provides getReadableStream method + const stream = responseAny.getReadableStream(); + const chunks: Buffer[] = []; + for await (const chunk of stream) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + buffer = Buffer.concat(chunks); + } else if (typeof responseAny.writeFile === "function") { + // SDK provides writeFile method - use a temp file + const tmpPath = path.join(os.tmpdir(), `feishu_img_${Date.now()}_${imageKey}`); + await responseAny.writeFile(tmpPath); + buffer = await fs.promises.readFile(tmpPath); + await fs.promises.unlink(tmpPath).catch(() => {}); // cleanup + } else if (typeof responseAny[Symbol.asyncIterator] === "function") { + // Response is an async iterable + const chunks: Buffer[] = []; + for await (const chunk of responseAny) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + buffer = Buffer.concat(chunks); + } else if (typeof responseAny.read === "function") { + // Response is a Readable stream + const chunks: Buffer[] = []; + for await (const chunk of responseAny as Readable) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + buffer = Buffer.concat(chunks); + } else { + // Debug: log what we actually received + const keys = Object.keys(responseAny); + const types = keys.map(k => `${k}: ${typeof responseAny[k]}`).join(", "); + throw new Error( + `Feishu image download failed: unexpected response format. Keys: [${types}]`, + ); + } + + return { buffer }; +} + +/** + * Download a message resource (file/image/audio/video) from Feishu. + * Used for downloading files, audio, and video from messages. + */ +export async function downloadMessageResourceFeishu(params: { + cfg: ClawdbotConfig; + messageId: string; + fileKey: string; + type: "image" | "file"; +}): Promise { + const { cfg, messageId, fileKey, type } = params; + const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; + if (!feishuCfg) { + throw new Error("Feishu channel not configured"); + } + + const client = createFeishuClient(feishuCfg); + + const response = await client.im.messageResource.get({ + path: { message_id: messageId, file_key: fileKey }, + params: { type }, + }); + + const responseAny = response as any; + if (responseAny.code !== undefined && responseAny.code !== 0) { + throw new Error( + `Feishu message resource download failed: ${responseAny.msg || `code ${responseAny.code}`}`, + ); + } + + // Handle various response formats from Feishu SDK + let buffer: Buffer; + + if (Buffer.isBuffer(response)) { + buffer = response; + } else if (response instanceof ArrayBuffer) { + buffer = Buffer.from(response); + } else if (responseAny.data && Buffer.isBuffer(responseAny.data)) { + buffer = responseAny.data; + } else if (responseAny.data instanceof ArrayBuffer) { + buffer = Buffer.from(responseAny.data); + } else if (typeof responseAny.getReadableStream === "function") { + // SDK provides getReadableStream method + const stream = responseAny.getReadableStream(); + const chunks: Buffer[] = []; + for await (const chunk of stream) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + buffer = Buffer.concat(chunks); + } else if (typeof responseAny.writeFile === "function") { + // SDK provides writeFile method - use a temp file + const tmpPath = path.join(os.tmpdir(), `feishu_${Date.now()}_${fileKey}`); + await responseAny.writeFile(tmpPath); + buffer = await fs.promises.readFile(tmpPath); + await fs.promises.unlink(tmpPath).catch(() => {}); // cleanup + } else if (typeof responseAny[Symbol.asyncIterator] === "function") { + // Response is an async iterable + const chunks: Buffer[] = []; + for await (const chunk of responseAny) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + buffer = Buffer.concat(chunks); + } else if (typeof responseAny.read === "function") { + // Response is a Readable stream + const chunks: Buffer[] = []; + for await (const chunk of responseAny as Readable) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + buffer = Buffer.concat(chunks); + } else { + // Debug: log what we actually received + const keys = Object.keys(responseAny); + const types = keys.map(k => `${k}: ${typeof responseAny[k]}`).join(", "); + throw new Error( + `Feishu message resource download failed: unexpected response format. Keys: [${types}]`, + ); + } + + return { buffer }; +} + +export type UploadImageResult = { + imageKey: string; +}; + +export type UploadFileResult = { + fileKey: string; +}; + +export type SendMediaResult = { + messageId: string; + chatId: string; +}; + +/** + * Upload an image to Feishu and get an image_key for sending. + * Supports: JPEG, PNG, WEBP, GIF, TIFF, BMP, ICO + */ +export async function uploadImageFeishu(params: { + cfg: ClawdbotConfig; + image: Buffer | string; // Buffer or file path + imageType?: "message" | "avatar"; +}): Promise { + const { cfg, image, imageType = "message" } = params; + const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; + if (!feishuCfg) { + throw new Error("Feishu channel not configured"); + } + + const client = createFeishuClient(feishuCfg); + + // SDK expects a Readable stream, not a Buffer + // Use type assertion since SDK actually accepts any Readable at runtime + const imageStream = + typeof image === "string" ? fs.createReadStream(image) : Readable.from(image); + + const response = await client.im.image.create({ + data: { + image_type: imageType, + image: imageStream as any, + }, + }); + + // SDK v1.30+ returns data directly without code wrapper on success + // On error, it throws or returns { code, msg } + const responseAny = response as any; + if (responseAny.code !== undefined && responseAny.code !== 0) { + throw new Error(`Feishu image upload failed: ${responseAny.msg || `code ${responseAny.code}`}`); + } + + const imageKey = responseAny.image_key ?? responseAny.data?.image_key; + if (!imageKey) { + throw new Error("Feishu image upload failed: no image_key returned"); + } + + return { imageKey }; +} + +/** + * Upload a file to Feishu and get a file_key for sending. + * Max file size: 30MB + */ +export async function uploadFileFeishu(params: { + cfg: ClawdbotConfig; + file: Buffer | string; // Buffer or file path + fileName: string; + fileType: "opus" | "mp4" | "pdf" | "doc" | "xls" | "ppt" | "stream"; + duration?: number; // Required for audio/video files, in milliseconds +}): Promise { + const { cfg, file, fileName, fileType, duration } = params; + const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; + if (!feishuCfg) { + throw new Error("Feishu channel not configured"); + } + + const client = createFeishuClient(feishuCfg); + + // SDK expects a Readable stream, not a Buffer + // Use type assertion since SDK actually accepts any Readable at runtime + const fileStream = + typeof file === "string" ? fs.createReadStream(file) : Readable.from(file); + + const response = await client.im.file.create({ + data: { + file_type: fileType, + file_name: fileName, + file: fileStream as any, + ...(duration !== undefined && { duration }), + }, + }); + + // SDK v1.30+ returns data directly without code wrapper on success + const responseAny = response as any; + if (responseAny.code !== undefined && responseAny.code !== 0) { + throw new Error(`Feishu file upload failed: ${responseAny.msg || `code ${responseAny.code}`}`); + } + + const fileKey = responseAny.file_key ?? responseAny.data?.file_key; + if (!fileKey) { + throw new Error("Feishu file upload failed: no file_key returned"); + } + + return { fileKey }; +} + +/** + * Send an image message using an image_key + */ +export async function sendImageFeishu(params: { + cfg: ClawdbotConfig; + to: string; + imageKey: string; + replyToMessageId?: string; +}): Promise { + const { cfg, to, imageKey, replyToMessageId } = params; + const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; + if (!feishuCfg) { + throw new Error("Feishu channel not configured"); + } + + const client = createFeishuClient(feishuCfg); + const receiveId = normalizeFeishuTarget(to); + if (!receiveId) { + throw new Error(`Invalid Feishu target: ${to}`); + } + + const receiveIdType = resolveReceiveIdType(receiveId); + const content = JSON.stringify({ image_key: imageKey }); + + if (replyToMessageId) { + const response = await client.im.message.reply({ + path: { message_id: replyToMessageId }, + data: { + content, + msg_type: "image", + }, + }); + + if (response.code !== 0) { + throw new Error(`Feishu image reply failed: ${response.msg || `code ${response.code}`}`); + } + + return { + messageId: response.data?.message_id ?? "unknown", + chatId: receiveId, + }; + } + + const response = await client.im.message.create({ + params: { receive_id_type: receiveIdType }, + data: { + receive_id: receiveId, + content, + msg_type: "image", + }, + }); + + if (response.code !== 0) { + throw new Error(`Feishu image send failed: ${response.msg || `code ${response.code}`}`); + } + + return { + messageId: response.data?.message_id ?? "unknown", + chatId: receiveId, + }; +} + +/** + * Send a file message using a file_key + */ +export async function sendFileFeishu(params: { + cfg: ClawdbotConfig; + to: string; + fileKey: string; + replyToMessageId?: string; +}): Promise { + const { cfg, to, fileKey, replyToMessageId } = params; + const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; + if (!feishuCfg) { + throw new Error("Feishu channel not configured"); + } + + const client = createFeishuClient(feishuCfg); + const receiveId = normalizeFeishuTarget(to); + if (!receiveId) { + throw new Error(`Invalid Feishu target: ${to}`); + } + + const receiveIdType = resolveReceiveIdType(receiveId); + const content = JSON.stringify({ file_key: fileKey }); + + if (replyToMessageId) { + const response = await client.im.message.reply({ + path: { message_id: replyToMessageId }, + data: { + content, + msg_type: "file", + }, + }); + + if (response.code !== 0) { + throw new Error(`Feishu file reply failed: ${response.msg || `code ${response.code}`}`); + } + + return { + messageId: response.data?.message_id ?? "unknown", + chatId: receiveId, + }; + } + + const response = await client.im.message.create({ + params: { receive_id_type: receiveIdType }, + data: { + receive_id: receiveId, + content, + msg_type: "file", + }, + }); + + if (response.code !== 0) { + throw new Error(`Feishu file send failed: ${response.msg || `code ${response.code}`}`); + } + + return { + messageId: response.data?.message_id ?? "unknown", + chatId: receiveId, + }; +} + +/** + * Helper to detect file type from extension + */ +export function detectFileType( + fileName: string, +): "opus" | "mp4" | "pdf" | "doc" | "xls" | "ppt" | "stream" { + const ext = path.extname(fileName).toLowerCase(); + switch (ext) { + case ".opus": + case ".ogg": + return "opus"; + case ".mp4": + case ".mov": + case ".avi": + return "mp4"; + case ".pdf": + return "pdf"; + case ".doc": + case ".docx": + return "doc"; + case ".xls": + case ".xlsx": + return "xls"; + case ".ppt": + case ".pptx": + return "ppt"; + default: + return "stream"; + } +} + +/** + * Check if a string is a local file path (not a URL) + */ +function isLocalPath(urlOrPath: string): boolean { + // Starts with / or ~ or drive letter (Windows) + if (urlOrPath.startsWith("/") || urlOrPath.startsWith("~") || /^[a-zA-Z]:/.test(urlOrPath)) { + return true; + } + // Try to parse as URL - if it fails or has no protocol, it's likely a local path + try { + const url = new URL(urlOrPath); + return url.protocol === "file:"; + } catch { + return true; // Not a valid URL, treat as local path + } +} + +/** + * Upload and send media (image or file) from URL, local path, or buffer + */ +export async function sendMediaFeishu(params: { + cfg: ClawdbotConfig; + to: string; + mediaUrl?: string; + mediaBuffer?: Buffer; + fileName?: string; + replyToMessageId?: string; +}): Promise { + const { cfg, to, mediaUrl, mediaBuffer, fileName, replyToMessageId } = params; + + let buffer: Buffer; + let name: string; + + if (mediaBuffer) { + buffer = mediaBuffer; + name = fileName ?? "file"; + } else if (mediaUrl) { + if (isLocalPath(mediaUrl)) { + // Local file path - read directly + const filePath = mediaUrl.startsWith("~") + ? mediaUrl.replace("~", process.env.HOME ?? "") + : mediaUrl.replace("file://", ""); + + if (!fs.existsSync(filePath)) { + throw new Error(`Local file not found: ${filePath}`); + } + buffer = fs.readFileSync(filePath); + name = fileName ?? path.basename(filePath); + } else { + // Remote URL - fetch + const response = await fetch(mediaUrl); + if (!response.ok) { + throw new Error(`Failed to fetch media from URL: ${response.status}`); + } + buffer = Buffer.from(await response.arrayBuffer()); + name = fileName ?? (path.basename(new URL(mediaUrl).pathname) || "file"); + } + } else { + throw new Error("Either mediaUrl or mediaBuffer must be provided"); + } + + // Determine if it's an image based on extension + const ext = path.extname(name).toLowerCase(); + const isImage = [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".ico", ".tiff"].includes(ext); + + if (isImage) { + const { imageKey } = await uploadImageFeishu({ cfg, image: buffer }); + return sendImageFeishu({ cfg, to, imageKey, replyToMessageId }); + } else { + const fileType = detectFileType(name); + const { fileKey } = await uploadFileFeishu({ + cfg, + file: buffer, + fileName: name, + fileType, + }); + return sendFileFeishu({ cfg, to, fileKey, replyToMessageId }); + } +} diff --git a/extensions/feishu/src/monitor.ts b/extensions/feishu/src/monitor.ts new file mode 100644 index 000000000..324a525ce --- /dev/null +++ b/extensions/feishu/src/monitor.ts @@ -0,0 +1,151 @@ +import * as Lark from "@larksuiteoapi/node-sdk"; +import type { ClawdbotConfig, RuntimeEnv, HistoryEntry } from "openclaw/plugin-sdk"; +import type { FeishuConfig } from "./types.js"; +import { createFeishuWSClient, createEventDispatcher } from "./client.js"; +import { resolveFeishuCredentials } from "./accounts.js"; +import { handleFeishuMessage, type FeishuMessageEvent, type FeishuBotAddedEvent } from "./bot.js"; +import { probeFeishu } from "./probe.js"; + +export type MonitorFeishuOpts = { + config?: ClawdbotConfig; + runtime?: RuntimeEnv; + abortSignal?: AbortSignal; + accountId?: string; +}; + +let currentWsClient: Lark.WSClient | null = null; +let botOpenId: string | undefined; + +async function fetchBotOpenId(cfg: FeishuConfig): Promise { + try { + const result = await probeFeishu(cfg); + return result.ok ? result.botOpenId : undefined; + } catch { + return undefined; + } +} + +export async function monitorFeishuProvider(opts: MonitorFeishuOpts = {}): Promise { + const cfg = opts.config; + if (!cfg) { + throw new Error("Config is required for Feishu monitor"); + } + + const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; + const creds = resolveFeishuCredentials(feishuCfg); + if (!creds) { + throw new Error("Feishu credentials not configured (appId, appSecret required)"); + } + + const log = opts.runtime?.log ?? console.log; + const error = opts.runtime?.error ?? console.error; + + if (feishuCfg) { + botOpenId = await fetchBotOpenId(feishuCfg); + log(`feishu: bot open_id resolved: ${botOpenId ?? "unknown"}`); + } + + const connectionMode = feishuCfg?.connectionMode ?? "websocket"; + + if (connectionMode === "websocket") { + return monitorWebSocket({ cfg, feishuCfg: feishuCfg!, runtime: opts.runtime, abortSignal: opts.abortSignal }); + } + + log("feishu: webhook mode not implemented in monitor, use HTTP server directly"); +} + +async function monitorWebSocket(params: { + cfg: ClawdbotConfig; + feishuCfg: FeishuConfig; + runtime?: RuntimeEnv; + abortSignal?: AbortSignal; +}): Promise { + const { cfg, feishuCfg, runtime, abortSignal } = params; + const log = runtime?.log ?? console.log; + const error = runtime?.error ?? console.error; + + log("feishu: starting WebSocket connection..."); + + const wsClient = createFeishuWSClient(feishuCfg); + currentWsClient = wsClient; + + const chatHistories = new Map(); + + const eventDispatcher = createEventDispatcher(feishuCfg); + + eventDispatcher.register({ + "im.message.receive_v1": async (data) => { + try { + const event = data as unknown as FeishuMessageEvent; + await handleFeishuMessage({ + cfg, + event, + botOpenId, + runtime, + chatHistories, + }); + } catch (err) { + error(`feishu: error handling message event: ${String(err)}`); + } + }, + "im.message.message_read_v1": async () => { + // Ignore read receipts + }, + "im.chat.member.bot.added_v1": async (data) => { + try { + const event = data as unknown as FeishuBotAddedEvent; + log(`feishu: bot added to chat ${event.chat_id}`); + } catch (err) { + error(`feishu: error handling bot added event: ${String(err)}`); + } + }, + "im.chat.member.bot.deleted_v1": async (data) => { + try { + const event = data as unknown as { chat_id: string }; + log(`feishu: bot removed from chat ${event.chat_id}`); + } catch (err) { + error(`feishu: error handling bot removed event: ${String(err)}`); + } + }, + }); + + return new Promise((resolve, reject) => { + const cleanup = () => { + if (currentWsClient === wsClient) { + currentWsClient = null; + } + }; + + const handleAbort = () => { + log("feishu: abort signal received, stopping WebSocket client"); + cleanup(); + resolve(); + }; + + if (abortSignal?.aborted) { + cleanup(); + resolve(); + return; + } + + abortSignal?.addEventListener("abort", handleAbort, { once: true }); + + try { + wsClient.start({ + eventDispatcher, + }); + + log("feishu: WebSocket client started"); + } catch (err) { + cleanup(); + abortSignal?.removeEventListener("abort", handleAbort); + reject(err); + } + }); +} + +export function stopFeishuMonitor(): void { + if (currentWsClient) { + currentWsClient = null; + } +} diff --git a/extensions/feishu/src/onboarding.ts b/extensions/feishu/src/onboarding.ts new file mode 100644 index 000000000..eb013bcee --- /dev/null +++ b/extensions/feishu/src/onboarding.ts @@ -0,0 +1,358 @@ +import type { + ChannelOnboardingAdapter, + ChannelOnboardingDmPolicy, + ClawdbotConfig, + DmPolicy, + WizardPrompter, +} from "openclaw/plugin-sdk"; +import { addWildcardAllowFrom, DEFAULT_ACCOUNT_ID, formatDocsLink } from "openclaw/plugin-sdk"; + +import { resolveFeishuCredentials } from "./accounts.js"; +import { probeFeishu } from "./probe.js"; +import type { FeishuConfig } from "./types.js"; + +const channel = "feishu" as const; + +function setFeishuDmPolicy(cfg: ClawdbotConfig, dmPolicy: DmPolicy): ClawdbotConfig { + const allowFrom = + dmPolicy === "open" + ? addWildcardAllowFrom(cfg.channels?.feishu?.allowFrom)?.map((entry) => String(entry)) + : undefined; + return { + ...cfg, + channels: { + ...cfg.channels, + feishu: { + ...cfg.channels?.feishu, + dmPolicy, + ...(allowFrom ? { allowFrom } : {}), + }, + }, + }; +} + +function setFeishuAllowFrom(cfg: ClawdbotConfig, allowFrom: string[]): ClawdbotConfig { + return { + ...cfg, + channels: { + ...cfg.channels, + feishu: { + ...cfg.channels?.feishu, + allowFrom, + }, + }, + }; +} + +function parseAllowFromInput(raw: string): string[] { + return raw + .split(/[\n,;]+/g) + .map((entry) => entry.trim()) + .filter(Boolean); +} + +async function promptFeishuAllowFrom(params: { + cfg: ClawdbotConfig; + prompter: WizardPrompter; +}): Promise { + const existing = params.cfg.channels?.feishu?.allowFrom ?? []; + await params.prompter.note( + [ + "Allowlist Feishu DMs by open_id or user_id.", + "You can find user open_id in Feishu admin console or via API.", + "Examples:", + "- ou_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "- on_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + ].join("\n"), + "Feishu allowlist", + ); + + while (true) { + const entry = await params.prompter.text({ + message: "Feishu allowFrom (user open_ids)", + placeholder: "ou_xxxxx, ou_yyyyy", + initialValue: existing[0] ? String(existing[0]) : undefined, + validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), + }); + const parts = parseAllowFromInput(String(entry)); + if (parts.length === 0) { + await params.prompter.note("Enter at least one user.", "Feishu allowlist"); + continue; + } + + const unique = [ + ...new Set([...existing.map((v) => String(v).trim()).filter(Boolean), ...parts]), + ]; + return setFeishuAllowFrom(params.cfg, unique); + } +} + +async function noteFeishuCredentialHelp(prompter: WizardPrompter): Promise { + await prompter.note( + [ + "1) Go to Feishu Open Platform (open.feishu.cn)", + "2) Create a self-built app", + "3) Get App ID and App Secret from Credentials page", + "4) Enable required permissions: im:message, im:chat, contact:user.base:readonly", + "5) Publish the app or add it to a test group", + "Tip: you can also set FEISHU_APP_ID / FEISHU_APP_SECRET env vars.", + `Docs: ${formatDocsLink("/channels/feishu", "feishu")}`, + ].join("\n"), + "Feishu credentials", + ); +} + +function setFeishuGroupPolicy( + cfg: ClawdbotConfig, + groupPolicy: "open" | "allowlist" | "disabled", +): ClawdbotConfig { + return { + ...cfg, + channels: { + ...cfg.channels, + feishu: { + ...cfg.channels?.feishu, + enabled: true, + groupPolicy, + }, + }, + }; +} + +function setFeishuGroupAllowFrom(cfg: ClawdbotConfig, groupAllowFrom: string[]): ClawdbotConfig { + return { + ...cfg, + channels: { + ...cfg.channels, + feishu: { + ...cfg.channels?.feishu, + groupAllowFrom, + }, + }, + }; +} + +const dmPolicy: ChannelOnboardingDmPolicy = { + label: "Feishu", + channel, + policyKey: "channels.feishu.dmPolicy", + allowFromKey: "channels.feishu.allowFrom", + getCurrent: (cfg) => (cfg.channels?.feishu as FeishuConfig | undefined)?.dmPolicy ?? "pairing", + setPolicy: (cfg, policy) => setFeishuDmPolicy(cfg, policy), + promptAllowFrom: promptFeishuAllowFrom, +}; + +export const feishuOnboardingAdapter: ChannelOnboardingAdapter = { + channel, + getStatus: async ({ cfg }) => { + const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; + const configured = Boolean(resolveFeishuCredentials(feishuCfg)); + + // Try to probe if configured + let probeResult = null; + if (configured && feishuCfg) { + try { + probeResult = await probeFeishu(feishuCfg); + } catch { + // Ignore probe errors + } + } + + const statusLines: string[] = []; + if (!configured) { + statusLines.push("Feishu: needs app credentials"); + } else if (probeResult?.ok) { + statusLines.push(`Feishu: connected as ${probeResult.botName ?? probeResult.botOpenId ?? "bot"}`); + } else { + statusLines.push("Feishu: configured (connection not verified)"); + } + + return { + channel, + configured, + statusLines, + selectionHint: configured ? "configured" : "needs app creds", + quickstartScore: configured ? 2 : 0, + }; + }, + + configure: async ({ cfg, prompter }) => { + const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; + const resolved = resolveFeishuCredentials(feishuCfg); + const hasConfigCreds = Boolean(feishuCfg?.appId?.trim() && feishuCfg?.appSecret?.trim()); + const canUseEnv = Boolean( + !hasConfigCreds && + process.env.FEISHU_APP_ID?.trim() && + process.env.FEISHU_APP_SECRET?.trim(), + ); + + let next = cfg; + let appId: string | null = null; + let appSecret: string | null = null; + + if (!resolved) { + await noteFeishuCredentialHelp(prompter); + } + + if (canUseEnv) { + const keepEnv = await prompter.confirm({ + message: "FEISHU_APP_ID + FEISHU_APP_SECRET detected. Use env vars?", + initialValue: true, + }); + if (keepEnv) { + next = { + ...next, + channels: { + ...next.channels, + feishu: { ...next.channels?.feishu, enabled: true }, + }, + }; + } else { + appId = String( + await prompter.text({ + message: "Enter Feishu App ID", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + appSecret = String( + await prompter.text({ + message: "Enter Feishu App Secret", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + } + } else if (hasConfigCreds) { + const keep = await prompter.confirm({ + message: "Feishu credentials already configured. Keep them?", + initialValue: true, + }); + if (!keep) { + appId = String( + await prompter.text({ + message: "Enter Feishu App ID", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + appSecret = String( + await prompter.text({ + message: "Enter Feishu App Secret", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + } + } else { + appId = String( + await prompter.text({ + message: "Enter Feishu App ID", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + appSecret = String( + await prompter.text({ + message: "Enter Feishu App Secret", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + } + + if (appId && appSecret) { + next = { + ...next, + channels: { + ...next.channels, + feishu: { + ...next.channels?.feishu, + enabled: true, + appId, + appSecret, + }, + }, + }; + + // Test connection + const testCfg = next.channels?.feishu as FeishuConfig; + try { + const probe = await probeFeishu(testCfg); + if (probe.ok) { + await prompter.note( + `Connected as ${probe.botName ?? probe.botOpenId ?? "bot"}`, + "Feishu connection test", + ); + } else { + await prompter.note( + `Connection failed: ${probe.error ?? "unknown error"}`, + "Feishu connection test", + ); + } + } catch (err) { + await prompter.note(`Connection test failed: ${String(err)}`, "Feishu connection test"); + } + } + + // Domain selection + const currentDomain = (next.channels?.feishu as FeishuConfig | undefined)?.domain ?? "feishu"; + const domain = await prompter.select({ + message: "Which Feishu domain?", + options: [ + { value: "feishu", label: "Feishu (feishu.cn) - China" }, + { value: "lark", label: "Lark (larksuite.com) - International" }, + ], + initialValue: currentDomain, + }); + if (domain) { + next = { + ...next, + channels: { + ...next.channels, + feishu: { + ...next.channels?.feishu, + domain: domain as "feishu" | "lark", + }, + }, + }; + } + + // Group policy + const groupPolicy = await prompter.select({ + message: "Group chat policy", + options: [ + { value: "allowlist", label: "Allowlist - only respond in specific groups" }, + { value: "open", label: "Open - respond in all groups (requires mention)" }, + { value: "disabled", label: "Disabled - don't respond in groups" }, + ], + initialValue: + (next.channels?.feishu as FeishuConfig | undefined)?.groupPolicy ?? "allowlist", + }); + if (groupPolicy) { + next = setFeishuGroupPolicy(next, groupPolicy as "open" | "allowlist" | "disabled"); + } + + // Group allowlist if needed + if (groupPolicy === "allowlist") { + const existing = (next.channels?.feishu as FeishuConfig | undefined)?.groupAllowFrom ?? []; + const entry = await prompter.text({ + message: "Group chat allowlist (chat_ids)", + placeholder: "oc_xxxxx, oc_yyyyy", + initialValue: existing.length > 0 ? existing.map(String).join(", ") : undefined, + }); + if (entry) { + const parts = parseAllowFromInput(String(entry)); + if (parts.length > 0) { + next = setFeishuGroupAllowFrom(next, parts); + } + } + } + + return { cfg: next, accountId: DEFAULT_ACCOUNT_ID }; + }, + + dmPolicy, + + disable: (cfg) => ({ + ...cfg, + channels: { + ...cfg.channels, + feishu: { ...cfg.channels?.feishu, enabled: false }, + }, + }), +}; diff --git a/extensions/feishu/src/outbound.ts b/extensions/feishu/src/outbound.ts new file mode 100644 index 000000000..29d34c031 --- /dev/null +++ b/extensions/feishu/src/outbound.ts @@ -0,0 +1,40 @@ +import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk"; +import { getFeishuRuntime } from "./runtime.js"; +import { sendMessageFeishu } from "./send.js"; +import { sendMediaFeishu } from "./media.js"; + +export const feishuOutbound: ChannelOutboundAdapter = { + deliveryMode: "direct", + chunker: (text, limit) => getFeishuRuntime().channel.text.chunkMarkdownText(text, limit), + chunkerMode: "markdown", + textChunkLimit: 4000, + sendText: async ({ cfg, to, text }) => { + const result = await sendMessageFeishu({ cfg, to, text }); + return { channel: "feishu", ...result }; + }, + sendMedia: async ({ cfg, to, text, mediaUrl }) => { + // Send text first if provided + if (text?.trim()) { + await sendMessageFeishu({ cfg, to, text }); + } + + // Upload and send media if URL provided + if (mediaUrl) { + try { + const result = await sendMediaFeishu({ cfg, to, mediaUrl }); + return { channel: "feishu", ...result }; + } catch (err) { + // Log the error for debugging + console.error(`[feishu] sendMediaFeishu failed:`, err); + // Fallback to URL link if upload fails + const fallbackText = `📎 ${mediaUrl}`; + const result = await sendMessageFeishu({ cfg, to, text: fallbackText }); + return { channel: "feishu", ...result }; + } + } + + // No media URL, just return text result + const result = await sendMessageFeishu({ cfg, to, text: text ?? "" }); + return { channel: "feishu", ...result }; + }, +}; diff --git a/extensions/feishu/src/policy.ts b/extensions/feishu/src/policy.ts new file mode 100644 index 000000000..a0e1a0d84 --- /dev/null +++ b/extensions/feishu/src/policy.ts @@ -0,0 +1,92 @@ +import type { ChannelGroupContext, GroupToolPolicyConfig } from "openclaw/plugin-sdk"; +import type { FeishuConfig, FeishuGroupConfig } from "./types.js"; + +export type FeishuAllowlistMatch = { + allowed: boolean; + matchKey?: string; + matchSource?: "wildcard" | "id" | "name"; +}; + +export function resolveFeishuAllowlistMatch(params: { + allowFrom: Array; + senderId: string; + senderName?: string | null; +}): FeishuAllowlistMatch { + const allowFrom = params.allowFrom + .map((entry) => String(entry).trim().toLowerCase()) + .filter(Boolean); + + if (allowFrom.length === 0) return { allowed: false }; + if (allowFrom.includes("*")) { + return { allowed: true, matchKey: "*", matchSource: "wildcard" }; + } + + const senderId = params.senderId.toLowerCase(); + if (allowFrom.includes(senderId)) { + return { allowed: true, matchKey: senderId, matchSource: "id" }; + } + + const senderName = params.senderName?.toLowerCase(); + if (senderName && allowFrom.includes(senderName)) { + return { allowed: true, matchKey: senderName, matchSource: "name" }; + } + + return { allowed: false }; +} + +export function resolveFeishuGroupConfig(params: { + cfg?: FeishuConfig; + groupId?: string | null; +}): FeishuGroupConfig | undefined { + const groups = params.cfg?.groups ?? {}; + const groupId = params.groupId?.trim(); + if (!groupId) return undefined; + + const direct = groups[groupId] as FeishuGroupConfig | undefined; + if (direct) return direct; + + const lowered = groupId.toLowerCase(); + const matchKey = Object.keys(groups).find((key) => key.toLowerCase() === lowered); + return matchKey ? (groups[matchKey] as FeishuGroupConfig | undefined) : undefined; +} + +export function resolveFeishuGroupToolPolicy( + params: ChannelGroupContext, +): GroupToolPolicyConfig | undefined { + const cfg = params.cfg.channels?.feishu as FeishuConfig | undefined; + if (!cfg) return undefined; + + const groupConfig = resolveFeishuGroupConfig({ + cfg, + groupId: params.groupId, + }); + + return groupConfig?.tools; +} + +export function isFeishuGroupAllowed(params: { + groupPolicy: "open" | "allowlist" | "disabled"; + allowFrom: Array; + senderId: string; + senderName?: string | null; +}): boolean { + const { groupPolicy } = params; + if (groupPolicy === "disabled") return false; + if (groupPolicy === "open") return true; + return resolveFeishuAllowlistMatch(params).allowed; +} + +export function resolveFeishuReplyPolicy(params: { + isDirectMessage: boolean; + globalConfig?: FeishuConfig; + groupConfig?: FeishuGroupConfig; +}): { requireMention: boolean } { + if (params.isDirectMessage) { + return { requireMention: false }; + } + + const requireMention = + params.groupConfig?.requireMention ?? params.globalConfig?.requireMention ?? true; + + return { requireMention }; +} diff --git a/extensions/feishu/src/probe.ts b/extensions/feishu/src/probe.ts new file mode 100644 index 000000000..959c0f990 --- /dev/null +++ b/extensions/feishu/src/probe.ts @@ -0,0 +1,46 @@ +import type { FeishuConfig, FeishuProbeResult } from "./types.js"; +import { createFeishuClient } from "./client.js"; +import { resolveFeishuCredentials } from "./accounts.js"; + +export async function probeFeishu(cfg?: FeishuConfig): Promise { + const creds = resolveFeishuCredentials(cfg); + if (!creds) { + return { + ok: false, + error: "missing credentials (appId, appSecret)", + }; + } + + try { + const client = createFeishuClient(cfg!); + // Use im.chat.list as a simple connectivity test + // The bot info API path varies by SDK version + const response = await (client as any).request({ + method: "GET", + url: "/open-apis/bot/v3/info", + data: {}, + }); + + if (response.code !== 0) { + return { + ok: false, + appId: creds.appId, + error: `API error: ${response.msg || `code ${response.code}`}`, + }; + } + + const bot = response.bot || response.data?.bot; + return { + ok: true, + appId: creds.appId, + botName: bot?.bot_name, + botOpenId: bot?.open_id, + }; + } catch (err) { + return { + ok: false, + appId: creds.appId, + error: err instanceof Error ? err.message : String(err), + }; + } +} diff --git a/extensions/feishu/src/reactions.ts b/extensions/feishu/src/reactions.ts new file mode 100644 index 000000000..44aa73c91 --- /dev/null +++ b/extensions/feishu/src/reactions.ts @@ -0,0 +1,157 @@ +import type { ClawdbotConfig } from "openclaw/plugin-sdk"; +import type { FeishuConfig } from "./types.js"; +import { createFeishuClient } from "./client.js"; + +export type FeishuReaction = { + reactionId: string; + emojiType: string; + operatorType: "app" | "user"; + operatorId: string; +}; + +/** + * Add a reaction (emoji) to a message. + * @param emojiType - Feishu emoji type, e.g., "SMILE", "THUMBSUP", "HEART" + * @see https://open.feishu.cn/document/server-docs/im-v1/message-reaction/emojis-introduce + */ +export async function addReactionFeishu(params: { + cfg: ClawdbotConfig; + messageId: string; + emojiType: string; +}): Promise<{ reactionId: string }> { + const { cfg, messageId, emojiType } = params; + const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; + if (!feishuCfg) { + throw new Error("Feishu channel not configured"); + } + + const client = createFeishuClient(feishuCfg); + + const response = (await client.im.messageReaction.create({ + path: { message_id: messageId }, + data: { + reaction_type: { + emoji_type: emojiType, + }, + }, + })) as { + code?: number; + msg?: string; + data?: { reaction_id?: string }; + }; + + if (response.code !== 0) { + throw new Error(`Feishu add reaction failed: ${response.msg || `code ${response.code}`}`); + } + + const reactionId = response.data?.reaction_id; + if (!reactionId) { + throw new Error("Feishu add reaction failed: no reaction_id returned"); + } + + return { reactionId }; +} + +/** + * Remove a reaction from a message. + */ +export async function removeReactionFeishu(params: { + cfg: ClawdbotConfig; + messageId: string; + reactionId: string; +}): Promise { + const { cfg, messageId, reactionId } = params; + const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; + if (!feishuCfg) { + throw new Error("Feishu channel not configured"); + } + + const client = createFeishuClient(feishuCfg); + + const response = (await client.im.messageReaction.delete({ + path: { + message_id: messageId, + reaction_id: reactionId, + }, + })) as { code?: number; msg?: string }; + + if (response.code !== 0) { + throw new Error(`Feishu remove reaction failed: ${response.msg || `code ${response.code}`}`); + } +} + +/** + * List all reactions for a message. + */ +export async function listReactionsFeishu(params: { + cfg: ClawdbotConfig; + messageId: string; + emojiType?: string; +}): Promise { + const { cfg, messageId, emojiType } = params; + const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; + if (!feishuCfg) { + throw new Error("Feishu channel not configured"); + } + + const client = createFeishuClient(feishuCfg); + + const response = (await client.im.messageReaction.list({ + path: { message_id: messageId }, + params: emojiType ? { reaction_type: emojiType } : undefined, + })) as { + code?: number; + msg?: string; + data?: { + items?: Array<{ + reaction_id?: string; + reaction_type?: { emoji_type?: string }; + operator_type?: string; + operator_id?: { open_id?: string; user_id?: string; union_id?: string }; + }>; + }; + }; + + if (response.code !== 0) { + throw new Error(`Feishu list reactions failed: ${response.msg || `code ${response.code}`}`); + } + + const items = response.data?.items ?? []; + return items.map((item) => ({ + reactionId: item.reaction_id ?? "", + emojiType: item.reaction_type?.emoji_type ?? "", + operatorType: item.operator_type === "app" ? "app" : "user", + operatorId: + item.operator_id?.open_id ?? item.operator_id?.user_id ?? item.operator_id?.union_id ?? "", + })); +} + +/** + * Common Feishu emoji types for convenience. + * @see https://open.feishu.cn/document/server-docs/im-v1/message-reaction/emojis-introduce + */ +export const FeishuEmoji = { + // Common reactions + THUMBSUP: "THUMBSUP", + THUMBSDOWN: "THUMBSDOWN", + HEART: "HEART", + SMILE: "SMILE", + GRINNING: "GRINNING", + LAUGHING: "LAUGHING", + CRY: "CRY", + ANGRY: "ANGRY", + SURPRISED: "SURPRISED", + THINKING: "THINKING", + CLAP: "CLAP", + OK: "OK", + FIST: "FIST", + PRAY: "PRAY", + FIRE: "FIRE", + PARTY: "PARTY", + CHECK: "CHECK", + CROSS: "CROSS", + QUESTION: "QUESTION", + EXCLAMATION: "EXCLAMATION", +} as const; + +export type FeishuEmojiType = (typeof FeishuEmoji)[keyof typeof FeishuEmoji]; diff --git a/extensions/feishu/src/reply-dispatcher.ts b/extensions/feishu/src/reply-dispatcher.ts new file mode 100644 index 000000000..f9c1ddc53 --- /dev/null +++ b/extensions/feishu/src/reply-dispatcher.ts @@ -0,0 +1,156 @@ +import { + createReplyPrefixContext, + createTypingCallbacks, + logTypingFailure, + type ClawdbotConfig, + type RuntimeEnv, + type ReplyPayload, +} from "openclaw/plugin-sdk"; +import { getFeishuRuntime } from "./runtime.js"; +import { sendMessageFeishu, sendMarkdownCardFeishu } from "./send.js"; +import type { FeishuConfig } from "./types.js"; +import { + addTypingIndicator, + removeTypingIndicator, + type TypingIndicatorState, +} from "./typing.js"; + +/** + * Detect if text contains markdown elements that benefit from card rendering. + * Used by auto render mode. + */ +function shouldUseCard(text: string): boolean { + // Code blocks (fenced) + if (/```[\s\S]*?```/.test(text)) return true; + // Tables (at least header + separator row with |) + if (/\|.+\|[\r\n]+\|[-:| ]+\|/.test(text)) return true; + return false; +} + +export type CreateFeishuReplyDispatcherParams = { + cfg: ClawdbotConfig; + agentId: string; + runtime: RuntimeEnv; + chatId: string; + replyToMessageId?: string; +}; + +export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherParams) { + const core = getFeishuRuntime(); + const { cfg, agentId, chatId, replyToMessageId } = params; + + const prefixContext = createReplyPrefixContext({ + cfg, + agentId, + }); + + // Feishu doesn't have a native typing indicator API. + // We use message reactions as a typing indicator substitute. + let typingState: TypingIndicatorState | null = null; + + const typingCallbacks = createTypingCallbacks({ + start: async () => { + if (!replyToMessageId) return; + typingState = await addTypingIndicator({ cfg, messageId: replyToMessageId }); + params.runtime.log?.(`feishu: added typing indicator reaction`); + }, + stop: async () => { + if (!typingState) return; + await removeTypingIndicator({ cfg, state: typingState }); + typingState = null; + params.runtime.log?.(`feishu: removed typing indicator reaction`); + }, + onStartError: (err) => { + logTypingFailure({ + log: (message) => params.runtime.log?.(message), + channel: "feishu", + action: "start", + error: err, + }); + }, + onStopError: (err) => { + logTypingFailure({ + log: (message) => params.runtime.log?.(message), + channel: "feishu", + action: "stop", + error: err, + }); + }, + }); + + const textChunkLimit = core.channel.text.resolveTextChunkLimit({ + cfg, + channel: "feishu", + defaultLimit: 4000, + }); + const chunkMode = core.channel.text.resolveChunkMode(cfg, "feishu"); + const tableMode = core.channel.text.resolveMarkdownTableMode({ + cfg, + channel: "feishu", + }); + + const { dispatcher, replyOptions, markDispatchIdle } = + core.channel.reply.createReplyDispatcherWithTyping({ + responsePrefix: prefixContext.responsePrefix, + responsePrefixContextProvider: prefixContext.responsePrefixContextProvider, + humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, agentId), + onReplyStart: typingCallbacks.onReplyStart, + deliver: async (payload: ReplyPayload) => { + params.runtime.log?.(`feishu deliver called: text=${payload.text?.slice(0, 100)}`); + const text = payload.text ?? ""; + if (!text.trim()) { + params.runtime.log?.(`feishu deliver: empty text, skipping`); + return; + } + + // Check render mode: auto (default), raw, or card + const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; + const renderMode = feishuCfg?.renderMode ?? "auto"; + + // Determine if we should use card for this message + const useCard = + renderMode === "card" || (renderMode === "auto" && shouldUseCard(text)); + + if (useCard) { + // Card mode: send as interactive card with markdown rendering + const chunks = core.channel.text.chunkTextWithMode(text, textChunkLimit, chunkMode); + params.runtime.log?.(`feishu deliver: sending ${chunks.length} card chunks to ${chatId}`); + for (const chunk of chunks) { + await sendMarkdownCardFeishu({ + cfg, + to: chatId, + text: chunk, + replyToMessageId, + }); + } + } else { + // Raw mode: send as plain text with table conversion + const converted = core.channel.text.convertMarkdownTables(text, tableMode); + const chunks = core.channel.text.chunkTextWithMode(converted, textChunkLimit, chunkMode); + params.runtime.log?.(`feishu deliver: sending ${chunks.length} text chunks to ${chatId}`); + for (const chunk of chunks) { + await sendMessageFeishu({ + cfg, + to: chatId, + text: chunk, + replyToMessageId, + }); + } + } + }, + onError: (err, info) => { + params.runtime.error?.(`feishu ${info.kind} reply failed: ${String(err)}`); + typingCallbacks.onIdle?.(); + }, + onIdle: typingCallbacks.onIdle, + }); + + return { + dispatcher, + replyOptions: { + ...replyOptions, + onModelSelected: prefixContext.onModelSelected, + }, + markDispatchIdle, + }; +} diff --git a/extensions/feishu/src/runtime.ts b/extensions/feishu/src/runtime.ts new file mode 100644 index 000000000..f1148c5e7 --- /dev/null +++ b/extensions/feishu/src/runtime.ts @@ -0,0 +1,14 @@ +import type { PluginRuntime } from "openclaw/plugin-sdk"; + +let runtime: PluginRuntime | null = null; + +export function setFeishuRuntime(next: PluginRuntime) { + runtime = next; +} + +export function getFeishuRuntime(): PluginRuntime { + if (!runtime) { + throw new Error("Feishu runtime not initialized"); + } + return runtime; +} diff --git a/extensions/feishu/src/send.ts b/extensions/feishu/src/send.ts new file mode 100644 index 000000000..b17dd337b --- /dev/null +++ b/extensions/feishu/src/send.ts @@ -0,0 +1,308 @@ +import type { ClawdbotConfig } from "openclaw/plugin-sdk"; +import type { FeishuConfig, FeishuSendResult } from "./types.js"; +import { createFeishuClient } from "./client.js"; +import { resolveReceiveIdType, normalizeFeishuTarget } from "./targets.js"; +import { getFeishuRuntime } from "./runtime.js"; + +export type FeishuMessageInfo = { + messageId: string; + chatId: string; + senderId?: string; + senderOpenId?: string; + content: string; + contentType: string; + createTime?: number; +}; + +/** + * Get a message by its ID. + * Useful for fetching quoted/replied message content. + */ +export async function getMessageFeishu(params: { + cfg: ClawdbotConfig; + messageId: string; +}): Promise { + const { cfg, messageId } = params; + const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; + if (!feishuCfg) { + throw new Error("Feishu channel not configured"); + } + + const client = createFeishuClient(feishuCfg); + + try { + const response = (await client.im.message.get({ + path: { message_id: messageId }, + })) as { + code?: number; + msg?: string; + data?: { + items?: Array<{ + message_id?: string; + chat_id?: string; + msg_type?: string; + body?: { content?: string }; + sender?: { + id?: string; + id_type?: string; + sender_type?: string; + }; + create_time?: string; + }>; + }; + }; + + if (response.code !== 0) { + return null; + } + + const item = response.data?.items?.[0]; + if (!item) { + return null; + } + + // Parse content based on message type + let content = item.body?.content ?? ""; + try { + const parsed = JSON.parse(content); + if (item.msg_type === "text" && parsed.text) { + content = parsed.text; + } + } catch { + // Keep raw content if parsing fails + } + + return { + messageId: item.message_id ?? messageId, + chatId: item.chat_id ?? "", + senderId: item.sender?.id, + senderOpenId: item.sender?.id_type === "open_id" ? item.sender?.id : undefined, + content, + contentType: item.msg_type ?? "text", + createTime: item.create_time ? parseInt(item.create_time, 10) : undefined, + }; + } catch { + return null; + } +} + +export type SendFeishuMessageParams = { + cfg: ClawdbotConfig; + to: string; + text: string; + replyToMessageId?: string; +}; + +export async function sendMessageFeishu(params: SendFeishuMessageParams): Promise { + const { cfg, to, text, replyToMessageId } = params; + const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; + if (!feishuCfg) { + throw new Error("Feishu channel not configured"); + } + + const client = createFeishuClient(feishuCfg); + const receiveId = normalizeFeishuTarget(to); + if (!receiveId) { + throw new Error(`Invalid Feishu target: ${to}`); + } + + const receiveIdType = resolveReceiveIdType(receiveId); + const tableMode = getFeishuRuntime().channel.text.resolveMarkdownTableMode({ + cfg, + channel: "feishu", + }); + const messageText = getFeishuRuntime().channel.text.convertMarkdownTables(text ?? "", tableMode); + + const content = JSON.stringify({ text: messageText }); + + if (replyToMessageId) { + const response = await client.im.message.reply({ + path: { message_id: replyToMessageId }, + data: { + content, + msg_type: "text", + }, + }); + + if (response.code !== 0) { + throw new Error(`Feishu reply failed: ${response.msg || `code ${response.code}`}`); + } + + return { + messageId: response.data?.message_id ?? "unknown", + chatId: receiveId, + }; + } + + const response = await client.im.message.create({ + params: { receive_id_type: receiveIdType }, + data: { + receive_id: receiveId, + content, + msg_type: "text", + }, + }); + + if (response.code !== 0) { + throw new Error(`Feishu send failed: ${response.msg || `code ${response.code}`}`); + } + + return { + messageId: response.data?.message_id ?? "unknown", + chatId: receiveId, + }; +} + +export type SendFeishuCardParams = { + cfg: ClawdbotConfig; + to: string; + card: Record; + replyToMessageId?: string; +}; + +export async function sendCardFeishu(params: SendFeishuCardParams): Promise { + const { cfg, to, card, replyToMessageId } = params; + const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; + if (!feishuCfg) { + throw new Error("Feishu channel not configured"); + } + + const client = createFeishuClient(feishuCfg); + const receiveId = normalizeFeishuTarget(to); + if (!receiveId) { + throw new Error(`Invalid Feishu target: ${to}`); + } + + const receiveIdType = resolveReceiveIdType(receiveId); + const content = JSON.stringify(card); + + if (replyToMessageId) { + const response = await client.im.message.reply({ + path: { message_id: replyToMessageId }, + data: { + content, + msg_type: "interactive", + }, + }); + + if (response.code !== 0) { + throw new Error(`Feishu card reply failed: ${response.msg || `code ${response.code}`}`); + } + + return { + messageId: response.data?.message_id ?? "unknown", + chatId: receiveId, + }; + } + + const response = await client.im.message.create({ + params: { receive_id_type: receiveIdType }, + data: { + receive_id: receiveId, + content, + msg_type: "interactive", + }, + }); + + if (response.code !== 0) { + throw new Error(`Feishu card send failed: ${response.msg || `code ${response.code}`}`); + } + + return { + messageId: response.data?.message_id ?? "unknown", + chatId: receiveId, + }; +} + +export async function updateCardFeishu(params: { + cfg: ClawdbotConfig; + messageId: string; + card: Record; +}): Promise { + const { cfg, messageId, card } = params; + const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; + if (!feishuCfg) { + throw new Error("Feishu channel not configured"); + } + + const client = createFeishuClient(feishuCfg); + const content = JSON.stringify(card); + + const response = await client.im.message.patch({ + path: { message_id: messageId }, + data: { content }, + }); + + if (response.code !== 0) { + throw new Error(`Feishu card update failed: ${response.msg || `code ${response.code}`}`); + } +} + +/** + * Build a Feishu interactive card with markdown content. + * Cards render markdown properly (code blocks, tables, links, etc.) + */ +export function buildMarkdownCard(text: string): Record { + return { + config: { + wide_screen_mode: true, + }, + elements: [ + { + tag: "markdown", + content: text, + }, + ], + }; +} + +/** + * Send a message as a markdown card (interactive message). + * This renders markdown properly in Feishu (code blocks, tables, bold/italic, etc.) + */ +export async function sendMarkdownCardFeishu(params: { + cfg: ClawdbotConfig; + to: string; + text: string; + replyToMessageId?: string; +}): Promise { + const { cfg, to, text, replyToMessageId } = params; + const card = buildMarkdownCard(text); + return sendCardFeishu({ cfg, to, card, replyToMessageId }); +} + +/** + * Edit an existing text message. + * Note: Feishu only allows editing messages within 24 hours. + */ +export async function editMessageFeishu(params: { + cfg: ClawdbotConfig; + messageId: string; + text: string; +}): Promise { + const { cfg, messageId, text } = params; + const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; + if (!feishuCfg) { + throw new Error("Feishu channel not configured"); + } + + const client = createFeishuClient(feishuCfg); + const tableMode = getFeishuRuntime().channel.text.resolveMarkdownTableMode({ + cfg, + channel: "feishu", + }); + const messageText = getFeishuRuntime().channel.text.convertMarkdownTables(text ?? "", tableMode); + const content = JSON.stringify({ text: messageText }); + + const response = await client.im.message.update({ + path: { message_id: messageId }, + data: { + msg_type: "text", + content, + }, + }); + + if (response.code !== 0) { + throw new Error(`Feishu message edit failed: ${response.msg || `code ${response.code}`}`); + } +} diff --git a/extensions/feishu/src/targets.ts b/extensions/feishu/src/targets.ts new file mode 100644 index 000000000..16d3e99b9 --- /dev/null +++ b/extensions/feishu/src/targets.ts @@ -0,0 +1,58 @@ +import type { FeishuIdType } from "./types.js"; + +const CHAT_ID_PREFIX = "oc_"; +const OPEN_ID_PREFIX = "ou_"; +const USER_ID_REGEX = /^[a-zA-Z0-9_-]+$/; + +export function detectIdType(id: string): FeishuIdType | null { + const trimmed = id.trim(); + if (trimmed.startsWith(CHAT_ID_PREFIX)) return "chat_id"; + if (trimmed.startsWith(OPEN_ID_PREFIX)) return "open_id"; + if (USER_ID_REGEX.test(trimmed)) return "user_id"; + return null; +} + +export function normalizeFeishuTarget(raw: string): string | null { + const trimmed = raw.trim(); + if (!trimmed) return null; + + const lowered = trimmed.toLowerCase(); + if (lowered.startsWith("chat:")) { + return trimmed.slice("chat:".length).trim() || null; + } + if (lowered.startsWith("user:")) { + return trimmed.slice("user:".length).trim() || null; + } + if (lowered.startsWith("open_id:")) { + return trimmed.slice("open_id:".length).trim() || null; + } + + return trimmed; +} + +export function formatFeishuTarget(id: string, type?: FeishuIdType): string { + const trimmed = id.trim(); + if (type === "chat_id" || trimmed.startsWith(CHAT_ID_PREFIX)) { + return `chat:${trimmed}`; + } + if (type === "open_id" || trimmed.startsWith(OPEN_ID_PREFIX)) { + return `user:${trimmed}`; + } + return trimmed; +} + +export function resolveReceiveIdType(id: string): "chat_id" | "open_id" | "user_id" { + const trimmed = id.trim(); + if (trimmed.startsWith(CHAT_ID_PREFIX)) return "chat_id"; + if (trimmed.startsWith(OPEN_ID_PREFIX)) return "open_id"; + return "open_id"; +} + +export function looksLikeFeishuId(raw: string): boolean { + const trimmed = raw.trim(); + if (!trimmed) return false; + if (/^(chat|user|open_id):/i.test(trimmed)) return true; + if (trimmed.startsWith(CHAT_ID_PREFIX)) return true; + if (trimmed.startsWith(OPEN_ID_PREFIX)) return true; + return false; +} diff --git a/extensions/feishu/src/types.ts b/extensions/feishu/src/types.ts new file mode 100644 index 000000000..828b8b8f8 --- /dev/null +++ b/extensions/feishu/src/types.ts @@ -0,0 +1,50 @@ +import type { FeishuConfigSchema, FeishuGroupSchema, z } from "./config-schema.js"; + +export type FeishuConfig = z.infer; +export type FeishuGroupConfig = z.infer; + +export type FeishuDomain = "feishu" | "lark"; +export type FeishuConnectionMode = "websocket" | "webhook"; + +export type ResolvedFeishuAccount = { + accountId: string; + enabled: boolean; + configured: boolean; + appId?: string; + domain: FeishuDomain; +}; + +export type FeishuIdType = "open_id" | "user_id" | "union_id" | "chat_id"; + +export type FeishuMessageContext = { + chatId: string; + messageId: string; + senderId: string; + senderOpenId: string; + senderName?: string; + chatType: "p2p" | "group"; + mentionedBot: boolean; + rootId?: string; + parentId?: string; + content: string; + contentType: string; +}; + +export type FeishuSendResult = { + messageId: string; + chatId: string; +}; + +export type FeishuProbeResult = { + ok: boolean; + error?: string; + appId?: string; + botName?: string; + botOpenId?: string; +}; + +export type FeishuMediaInfo = { + path: string; + contentType?: string; + placeholder: string; +}; diff --git a/extensions/feishu/src/typing.ts b/extensions/feishu/src/typing.ts new file mode 100644 index 000000000..e316f65db --- /dev/null +++ b/extensions/feishu/src/typing.ts @@ -0,0 +1,73 @@ +import type { ClawdbotConfig } from "openclaw/plugin-sdk"; +import type { FeishuConfig } from "./types.js"; +import { createFeishuClient } from "./client.js"; + +// Feishu emoji types for typing indicator +// See: https://open.feishu.cn/document/server-docs/im-v1/message-reaction/emojis-introduce +// Full list: https://github.com/go-lark/lark/blob/main/emoji.go +const TYPING_EMOJI = "Typing"; // Typing indicator emoji + +export type TypingIndicatorState = { + messageId: string; + reactionId: string | null; +}; + +/** + * Add a typing indicator (reaction) to a message + */ +export async function addTypingIndicator(params: { + cfg: ClawdbotConfig; + messageId: string; +}): Promise { + const { cfg, messageId } = params; + const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; + if (!feishuCfg) { + return { messageId, reactionId: null }; + } + + const client = createFeishuClient(feishuCfg); + + try { + const response = await client.im.messageReaction.create({ + path: { message_id: messageId }, + data: { + reaction_type: { emoji_type: TYPING_EMOJI }, + }, + }); + + const reactionId = (response as any)?.data?.reaction_id ?? null; + return { messageId, reactionId }; + } catch (err) { + // Silently fail - typing indicator is not critical + console.log(`[feishu] failed to add typing indicator: ${err}`); + return { messageId, reactionId: null }; + } +} + +/** + * Remove a typing indicator (reaction) from a message + */ +export async function removeTypingIndicator(params: { + cfg: ClawdbotConfig; + state: TypingIndicatorState; +}): Promise { + const { cfg, state } = params; + if (!state.reactionId) return; + + const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; + if (!feishuCfg) return; + + const client = createFeishuClient(feishuCfg); + + try { + await client.im.messageReaction.delete({ + path: { + message_id: state.messageId, + reaction_id: state.reactionId, + }, + }); + } catch (err) { + // Silently fail - cleanup is not critical + console.log(`[feishu] failed to remove typing indicator: ${err}`); + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 95b940c97..0d0c1ff5d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -304,6 +304,12 @@ importers: extensions/discord: {} + extensions/feishu: + dependencies: + '@larksuiteoapi/node-sdk': + specifier: ^1.30.0 + version: 1.58.0 + extensions/google-antigravity-auth: {} extensions/google-gemini-cli-auth: {} @@ -1333,6 +1339,9 @@ packages: peerDependencies: apache-arrow: '>=15.0.0 <=18.1.0' + '@larksuiteoapi/node-sdk@1.58.0': + resolution: {integrity: sha512-NcQNHdGuHOxOWY3bRGS9WldwpbR6+k7Fi0H1IJXDNNmbSrEB/8rLwqHRC8tAbbj/Mp8TWH/v1O+p487m6xskxw==} + '@line/bot-sdk@10.6.0': resolution: {integrity: sha512-4hSpglL/G/cW2JCcohaYz/BS0uOSJNV9IEYdMm0EiPEvDLayoI2hGq2D86uYPQFD2gvgkyhmzdShpWLG3P5r3w==} engines: {node: '>=20'} @@ -3080,6 +3089,9 @@ packages: axios@1.13.2: resolution: {integrity: sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==} + axios@1.13.4: + resolution: {integrity: sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg==} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -4166,6 +4178,9 @@ packages: lodash.debounce@4.0.8: resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} + lodash.identity@3.0.0: + resolution: {integrity: sha512-AupTIzdLQxJS5wIYUQlgGyk2XRTfGXA+MCghDHqZk0pzUNYvd3EESS6dkChNauNYVIutcb0dfHw1ri9Q1yPV8Q==} + lodash.includes@4.3.0: resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} @@ -4184,9 +4199,15 @@ packages: lodash.isstring@4.0.1: resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lodash.once@4.1.1: resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + lodash.pickby@4.6.0: + resolution: {integrity: sha512-AZV+GsS/6ckvPOVQPXSiFFacKvKB4kOQu6ynt9wz0F3LO4R9Ij4K1ddYsIytDpSgLz88JHd9P+oaLeej5/Sl7Q==} + lodash@4.17.23: resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} @@ -6883,6 +6904,20 @@ snapshots: '@lancedb/lancedb-win32-arm64-msvc': 0.23.0 '@lancedb/lancedb-win32-x64-msvc': 0.23.0 + '@larksuiteoapi/node-sdk@1.58.0': + dependencies: + axios: 1.13.4 + lodash.identity: 3.0.0 + lodash.merge: 4.6.2 + lodash.pickby: 4.6.0 + protobufjs: 7.5.4 + qs: 6.14.1 + ws: 8.19.0 + transitivePeerDependencies: + - bufferutil + - debug + - utf-8-validate + '@line/bot-sdk@10.6.0': dependencies: '@types/node': 24.10.9 @@ -8938,6 +8973,14 @@ snapshots: transitivePeerDependencies: - debug + axios@1.13.4: + dependencies: + follow-redirects: 1.15.11(debug@4.4.3) + form-data: 4.0.5 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + balanced-match@1.0.2: {} balanced-match@3.0.1: {} @@ -10174,6 +10217,8 @@ snapshots: lodash.debounce@4.0.8: optional: true + lodash.identity@3.0.0: {} + lodash.includes@4.3.0: {} lodash.isboolean@3.0.3: {} @@ -10186,8 +10231,12 @@ snapshots: lodash.isstring@4.0.1: {} + lodash.merge@4.6.2: {} + lodash.once@4.1.1: {} + lodash.pickby@4.6.0: {} + lodash@4.17.23: {} log-symbols@6.0.0: From 992c31e6e85fa76741ecdf0a0733342317182cd0 Mon Sep 17 00:00:00 2001 From: m1heng <18018422+m1heng@users.noreply.github.com> Date: Sat, 31 Jan 2026 00:04:45 +0800 Subject: [PATCH 2/3] chore: add feishu channel to labeler config Co-Authored-By: Claude Opus 4.5 --- .github/labeler.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/labeler.yml b/.github/labeler.yml index 5c19fa418..e80d0bd7d 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -3,6 +3,11 @@ - any-glob-to-any-file: - "extensions/bluebubbles/**" - "docs/channels/bluebubbles.md" +"channel: feishu": + - changed-files: + - any-glob-to-any-file: + - "extensions/feishu/**" + - "docs/channels/feishu.md" "channel: discord": - changed-files: - any-glob-to-any-file: From 4d2e1e03b09a14017735de87c0356d0c4ed804a0 Mon Sep 17 00:00:00 2001 From: m1heng <18018422+m1heng@users.noreply.github.com> Date: Sat, 31 Jan 2026 00:42:07 +0800 Subject: [PATCH 3/3] docs: add feishu to channel index and navigation - Add Feishu to docs/channels/index.md supported channels list - Add feishu to docs/docs.json navigation - Add channel metadata and install config to package.json Co-Authored-By: Claude Opus 4.5 --- docs/channels/index.md | 1 + docs/docs.json | 1 + extensions/feishu/package.json | 20 +++++++++++++++++++- 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/docs/channels/index.md b/docs/channels/index.md index ea1b1bc8a..975537fa3 100644 --- a/docs/channels/index.md +++ b/docs/channels/index.md @@ -29,6 +29,7 @@ Text is supported everywhere; media and reactions vary by channel. - [Twitch](/channels/twitch) — Twitch chat via IRC connection (plugin, installed separately). - [Zalo](/channels/zalo) — Zalo Bot API; Vietnam's popular messenger (plugin, installed separately). - [Zalo Personal](/channels/zalouser) — Zalo personal account via QR login (plugin, installed separately). +- [Feishu](/channels/feishu) — Feishu/Lark (飞书) Bot API; China/International enterprise messaging (plugin, installed separately). - [WebChat](/web/webchat) — Gateway WebChat UI over WebSocket. ## Notes diff --git a/docs/docs.json b/docs/docs.json index a676004f6..869a4255a 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -1012,6 +1012,7 @@ "channels/matrix", "channels/zalo", "channels/zalouser", + "channels/feishu", "broadcast-groups", "channels/troubleshooting", "channels/location" diff --git a/extensions/feishu/package.json b/extensions/feishu/package.json index 2103f2312..1a8c069dc 100644 --- a/extensions/feishu/package.json +++ b/extensions/feishu/package.json @@ -6,7 +6,25 @@ "openclaw": { "extensions": [ "./index.ts" - ] + ], + "channel": { + "id": "feishu", + "label": "Feishu", + "selectionLabel": "Feishu/Lark (飞书)", + "docsPath": "/channels/feishu", + "docsLabel": "feishu", + "blurb": "Feishu/Lark enterprise messaging for China and International markets.", + "aliases": [ + "lark" + ], + "order": 72, + "quickstartAllowFrom": true + }, + "install": { + "npmSpec": "@openclaw/feishu", + "localPath": "extensions/feishu", + "defaultChoice": "npm" + } }, "dependencies": { "@larksuiteoapi/node-sdk": "^1.30.0"