feat(feishu): add Feishu/Lark channel plugin

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 <noreply@anthropic.com>
This commit is contained in:
m1heng 2026-01-30 23:56:51 +08:00
parent 09be5d45d5
commit bd1ae9d411
24 changed files with 3581 additions and 0 deletions

187
docs/channels/feishu.md Normal file
View File

@ -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 <CODE>`
### 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).

View File

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

View File

@ -0,0 +1,9 @@
{
"id": "feishu",
"channels": ["feishu"],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View File

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

View File

@ -0,0 +1,53 @@
import type { ClawdbotConfig } from "openclaw/plugin-sdk";
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk";
import type { FeishuConfig, FeishuDomain, ResolvedFeishuAccount } from "./types.js";
export function resolveFeishuCredentials(cfg?: FeishuConfig): {
appId: string;
appSecret: string;
encryptKey?: string;
verificationToken?: string;
domain: FeishuDomain;
} | null {
const appId = cfg?.appId?.trim();
const appSecret = cfg?.appSecret?.trim();
if (!appId || !appSecret) return null;
return {
appId,
appSecret,
encryptKey: cfg?.encryptKey?.trim() || undefined,
verificationToken: cfg?.verificationToken?.trim() || undefined,
domain: cfg?.domain ?? "feishu",
};
}
export function resolveFeishuAccount(params: {
cfg: ClawdbotConfig;
accountId?: string | null;
}): ResolvedFeishuAccount {
const feishuCfg = params.cfg.channels?.feishu as FeishuConfig | undefined;
const enabled = feishuCfg?.enabled !== false;
const creds = resolveFeishuCredentials(feishuCfg);
return {
accountId: params.accountId?.trim() || DEFAULT_ACCOUNT_ID,
enabled,
configured: Boolean(creds),
appId: creds?.appId,
domain: creds?.domain ?? "feishu",
};
}
export function listFeishuAccountIds(_cfg: ClawdbotConfig): string[] {
return [DEFAULT_ACCOUNT_ID];
}
export function resolveDefaultFeishuAccountId(_cfg: ClawdbotConfig): string {
return DEFAULT_ACCOUNT_ID;
}
export function listEnabledFeishuAccounts(cfg: ClawdbotConfig): ResolvedFeishuAccount[] {
return listFeishuAccountIds(cfg)
.map((accountId) => resolveFeishuAccount({ cfg, accountId }))
.filter((account) => account.enabled && account.configured);
}

View File

@ -0,0 +1,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<string, { name: string; expireAt: number }>();
async function resolveFeishuSenderName(params: {
feishuCfg?: FeishuConfig;
senderOpenId: string;
log: (...args: any[]) => void;
}): Promise<string | undefined> {
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 "<media:image>";
case "file":
return "<media:document>";
case "audio":
return "<media:audio>";
case "video":
return "<media:video>";
case "sticker":
return "<media:sticker>";
default:
return "<media:document>";
}
}
/**
* Resolve media from a Feishu message, downloading and saving to disk.
* Similar to Discord's resolveMediaList().
*/
async function resolveFeishuMediaList(params: {
cfg: ClawdbotConfig;
messageId: string;
messageType: string;
content: string;
maxBytes: number;
log?: (msg: string) => void;
}): Promise<FeishuMediaInfo[]> {
const { cfg, messageId, messageType, content, maxBytes, log } = params;
// Only process media message types (including post for embedded images)
const mediaTypes = ["image", "file", "audio", "video", "sticker", "post"];
if (!mediaTypes.includes(messageType)) {
return [];
}
const out: FeishuMediaInfo[] = [];
const core = getFeishuRuntime();
// Handle post (rich text) messages with embedded images
if (messageType === "post") {
const { imageKeys } = parsePostContent(content);
if (imageKeys.length === 0) {
return [];
}
log?.(`feishu: post message contains ${imageKeys.length} embedded image(s)`);
for (const imageKey of imageKeys) {
try {
// Embedded images in post use messageResource API with image_key as file_key
const result = await downloadMessageResourceFeishu({
cfg,
messageId,
fileKey: imageKey,
type: "image",
});
let contentType = result.contentType;
if (!contentType) {
contentType = await core.media.detectMime({ buffer: result.buffer });
}
const saved = await core.channel.media.saveMediaBuffer(
result.buffer,
contentType,
"inbound",
maxBytes,
);
out.push({
path: saved.path,
contentType: saved.contentType,
placeholder: "<media:image>",
});
log?.(`feishu: downloaded embedded image ${imageKey}, saved to ${saved.path}`);
} catch (err) {
log?.(`feishu: failed to download embedded image ${imageKey}: ${String(err)}`);
}
}
return out;
}
// Handle other media types
const mediaKeys = parseMediaKeys(content, messageType);
if (!mediaKeys.imageKey && !mediaKeys.fileKey) {
return [];
}
try {
let buffer: Buffer;
let contentType: string | undefined;
let fileName: string | undefined;
// For message media, always use messageResource API
// The image.get API is only for images uploaded via im/v1/images, not for message attachments
const fileKey = mediaKeys.imageKey || mediaKeys.fileKey;
if (!fileKey) {
return [];
}
const resourceType = messageType === "image" ? "image" : "file";
const result = await downloadMessageResourceFeishu({
cfg,
messageId,
fileKey,
type: resourceType,
});
buffer = result.buffer;
contentType = result.contentType;
fileName = result.fileName || mediaKeys.fileName;
// Detect mime type if not provided
if (!contentType) {
contentType = await core.media.detectMime({ buffer });
}
// Save to disk using core's saveMediaBuffer
const saved = await core.channel.media.saveMediaBuffer(
buffer,
contentType,
"inbound",
maxBytes,
fileName,
);
out.push({
path: saved.path,
contentType: saved.contentType,
placeholder: inferPlaceholder(messageType),
});
log?.(`feishu: downloaded ${messageType} media, saved to ${saved.path}`);
} catch (err) {
log?.(`feishu: failed to download ${messageType} media: ${String(err)}`);
}
return out;
}
/**
* Build media payload for inbound context.
* Similar to Discord's buildDiscordMediaPayload().
*/
function buildFeishuMediaPayload(
mediaList: FeishuMediaInfo[],
): {
MediaPath?: string;
MediaType?: string;
MediaUrl?: string;
MediaPaths?: string[];
MediaUrls?: string[];
MediaTypes?: string[];
} {
const first = mediaList[0];
const mediaPaths = mediaList.map((media) => media.path);
const mediaTypes = mediaList.map((media) => media.contentType).filter(Boolean) as string[];
return {
MediaPath: first?.path,
MediaType: first?.contentType,
MediaUrl: first?.path,
MediaPaths: mediaPaths.length > 0 ? mediaPaths : undefined,
MediaUrls: mediaPaths.length > 0 ? mediaPaths : undefined,
MediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined,
};
}
export function parseFeishuMessageEvent(
event: FeishuMessageEvent,
botOpenId?: string,
): FeishuMessageContext {
const rawContent = parseMessageContent(event.message.content, event.message.message_type);
const mentionedBot = checkBotMentioned(event, botOpenId);
const content = stripBotMention(rawContent, event.message.mentions);
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<string, HistoryEntry[]>;
}): Promise<void> {
const { cfg, event, botOpenId, runtime, chatHistories } = params;
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
const log = runtime?.log ?? console.log;
const error = runtime?.error ?? console.error;
let ctx = parseFeishuMessageEvent(event, botOpenId);
const isGroup = ctx.chatType === "group";
// Resolve sender display name (best-effort) so the agent can attribute messages correctly.
const 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)}`);
}
}

View File

@ -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<ResolvedFeishuAccount> = {
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<string, unknown>).feishu;
if (Object.keys(nextChannels).length > 0) {
next.channels = nextChannels;
} else {
delete next.channels;
}
return next;
},
isConfigured: (_account, cfg) =>
Boolean(resolveFeishuCredentials(cfg.channels?.feishu as FeishuConfig | undefined)),
describeAccount: (account) => ({
accountId: account.accountId,
enabled: account.enabled,
configured: account.configured,
}),
resolveAllowFrom: ({ cfg }) =>
(cfg.channels?.feishu as FeishuConfig | undefined)?.allowFrom ?? [],
formatAllowFrom: ({ allowFrom }) =>
allowFrom
.map((entry) => String(entry).trim())
.filter(Boolean)
.map((entry) => entry.toLowerCase()),
},
security: {
collectWarnings: ({ cfg }) => {
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
const defaultGroupPolicy = (cfg.channels as Record<string, { groupPolicy?: string }> | undefined)?.defaults?.groupPolicy;
const groupPolicy = feishuCfg?.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
if (groupPolicy !== "open") return [];
return [
`- Feishu groups: groupPolicy="open" allows any member to trigger (mention-gated). Set channels.feishu.groupPolicy="allowlist" + channels.feishu.groupAllowFrom to restrict senders.`,
];
},
},
setup: {
resolveAccountId: () => DEFAULT_ACCOUNT_ID,
applyAccountConfig: ({ cfg }) => ({
...cfg,
channels: {
...cfg.channels,
feishu: {
...cfg.channels?.feishu,
enabled: true,
},
},
}),
},
onboarding: feishuOnboardingAdapter,
messaging: {
normalizeTarget: normalizeFeishuTarget,
targetResolver: {
looksLikeId: looksLikeFeishuId,
hint: "<chatId|user:openId|chat:chatId>",
},
},
directory: {
self: async () => null,
listPeers: async ({ cfg, 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,
});
},
},
};

View File

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

View File

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

View File

@ -0,0 +1,159 @@
import type { ClawdbotConfig } from "openclaw/plugin-sdk";
import type { FeishuConfig } from "./types.js";
import { createFeishuClient } from "./client.js";
import { normalizeFeishuTarget } from "./targets.js";
export type FeishuDirectoryPeer = {
kind: "user";
id: string;
name?: string;
};
export type FeishuDirectoryGroup = {
kind: "group";
id: string;
name?: string;
};
export async function listFeishuDirectoryPeers(params: {
cfg: ClawdbotConfig;
query?: string;
limit?: number;
}): Promise<FeishuDirectoryPeer[]> {
const feishuCfg = params.cfg.channels?.feishu as FeishuConfig | undefined;
const q = params.query?.trim().toLowerCase() || "";
const ids = new Set<string>();
for (const entry of feishuCfg?.allowFrom ?? []) {
const trimmed = String(entry).trim();
if (trimmed && trimmed !== "*") ids.add(trimmed);
}
for (const userId of Object.keys(feishuCfg?.dms ?? {})) {
const trimmed = userId.trim();
if (trimmed) ids.add(trimmed);
}
return Array.from(ids)
.map((raw) => raw.trim())
.filter(Boolean)
.map((raw) => normalizeFeishuTarget(raw) ?? raw)
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
.slice(0, params.limit && params.limit > 0 ? params.limit : undefined)
.map((id) => ({ kind: "user" as const, id }));
}
export async function listFeishuDirectoryGroups(params: {
cfg: ClawdbotConfig;
query?: string;
limit?: number;
}): Promise<FeishuDirectoryGroup[]> {
const feishuCfg = params.cfg.channels?.feishu as FeishuConfig | undefined;
const q = params.query?.trim().toLowerCase() || "";
const ids = new Set<string>();
for (const groupId of Object.keys(feishuCfg?.groups ?? {})) {
const trimmed = groupId.trim();
if (trimmed && trimmed !== "*") ids.add(trimmed);
}
for (const entry of feishuCfg?.groupAllowFrom ?? []) {
const trimmed = String(entry).trim();
if (trimmed && trimmed !== "*") ids.add(trimmed);
}
return Array.from(ids)
.map((raw) => raw.trim())
.filter(Boolean)
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
.slice(0, params.limit && params.limit > 0 ? params.limit : undefined)
.map((id) => ({ kind: "group" as const, id }));
}
export async function listFeishuDirectoryPeersLive(params: {
cfg: ClawdbotConfig;
query?: string;
limit?: number;
}): Promise<FeishuDirectoryPeer[]> {
const feishuCfg = params.cfg.channels?.feishu as FeishuConfig | undefined;
if (!feishuCfg?.appId || !feishuCfg?.appSecret) {
return listFeishuDirectoryPeers(params);
}
try {
const client = createFeishuClient(feishuCfg);
const peers: FeishuDirectoryPeer[] = [];
const limit = params.limit ?? 50;
const response = await client.contact.user.list({
params: {
page_size: Math.min(limit, 50),
},
});
if (response.code === 0 && response.data?.items) {
for (const user of response.data.items) {
if (user.open_id) {
const q = params.query?.trim().toLowerCase() || "";
const name = user.name || "";
if (!q || user.open_id.toLowerCase().includes(q) || name.toLowerCase().includes(q)) {
peers.push({
kind: "user",
id: user.open_id,
name: name || undefined,
});
}
}
if (peers.length >= limit) break;
}
}
return peers;
} catch {
return listFeishuDirectoryPeers(params);
}
}
export async function listFeishuDirectoryGroupsLive(params: {
cfg: ClawdbotConfig;
query?: string;
limit?: number;
}): Promise<FeishuDirectoryGroup[]> {
const feishuCfg = params.cfg.channels?.feishu as FeishuConfig | undefined;
if (!feishuCfg?.appId || !feishuCfg?.appSecret) {
return listFeishuDirectoryGroups(params);
}
try {
const client = createFeishuClient(feishuCfg);
const groups: FeishuDirectoryGroup[] = [];
const limit = params.limit ?? 50;
const response = await client.im.chat.list({
params: {
page_size: Math.min(limit, 100),
},
});
if (response.code === 0 && response.data?.items) {
for (const chat of response.data.items) {
if (chat.chat_id) {
const q = params.query?.trim().toLowerCase() || "";
const name = chat.name || "";
if (!q || chat.chat_id.toLowerCase().includes(q) || name.toLowerCase().includes(q)) {
groups.push({
kind: "group",
id: chat.chat_id,
name: name || undefined,
});
}
}
if (groups.length >= limit) break;
}
}
return groups;
} catch {
return listFeishuDirectoryGroups(params);
}
}

View File

@ -0,0 +1,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<DownloadImageResult> {
const { cfg, imageKey } = params;
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
if (!feishuCfg) {
throw new Error("Feishu channel not configured");
}
const client = createFeishuClient(feishuCfg);
const response = await client.im.image.get({
path: { image_key: imageKey },
});
const responseAny = response as any;
if (responseAny.code !== undefined && responseAny.code !== 0) {
throw new Error(`Feishu image download failed: ${responseAny.msg || `code ${responseAny.code}`}`);
}
// Handle various response formats from Feishu SDK
let buffer: Buffer;
if (Buffer.isBuffer(response)) {
buffer = response;
} else if (response instanceof ArrayBuffer) {
buffer = Buffer.from(response);
} else if (responseAny.data && Buffer.isBuffer(responseAny.data)) {
buffer = responseAny.data;
} else if (responseAny.data instanceof ArrayBuffer) {
buffer = Buffer.from(responseAny.data);
} else if (typeof responseAny.getReadableStream === "function") {
// SDK provides getReadableStream method
const stream = responseAny.getReadableStream();
const chunks: Buffer[] = [];
for await (const chunk of stream) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
buffer = Buffer.concat(chunks);
} else if (typeof responseAny.writeFile === "function") {
// SDK provides writeFile method - use a temp file
const tmpPath = path.join(os.tmpdir(), `feishu_img_${Date.now()}_${imageKey}`);
await responseAny.writeFile(tmpPath);
buffer = await fs.promises.readFile(tmpPath);
await fs.promises.unlink(tmpPath).catch(() => {}); // cleanup
} else if (typeof responseAny[Symbol.asyncIterator] === "function") {
// Response is an async iterable
const chunks: Buffer[] = [];
for await (const chunk of responseAny) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
buffer = Buffer.concat(chunks);
} else if (typeof responseAny.read === "function") {
// Response is a Readable stream
const chunks: Buffer[] = [];
for await (const chunk of responseAny as Readable) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
buffer = Buffer.concat(chunks);
} else {
// Debug: log what we actually received
const keys = Object.keys(responseAny);
const types = keys.map(k => `${k}: ${typeof responseAny[k]}`).join(", ");
throw new Error(
`Feishu image download failed: unexpected response format. Keys: [${types}]`,
);
}
return { buffer };
}
/**
* Download a message resource (file/image/audio/video) from Feishu.
* Used for downloading files, audio, and video from messages.
*/
export async function downloadMessageResourceFeishu(params: {
cfg: ClawdbotConfig;
messageId: string;
fileKey: string;
type: "image" | "file";
}): Promise<DownloadMessageResourceResult> {
const { cfg, messageId, fileKey, type } = params;
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
if (!feishuCfg) {
throw new Error("Feishu channel not configured");
}
const client = createFeishuClient(feishuCfg);
const response = await client.im.messageResource.get({
path: { message_id: messageId, file_key: fileKey },
params: { type },
});
const responseAny = response as any;
if (responseAny.code !== undefined && responseAny.code !== 0) {
throw new Error(
`Feishu message resource download failed: ${responseAny.msg || `code ${responseAny.code}`}`,
);
}
// Handle various response formats from Feishu SDK
let buffer: Buffer;
if (Buffer.isBuffer(response)) {
buffer = response;
} else if (response instanceof ArrayBuffer) {
buffer = Buffer.from(response);
} else if (responseAny.data && Buffer.isBuffer(responseAny.data)) {
buffer = responseAny.data;
} else if (responseAny.data instanceof ArrayBuffer) {
buffer = Buffer.from(responseAny.data);
} else if (typeof responseAny.getReadableStream === "function") {
// SDK provides getReadableStream method
const stream = responseAny.getReadableStream();
const chunks: Buffer[] = [];
for await (const chunk of stream) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
buffer = Buffer.concat(chunks);
} else if (typeof responseAny.writeFile === "function") {
// SDK provides writeFile method - use a temp file
const tmpPath = path.join(os.tmpdir(), `feishu_${Date.now()}_${fileKey}`);
await responseAny.writeFile(tmpPath);
buffer = await fs.promises.readFile(tmpPath);
await fs.promises.unlink(tmpPath).catch(() => {}); // cleanup
} else if (typeof responseAny[Symbol.asyncIterator] === "function") {
// Response is an async iterable
const chunks: Buffer[] = [];
for await (const chunk of responseAny) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
buffer = Buffer.concat(chunks);
} else if (typeof responseAny.read === "function") {
// Response is a Readable stream
const chunks: Buffer[] = [];
for await (const chunk of responseAny as Readable) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
buffer = Buffer.concat(chunks);
} else {
// Debug: log what we actually received
const keys = Object.keys(responseAny);
const types = keys.map(k => `${k}: ${typeof responseAny[k]}`).join(", ");
throw new Error(
`Feishu message resource download failed: unexpected response format. Keys: [${types}]`,
);
}
return { buffer };
}
export type UploadImageResult = {
imageKey: string;
};
export type UploadFileResult = {
fileKey: string;
};
export type SendMediaResult = {
messageId: string;
chatId: string;
};
/**
* Upload an image to Feishu and get an image_key for sending.
* Supports: JPEG, PNG, WEBP, GIF, TIFF, BMP, ICO
*/
export async function uploadImageFeishu(params: {
cfg: ClawdbotConfig;
image: Buffer | string; // Buffer or file path
imageType?: "message" | "avatar";
}): Promise<UploadImageResult> {
const { cfg, image, imageType = "message" } = params;
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
if (!feishuCfg) {
throw new Error("Feishu channel not configured");
}
const client = createFeishuClient(feishuCfg);
// SDK expects a Readable stream, not a Buffer
// Use type assertion since SDK actually accepts any Readable at runtime
const imageStream =
typeof image === "string" ? fs.createReadStream(image) : Readable.from(image);
const response = await client.im.image.create({
data: {
image_type: imageType,
image: imageStream as any,
},
});
// SDK v1.30+ returns data directly without code wrapper on success
// On error, it throws or returns { code, msg }
const responseAny = response as any;
if (responseAny.code !== undefined && responseAny.code !== 0) {
throw new Error(`Feishu image upload failed: ${responseAny.msg || `code ${responseAny.code}`}`);
}
const imageKey = responseAny.image_key ?? responseAny.data?.image_key;
if (!imageKey) {
throw new Error("Feishu image upload failed: no image_key returned");
}
return { imageKey };
}
/**
* Upload a file to Feishu and get a file_key for sending.
* Max file size: 30MB
*/
export async function uploadFileFeishu(params: {
cfg: ClawdbotConfig;
file: Buffer | string; // Buffer or file path
fileName: string;
fileType: "opus" | "mp4" | "pdf" | "doc" | "xls" | "ppt" | "stream";
duration?: number; // Required for audio/video files, in milliseconds
}): Promise<UploadFileResult> {
const { cfg, file, fileName, fileType, duration } = params;
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
if (!feishuCfg) {
throw new Error("Feishu channel not configured");
}
const client = createFeishuClient(feishuCfg);
// SDK expects a Readable stream, not a Buffer
// Use type assertion since SDK actually accepts any Readable at runtime
const fileStream =
typeof file === "string" ? fs.createReadStream(file) : Readable.from(file);
const response = await client.im.file.create({
data: {
file_type: fileType,
file_name: fileName,
file: fileStream as any,
...(duration !== undefined && { duration }),
},
});
// SDK v1.30+ returns data directly without code wrapper on success
const responseAny = response as any;
if (responseAny.code !== undefined && responseAny.code !== 0) {
throw new Error(`Feishu file upload failed: ${responseAny.msg || `code ${responseAny.code}`}`);
}
const fileKey = responseAny.file_key ?? responseAny.data?.file_key;
if (!fileKey) {
throw new Error("Feishu file upload failed: no file_key returned");
}
return { fileKey };
}
/**
* Send an image message using an image_key
*/
export async function sendImageFeishu(params: {
cfg: ClawdbotConfig;
to: string;
imageKey: string;
replyToMessageId?: string;
}): Promise<SendMediaResult> {
const { cfg, to, imageKey, replyToMessageId } = params;
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
if (!feishuCfg) {
throw new Error("Feishu channel not configured");
}
const client = createFeishuClient(feishuCfg);
const receiveId = normalizeFeishuTarget(to);
if (!receiveId) {
throw new Error(`Invalid Feishu target: ${to}`);
}
const receiveIdType = resolveReceiveIdType(receiveId);
const content = JSON.stringify({ image_key: imageKey });
if (replyToMessageId) {
const response = await client.im.message.reply({
path: { message_id: replyToMessageId },
data: {
content,
msg_type: "image",
},
});
if (response.code !== 0) {
throw new Error(`Feishu image reply failed: ${response.msg || `code ${response.code}`}`);
}
return {
messageId: response.data?.message_id ?? "unknown",
chatId: receiveId,
};
}
const response = await client.im.message.create({
params: { receive_id_type: receiveIdType },
data: {
receive_id: receiveId,
content,
msg_type: "image",
},
});
if (response.code !== 0) {
throw new Error(`Feishu image send failed: ${response.msg || `code ${response.code}`}`);
}
return {
messageId: response.data?.message_id ?? "unknown",
chatId: receiveId,
};
}
/**
* Send a file message using a file_key
*/
export async function sendFileFeishu(params: {
cfg: ClawdbotConfig;
to: string;
fileKey: string;
replyToMessageId?: string;
}): Promise<SendMediaResult> {
const { cfg, to, fileKey, replyToMessageId } = params;
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
if (!feishuCfg) {
throw new Error("Feishu channel not configured");
}
const client = createFeishuClient(feishuCfg);
const receiveId = normalizeFeishuTarget(to);
if (!receiveId) {
throw new Error(`Invalid Feishu target: ${to}`);
}
const receiveIdType = resolveReceiveIdType(receiveId);
const content = JSON.stringify({ file_key: fileKey });
if (replyToMessageId) {
const response = await client.im.message.reply({
path: { message_id: replyToMessageId },
data: {
content,
msg_type: "file",
},
});
if (response.code !== 0) {
throw new Error(`Feishu file reply failed: ${response.msg || `code ${response.code}`}`);
}
return {
messageId: response.data?.message_id ?? "unknown",
chatId: receiveId,
};
}
const response = await client.im.message.create({
params: { receive_id_type: receiveIdType },
data: {
receive_id: receiveId,
content,
msg_type: "file",
},
});
if (response.code !== 0) {
throw new Error(`Feishu file send failed: ${response.msg || `code ${response.code}`}`);
}
return {
messageId: response.data?.message_id ?? "unknown",
chatId: receiveId,
};
}
/**
* Helper to detect file type from extension
*/
export function detectFileType(
fileName: string,
): "opus" | "mp4" | "pdf" | "doc" | "xls" | "ppt" | "stream" {
const ext = path.extname(fileName).toLowerCase();
switch (ext) {
case ".opus":
case ".ogg":
return "opus";
case ".mp4":
case ".mov":
case ".avi":
return "mp4";
case ".pdf":
return "pdf";
case ".doc":
case ".docx":
return "doc";
case ".xls":
case ".xlsx":
return "xls";
case ".ppt":
case ".pptx":
return "ppt";
default:
return "stream";
}
}
/**
* Check if a string is a local file path (not a URL)
*/
function isLocalPath(urlOrPath: string): boolean {
// Starts with / or ~ or drive letter (Windows)
if (urlOrPath.startsWith("/") || urlOrPath.startsWith("~") || /^[a-zA-Z]:/.test(urlOrPath)) {
return true;
}
// Try to parse as URL - if it fails or has no protocol, it's likely a local path
try {
const url = new URL(urlOrPath);
return url.protocol === "file:";
} catch {
return true; // Not a valid URL, treat as local path
}
}
/**
* Upload and send media (image or file) from URL, local path, or buffer
*/
export async function sendMediaFeishu(params: {
cfg: ClawdbotConfig;
to: string;
mediaUrl?: string;
mediaBuffer?: Buffer;
fileName?: string;
replyToMessageId?: string;
}): Promise<SendMediaResult> {
const { cfg, to, mediaUrl, mediaBuffer, fileName, replyToMessageId } = params;
let buffer: Buffer;
let name: string;
if (mediaBuffer) {
buffer = mediaBuffer;
name = fileName ?? "file";
} else if (mediaUrl) {
if (isLocalPath(mediaUrl)) {
// Local file path - read directly
const filePath = mediaUrl.startsWith("~")
? mediaUrl.replace("~", process.env.HOME ?? "")
: mediaUrl.replace("file://", "");
if (!fs.existsSync(filePath)) {
throw new Error(`Local file not found: ${filePath}`);
}
buffer = fs.readFileSync(filePath);
name = fileName ?? path.basename(filePath);
} else {
// Remote URL - fetch
const response = await fetch(mediaUrl);
if (!response.ok) {
throw new Error(`Failed to fetch media from URL: ${response.status}`);
}
buffer = Buffer.from(await response.arrayBuffer());
name = fileName ?? (path.basename(new URL(mediaUrl).pathname) || "file");
}
} else {
throw new Error("Either mediaUrl or mediaBuffer must be provided");
}
// Determine if it's an image based on extension
const ext = path.extname(name).toLowerCase();
const isImage = [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".ico", ".tiff"].includes(ext);
if (isImage) {
const { imageKey } = await uploadImageFeishu({ cfg, image: buffer });
return sendImageFeishu({ cfg, to, imageKey, replyToMessageId });
} else {
const fileType = detectFileType(name);
const { fileKey } = await uploadFileFeishu({
cfg,
file: buffer,
fileName: name,
fileType,
});
return sendFileFeishu({ cfg, to, fileKey, replyToMessageId });
}
}

View File

@ -0,0 +1,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<string | undefined> {
try {
const result = await probeFeishu(cfg);
return result.ok ? result.botOpenId : undefined;
} catch {
return undefined;
}
}
export async function monitorFeishuProvider(opts: MonitorFeishuOpts = {}): Promise<void> {
const cfg = opts.config;
if (!cfg) {
throw new Error("Config is required for Feishu monitor");
}
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
const creds = resolveFeishuCredentials(feishuCfg);
if (!creds) {
throw new Error("Feishu credentials not configured (appId, appSecret required)");
}
const log = opts.runtime?.log ?? console.log;
const error = opts.runtime?.error ?? console.error;
if (feishuCfg) {
botOpenId = await fetchBotOpenId(feishuCfg);
log(`feishu: bot open_id resolved: ${botOpenId ?? "unknown"}`);
}
const connectionMode = feishuCfg?.connectionMode ?? "websocket";
if (connectionMode === "websocket") {
return monitorWebSocket({ cfg, feishuCfg: feishuCfg!, runtime: opts.runtime, abortSignal: opts.abortSignal });
}
log("feishu: webhook mode not implemented in monitor, use HTTP server directly");
}
async function monitorWebSocket(params: {
cfg: ClawdbotConfig;
feishuCfg: FeishuConfig;
runtime?: RuntimeEnv;
abortSignal?: AbortSignal;
}): Promise<void> {
const { cfg, feishuCfg, runtime, abortSignal } = params;
const log = runtime?.log ?? console.log;
const error = runtime?.error ?? console.error;
log("feishu: starting WebSocket connection...");
const wsClient = createFeishuWSClient(feishuCfg);
currentWsClient = wsClient;
const chatHistories = new Map<string, HistoryEntry[]>();
const eventDispatcher = createEventDispatcher(feishuCfg);
eventDispatcher.register({
"im.message.receive_v1": async (data) => {
try {
const event = data as unknown as FeishuMessageEvent;
await handleFeishuMessage({
cfg,
event,
botOpenId,
runtime,
chatHistories,
});
} catch (err) {
error(`feishu: error handling message event: ${String(err)}`);
}
},
"im.message.message_read_v1": async () => {
// Ignore read receipts
},
"im.chat.member.bot.added_v1": async (data) => {
try {
const event = data as unknown as FeishuBotAddedEvent;
log(`feishu: bot added to chat ${event.chat_id}`);
} catch (err) {
error(`feishu: error handling bot added event: ${String(err)}`);
}
},
"im.chat.member.bot.deleted_v1": async (data) => {
try {
const event = data as unknown as { chat_id: string };
log(`feishu: bot removed from chat ${event.chat_id}`);
} catch (err) {
error(`feishu: error handling bot removed event: ${String(err)}`);
}
},
});
return new Promise((resolve, reject) => {
const cleanup = () => {
if (currentWsClient === wsClient) {
currentWsClient = null;
}
};
const handleAbort = () => {
log("feishu: abort signal received, stopping WebSocket client");
cleanup();
resolve();
};
if (abortSignal?.aborted) {
cleanup();
resolve();
return;
}
abortSignal?.addEventListener("abort", handleAbort, { once: true });
try {
wsClient.start({
eventDispatcher,
});
log("feishu: WebSocket client started");
} catch (err) {
cleanup();
abortSignal?.removeEventListener("abort", handleAbort);
reject(err);
}
});
}
export function stopFeishuMonitor(): void {
if (currentWsClient) {
currentWsClient = null;
}
}

View File

@ -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<ClawdbotConfig> {
const existing = params.cfg.channels?.feishu?.allowFrom ?? [];
await params.prompter.note(
[
"Allowlist Feishu DMs by open_id or user_id.",
"You can find user open_id in Feishu admin console or via API.",
"Examples:",
"- ou_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"- on_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
].join("\n"),
"Feishu allowlist",
);
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<void> {
await prompter.note(
[
"1) Go to Feishu Open Platform (open.feishu.cn)",
"2) Create a self-built app",
"3) Get App ID and App Secret from Credentials page",
"4) Enable required permissions: im:message, im:chat, contact:user.base:readonly",
"5) Publish the app or add it to a test group",
"Tip: you can also set FEISHU_APP_ID / FEISHU_APP_SECRET env vars.",
`Docs: ${formatDocsLink("/channels/feishu", "feishu")}`,
].join("\n"),
"Feishu credentials",
);
}
function setFeishuGroupPolicy(
cfg: ClawdbotConfig,
groupPolicy: "open" | "allowlist" | "disabled",
): ClawdbotConfig {
return {
...cfg,
channels: {
...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 },
},
}),
};

View File

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

View File

@ -0,0 +1,92 @@
import type { ChannelGroupContext, GroupToolPolicyConfig } from "openclaw/plugin-sdk";
import type { FeishuConfig, FeishuGroupConfig } from "./types.js";
export type FeishuAllowlistMatch = {
allowed: boolean;
matchKey?: string;
matchSource?: "wildcard" | "id" | "name";
};
export function resolveFeishuAllowlistMatch(params: {
allowFrom: Array<string | number>;
senderId: string;
senderName?: string | null;
}): FeishuAllowlistMatch {
const allowFrom = params.allowFrom
.map((entry) => String(entry).trim().toLowerCase())
.filter(Boolean);
if (allowFrom.length === 0) return { allowed: false };
if (allowFrom.includes("*")) {
return { allowed: true, matchKey: "*", matchSource: "wildcard" };
}
const senderId = params.senderId.toLowerCase();
if (allowFrom.includes(senderId)) {
return { allowed: true, matchKey: senderId, matchSource: "id" };
}
const senderName = params.senderName?.toLowerCase();
if (senderName && allowFrom.includes(senderName)) {
return { allowed: true, matchKey: senderName, matchSource: "name" };
}
return { allowed: false };
}
export function resolveFeishuGroupConfig(params: {
cfg?: FeishuConfig;
groupId?: string | null;
}): FeishuGroupConfig | undefined {
const groups = params.cfg?.groups ?? {};
const groupId = params.groupId?.trim();
if (!groupId) return undefined;
const direct = groups[groupId] as FeishuGroupConfig | undefined;
if (direct) return direct;
const lowered = groupId.toLowerCase();
const matchKey = Object.keys(groups).find((key) => key.toLowerCase() === lowered);
return matchKey ? (groups[matchKey] as FeishuGroupConfig | undefined) : undefined;
}
export function resolveFeishuGroupToolPolicy(
params: ChannelGroupContext,
): GroupToolPolicyConfig | undefined {
const cfg = params.cfg.channels?.feishu as FeishuConfig | undefined;
if (!cfg) return undefined;
const groupConfig = resolveFeishuGroupConfig({
cfg,
groupId: params.groupId,
});
return groupConfig?.tools;
}
export function isFeishuGroupAllowed(params: {
groupPolicy: "open" | "allowlist" | "disabled";
allowFrom: Array<string | number>;
senderId: string;
senderName?: string | null;
}): boolean {
const { groupPolicy } = params;
if (groupPolicy === "disabled") return false;
if (groupPolicy === "open") return true;
return resolveFeishuAllowlistMatch(params).allowed;
}
export function resolveFeishuReplyPolicy(params: {
isDirectMessage: boolean;
globalConfig?: FeishuConfig;
groupConfig?: FeishuGroupConfig;
}): { requireMention: boolean } {
if (params.isDirectMessage) {
return { requireMention: false };
}
const requireMention =
params.groupConfig?.requireMention ?? params.globalConfig?.requireMention ?? true;
return { requireMention };
}

View File

@ -0,0 +1,46 @@
import type { FeishuConfig, FeishuProbeResult } from "./types.js";
import { createFeishuClient } from "./client.js";
import { resolveFeishuCredentials } from "./accounts.js";
export async function probeFeishu(cfg?: FeishuConfig): Promise<FeishuProbeResult> {
const creds = resolveFeishuCredentials(cfg);
if (!creds) {
return {
ok: false,
error: "missing credentials (appId, appSecret)",
};
}
try {
const client = createFeishuClient(cfg!);
// Use im.chat.list as a simple connectivity test
// The bot info API path varies by SDK version
const response = await (client as any).request({
method: "GET",
url: "/open-apis/bot/v3/info",
data: {},
});
if (response.code !== 0) {
return {
ok: false,
appId: creds.appId,
error: `API error: ${response.msg || `code ${response.code}`}`,
};
}
const bot = response.bot || response.data?.bot;
return {
ok: true,
appId: creds.appId,
botName: bot?.bot_name,
botOpenId: bot?.open_id,
};
} catch (err) {
return {
ok: false,
appId: creds.appId,
error: err instanceof Error ? err.message : String(err),
};
}
}

View File

@ -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<void> {
const { cfg, messageId, reactionId } = params;
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
if (!feishuCfg) {
throw new Error("Feishu channel not configured");
}
const client = createFeishuClient(feishuCfg);
const response = (await client.im.messageReaction.delete({
path: {
message_id: messageId,
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<FeishuReaction[]> {
const { cfg, messageId, emojiType } = params;
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
if (!feishuCfg) {
throw new Error("Feishu channel not configured");
}
const client = createFeishuClient(feishuCfg);
const response = (await client.im.messageReaction.list({
path: { message_id: messageId },
params: emojiType ? { reaction_type: emojiType } : undefined,
})) 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];

View File

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

View File

@ -0,0 +1,14 @@
import type { PluginRuntime } from "openclaw/plugin-sdk";
let runtime: PluginRuntime | null = null;
export function setFeishuRuntime(next: PluginRuntime) {
runtime = next;
}
export function getFeishuRuntime(): PluginRuntime {
if (!runtime) {
throw new Error("Feishu runtime not initialized");
}
return runtime;
}

View File

@ -0,0 +1,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<FeishuMessageInfo | null> {
const { cfg, messageId } = params;
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
if (!feishuCfg) {
throw new Error("Feishu channel not configured");
}
const client = createFeishuClient(feishuCfg);
try {
const response = (await client.im.message.get({
path: { message_id: messageId },
})) as {
code?: number;
msg?: string;
data?: {
items?: Array<{
message_id?: string;
chat_id?: string;
msg_type?: string;
body?: { content?: string };
sender?: {
id?: string;
id_type?: string;
sender_type?: string;
};
create_time?: string;
}>;
};
};
if (response.code !== 0) {
return null;
}
const item = response.data?.items?.[0];
if (!item) {
return null;
}
// Parse content based on message type
let content = item.body?.content ?? "";
try {
const parsed = JSON.parse(content);
if (item.msg_type === "text" && parsed.text) {
content = parsed.text;
}
} catch {
// Keep raw content if parsing fails
}
return {
messageId: item.message_id ?? messageId,
chatId: item.chat_id ?? "",
senderId: item.sender?.id,
senderOpenId: item.sender?.id_type === "open_id" ? item.sender?.id : undefined,
content,
contentType: item.msg_type ?? "text",
createTime: item.create_time ? parseInt(item.create_time, 10) : undefined,
};
} catch {
return null;
}
}
export type SendFeishuMessageParams = {
cfg: ClawdbotConfig;
to: string;
text: string;
replyToMessageId?: string;
};
export async function sendMessageFeishu(params: SendFeishuMessageParams): Promise<FeishuSendResult> {
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<string, unknown>;
replyToMessageId?: string;
};
export async function sendCardFeishu(params: SendFeishuCardParams): Promise<FeishuSendResult> {
const { cfg, to, card, replyToMessageId } = params;
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
if (!feishuCfg) {
throw new Error("Feishu channel not configured");
}
const client = createFeishuClient(feishuCfg);
const receiveId = normalizeFeishuTarget(to);
if (!receiveId) {
throw new Error(`Invalid Feishu target: ${to}`);
}
const receiveIdType = resolveReceiveIdType(receiveId);
const content = JSON.stringify(card);
if (replyToMessageId) {
const response = await client.im.message.reply({
path: { message_id: replyToMessageId },
data: {
content,
msg_type: "interactive",
},
});
if (response.code !== 0) {
throw new Error(`Feishu card reply failed: ${response.msg || `code ${response.code}`}`);
}
return {
messageId: response.data?.message_id ?? "unknown",
chatId: receiveId,
};
}
const response = await client.im.message.create({
params: { receive_id_type: receiveIdType },
data: {
receive_id: receiveId,
content,
msg_type: "interactive",
},
});
if (response.code !== 0) {
throw new Error(`Feishu card send failed: ${response.msg || `code ${response.code}`}`);
}
return {
messageId: response.data?.message_id ?? "unknown",
chatId: receiveId,
};
}
export async function updateCardFeishu(params: {
cfg: ClawdbotConfig;
messageId: string;
card: Record<string, unknown>;
}): Promise<void> {
const { cfg, messageId, card } = params;
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
if (!feishuCfg) {
throw new Error("Feishu channel not configured");
}
const client = createFeishuClient(feishuCfg);
const content = JSON.stringify(card);
const response = await client.im.message.patch({
path: { message_id: messageId },
data: { content },
});
if (response.code !== 0) {
throw new Error(`Feishu card update failed: ${response.msg || `code ${response.code}`}`);
}
}
/**
* Build a Feishu interactive card with markdown content.
* Cards render markdown properly (code blocks, tables, links, etc.)
*/
export function buildMarkdownCard(text: string): Record<string, unknown> {
return {
config: {
wide_screen_mode: true,
},
elements: [
{
tag: "markdown",
content: text,
},
],
};
}
/**
* Send a message as a markdown card (interactive message).
* This renders markdown properly in Feishu (code blocks, tables, bold/italic, etc.)
*/
export async function sendMarkdownCardFeishu(params: {
cfg: ClawdbotConfig;
to: string;
text: string;
replyToMessageId?: string;
}): Promise<FeishuSendResult> {
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<void> {
const { cfg, messageId, text } = params;
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
if (!feishuCfg) {
throw new Error("Feishu channel not configured");
}
const client = createFeishuClient(feishuCfg);
const tableMode = getFeishuRuntime().channel.text.resolveMarkdownTableMode({
cfg,
channel: "feishu",
});
const messageText = getFeishuRuntime().channel.text.convertMarkdownTables(text ?? "", tableMode);
const content = 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}`}`);
}
}

View File

@ -0,0 +1,58 @@
import type { FeishuIdType } from "./types.js";
const CHAT_ID_PREFIX = "oc_";
const OPEN_ID_PREFIX = "ou_";
const USER_ID_REGEX = /^[a-zA-Z0-9_-]+$/;
export function detectIdType(id: string): FeishuIdType | null {
const trimmed = id.trim();
if (trimmed.startsWith(CHAT_ID_PREFIX)) return "chat_id";
if (trimmed.startsWith(OPEN_ID_PREFIX)) return "open_id";
if (USER_ID_REGEX.test(trimmed)) return "user_id";
return null;
}
export function normalizeFeishuTarget(raw: string): string | null {
const trimmed = raw.trim();
if (!trimmed) return null;
const lowered = trimmed.toLowerCase();
if (lowered.startsWith("chat:")) {
return trimmed.slice("chat:".length).trim() || null;
}
if (lowered.startsWith("user:")) {
return trimmed.slice("user:".length).trim() || null;
}
if (lowered.startsWith("open_id:")) {
return trimmed.slice("open_id:".length).trim() || null;
}
return trimmed;
}
export function formatFeishuTarget(id: string, type?: FeishuIdType): string {
const trimmed = id.trim();
if (type === "chat_id" || trimmed.startsWith(CHAT_ID_PREFIX)) {
return `chat:${trimmed}`;
}
if (type === "open_id" || trimmed.startsWith(OPEN_ID_PREFIX)) {
return `user:${trimmed}`;
}
return trimmed;
}
export function resolveReceiveIdType(id: string): "chat_id" | "open_id" | "user_id" {
const trimmed = id.trim();
if (trimmed.startsWith(CHAT_ID_PREFIX)) return "chat_id";
if (trimmed.startsWith(OPEN_ID_PREFIX)) return "open_id";
return "open_id";
}
export function looksLikeFeishuId(raw: string): boolean {
const trimmed = raw.trim();
if (!trimmed) return false;
if (/^(chat|user|open_id):/i.test(trimmed)) return true;
if (trimmed.startsWith(CHAT_ID_PREFIX)) return true;
if (trimmed.startsWith(OPEN_ID_PREFIX)) return true;
return false;
}

View File

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

View File

@ -0,0 +1,73 @@
import type { ClawdbotConfig } from "openclaw/plugin-sdk";
import type { FeishuConfig } from "./types.js";
import { createFeishuClient } from "./client.js";
// Feishu emoji types for typing indicator
// See: https://open.feishu.cn/document/server-docs/im-v1/message-reaction/emojis-introduce
// Full list: https://github.com/go-lark/lark/blob/main/emoji.go
const TYPING_EMOJI = "Typing"; // Typing indicator emoji
export type TypingIndicatorState = {
messageId: string;
reactionId: string | null;
};
/**
* Add a typing indicator (reaction) to a message
*/
export async function addTypingIndicator(params: {
cfg: ClawdbotConfig;
messageId: string;
}): Promise<TypingIndicatorState> {
const { cfg, messageId } = params;
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
if (!feishuCfg) {
return { messageId, reactionId: null };
}
const client = createFeishuClient(feishuCfg);
try {
const response = await client.im.messageReaction.create({
path: { message_id: messageId },
data: {
reaction_type: { emoji_type: TYPING_EMOJI },
},
});
const reactionId = (response as any)?.data?.reaction_id ?? null;
return { messageId, reactionId };
} catch (err) {
// Silently fail - typing indicator is not critical
console.log(`[feishu] failed to add typing indicator: ${err}`);
return { messageId, reactionId: null };
}
}
/**
* Remove a typing indicator (reaction) from a message
*/
export async function removeTypingIndicator(params: {
cfg: ClawdbotConfig;
state: TypingIndicatorState;
}): Promise<void> {
const { cfg, state } = params;
if (!state.reactionId) return;
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
if (!feishuCfg) return;
const client = createFeishuClient(feishuCfg);
try {
await client.im.messageReaction.delete({
path: {
message_id: state.messageId,
reaction_id: state.reactionId,
},
});
} catch (err) {
// Silently fail - cleanup is not critical
console.log(`[feishu] failed to remove typing indicator: ${err}`);
}
}

49
pnpm-lock.yaml generated
View File

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