feat(feishu): replace built-in SDK with community plugin

Replace the built-in Feishu SDK with the community-maintained
clawdbot-feishu plugin by @m1heng.

Changes:
- Remove src/feishu/ directory (19 files)
- Remove src/channels/plugins/outbound/feishu.ts
- Remove src/channels/plugins/normalize/feishu.ts
- Remove src/config/types.feishu.ts
- Remove feishu exports from plugin-sdk/index.ts
- Remove FeishuConfig from types.channels.ts

New features in community plugin:
- Document tools (read/create/edit Feishu docs)
- Wiki tools (navigate/manage knowledge base)
- Drive tools (folder/file management)
- Bitable tools (read/write table records)
- Permission tools (collaborator management)
- Emoji reactions support
- Typing indicators
- Rich media support (bidirectional image/file transfer)
- @mention handling
- Skills for feishu-doc, feishu-wiki, feishu-drive, feishu-perm

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Yifeng Wang
2026-02-05 18:26:05 +08:00
committed by cpojer
parent 02842bef91
commit 2267d58afc
66 changed files with 5702 additions and 4486 deletions

View File

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

View File

@@ -0,0 +1,443 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { Type } from "@sinclair/typebox";
import type { FeishuConfig } from "./types.js";
import { createFeishuClient } from "./client.js";
// ============ Helpers ============
function json(data: unknown) {
return {
content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }],
details: data,
};
}
/** Field type ID to human-readable name */
const FIELD_TYPE_NAMES: Record<number, string> = {
1: "Text",
2: "Number",
3: "SingleSelect",
4: "MultiSelect",
5: "DateTime",
7: "Checkbox",
11: "User",
13: "Phone",
15: "URL",
17: "Attachment",
18: "SingleLink",
19: "Lookup",
20: "Formula",
21: "DuplexLink",
22: "Location",
23: "GroupChat",
1001: "CreatedTime",
1002: "ModifiedTime",
1003: "CreatedUser",
1004: "ModifiedUser",
1005: "AutoNumber",
};
// ============ Core Functions ============
/** Parse bitable URL and extract tokens */
function parseBitableUrl(url: string): { token: string; tableId?: string; isWiki: boolean } | null {
try {
const u = new URL(url);
const tableId = u.searchParams.get("table") ?? undefined;
// Wiki format: /wiki/XXXXX?table=YYY
const wikiMatch = u.pathname.match(/\/wiki\/([A-Za-z0-9]+)/);
if (wikiMatch) {
return { token: wikiMatch[1], tableId, isWiki: true };
}
// Base format: /base/XXXXX?table=YYY
const baseMatch = u.pathname.match(/\/base\/([A-Za-z0-9]+)/);
if (baseMatch) {
return { token: baseMatch[1], tableId, isWiki: false };
}
return null;
} catch {
return null;
}
}
/** Get app_token from wiki node_token */
async function getAppTokenFromWiki(
client: ReturnType<typeof createFeishuClient>,
nodeToken: string,
): Promise<string> {
const res = await client.wiki.space.getNode({
params: { token: nodeToken },
});
if (res.code !== 0) throw new Error(res.msg);
const node = res.data?.node;
if (!node) throw new Error("Node not found");
if (node.obj_type !== "bitable") {
throw new Error(`Node is not a bitable (type: ${node.obj_type})`);
}
return node.obj_token!;
}
/** Get bitable metadata from URL (handles both /base/ and /wiki/ URLs) */
async function getBitableMeta(client: ReturnType<typeof createFeishuClient>, url: string) {
const parsed = parseBitableUrl(url);
if (!parsed) {
throw new Error("Invalid URL format. Expected /base/XXX or /wiki/XXX URL");
}
let appToken: string;
if (parsed.isWiki) {
appToken = await getAppTokenFromWiki(client, parsed.token);
} else {
appToken = parsed.token;
}
// Get bitable app info
const res = await client.bitable.app.get({
path: { app_token: appToken },
});
if (res.code !== 0) throw new Error(res.msg);
// List tables if no table_id specified
let tables: { table_id: string; name: string }[] = [];
if (!parsed.tableId) {
const tablesRes = await client.bitable.appTable.list({
path: { app_token: appToken },
});
if (tablesRes.code === 0) {
tables = (tablesRes.data?.items ?? []).map((t) => ({
table_id: t.table_id!,
name: t.name!,
}));
}
}
return {
app_token: appToken,
table_id: parsed.tableId,
name: res.data?.app?.name,
url_type: parsed.isWiki ? "wiki" : "base",
...(tables.length > 0 && { tables }),
hint: parsed.tableId
? `Use app_token="${appToken}" and table_id="${parsed.tableId}" for other bitable tools`
: `Use app_token="${appToken}" for other bitable tools. Select a table_id from the tables list.`,
};
}
async function listFields(
client: ReturnType<typeof createFeishuClient>,
appToken: string,
tableId: string,
) {
const res = await client.bitable.appTableField.list({
path: { app_token: appToken, table_id: tableId },
});
if (res.code !== 0) throw new Error(res.msg);
const fields = res.data?.items ?? [];
return {
fields: fields.map((f) => ({
field_id: f.field_id,
field_name: f.field_name,
type: f.type,
type_name: FIELD_TYPE_NAMES[f.type ?? 0] || `type_${f.type}`,
is_primary: f.is_primary,
...(f.property && { property: f.property }),
})),
total: fields.length,
};
}
async function listRecords(
client: ReturnType<typeof createFeishuClient>,
appToken: string,
tableId: string,
pageSize?: number,
pageToken?: string,
) {
const res = await client.bitable.appTableRecord.list({
path: { app_token: appToken, table_id: tableId },
params: {
page_size: pageSize ?? 100,
...(pageToken && { page_token: pageToken }),
},
});
if (res.code !== 0) throw new Error(res.msg);
return {
records: res.data?.items ?? [],
has_more: res.data?.has_more ?? false,
page_token: res.data?.page_token,
total: res.data?.total,
};
}
async function getRecord(
client: ReturnType<typeof createFeishuClient>,
appToken: string,
tableId: string,
recordId: string,
) {
const res = await client.bitable.appTableRecord.get({
path: { app_token: appToken, table_id: tableId, record_id: recordId },
});
if (res.code !== 0) throw new Error(res.msg);
return {
record: res.data?.record,
};
}
async function createRecord(
client: ReturnType<typeof createFeishuClient>,
appToken: string,
tableId: string,
fields: Record<string, unknown>,
) {
const res = await client.bitable.appTableRecord.create({
path: { app_token: appToken, table_id: tableId },
data: { fields },
});
if (res.code !== 0) throw new Error(res.msg);
return {
record: res.data?.record,
};
}
async function updateRecord(
client: ReturnType<typeof createFeishuClient>,
appToken: string,
tableId: string,
recordId: string,
fields: Record<string, unknown>,
) {
const res = await client.bitable.appTableRecord.update({
path: { app_token: appToken, table_id: tableId, record_id: recordId },
data: { fields },
});
if (res.code !== 0) throw new Error(res.msg);
return {
record: res.data?.record,
};
}
// ============ Schemas ============
const GetMetaSchema = Type.Object({
url: Type.String({
description: "Bitable URL. Supports both formats: /base/XXX?table=YYY or /wiki/XXX?table=YYY",
}),
});
const ListFieldsSchema = Type.Object({
app_token: Type.String({
description: "Bitable app token (use feishu_bitable_get_meta to get from URL)",
}),
table_id: Type.String({ description: "Table ID (from URL: ?table=YYY)" }),
});
const ListRecordsSchema = Type.Object({
app_token: Type.String({
description: "Bitable app token (use feishu_bitable_get_meta to get from URL)",
}),
table_id: Type.String({ description: "Table ID (from URL: ?table=YYY)" }),
page_size: Type.Optional(
Type.Number({
description: "Number of records per page (1-500, default 100)",
minimum: 1,
maximum: 500,
}),
),
page_token: Type.Optional(
Type.String({ description: "Pagination token from previous response" }),
),
});
const GetRecordSchema = Type.Object({
app_token: Type.String({
description: "Bitable app token (use feishu_bitable_get_meta to get from URL)",
}),
table_id: Type.String({ description: "Table ID (from URL: ?table=YYY)" }),
record_id: Type.String({ description: "Record ID to retrieve" }),
});
const CreateRecordSchema = Type.Object({
app_token: Type.String({
description: "Bitable app token (use feishu_bitable_get_meta to get from URL)",
}),
table_id: Type.String({ description: "Table ID (from URL: ?table=YYY)" }),
fields: Type.Record(Type.String(), Type.Any(), {
description:
"Field values keyed by field name. Format by type: Text='string', Number=123, SingleSelect='Option', MultiSelect=['A','B'], DateTime=timestamp_ms, User=[{id:'ou_xxx'}], URL={text:'Display',link:'https://...'}",
}),
});
const UpdateRecordSchema = Type.Object({
app_token: Type.String({
description: "Bitable app token (use feishu_bitable_get_meta to get from URL)",
}),
table_id: Type.String({ description: "Table ID (from URL: ?table=YYY)" }),
record_id: Type.String({ description: "Record ID to update" }),
fields: Type.Record(Type.String(), Type.Any(), {
description: "Field values to update (same format as create_record)",
}),
});
// ============ Tool Registration ============
export function registerFeishuBitableTools(api: OpenClawPluginApi) {
const feishuCfg = api.config?.channels?.feishu as FeishuConfig | undefined;
if (!feishuCfg?.appId || !feishuCfg?.appSecret) {
api.logger.debug?.("feishu_bitable: Feishu credentials not configured, skipping bitable tools");
return;
}
const getClient = () => createFeishuClient(feishuCfg);
// Tool 0: feishu_bitable_get_meta (helper to parse URLs)
api.registerTool(
{
name: "feishu_bitable_get_meta",
label: "Feishu Bitable Get Meta",
description:
"Parse a Bitable URL and get app_token, table_id, and table list. Use this first when given a /wiki/ or /base/ URL.",
parameters: GetMetaSchema,
async execute(_toolCallId, params) {
const { url } = params as { url: string };
try {
const result = await getBitableMeta(getClient(), url);
return json(result);
} catch (err) {
return json({ error: err instanceof Error ? err.message : String(err) });
}
},
},
{ name: "feishu_bitable_get_meta" },
);
// Tool 1: feishu_bitable_list_fields
api.registerTool(
{
name: "feishu_bitable_list_fields",
label: "Feishu Bitable List Fields",
description: "List all fields (columns) in a Bitable table with their types and properties",
parameters: ListFieldsSchema,
async execute(_toolCallId, params) {
const { app_token, table_id } = params as { app_token: string; table_id: string };
try {
const result = await listFields(getClient(), app_token, table_id);
return json(result);
} catch (err) {
return json({ error: err instanceof Error ? err.message : String(err) });
}
},
},
{ name: "feishu_bitable_list_fields" },
);
// Tool 2: feishu_bitable_list_records
api.registerTool(
{
name: "feishu_bitable_list_records",
label: "Feishu Bitable List Records",
description: "List records (rows) from a Bitable table with pagination support",
parameters: ListRecordsSchema,
async execute(_toolCallId, params) {
const { app_token, table_id, page_size, page_token } = params as {
app_token: string;
table_id: string;
page_size?: number;
page_token?: string;
};
try {
const result = await listRecords(getClient(), app_token, table_id, page_size, page_token);
return json(result);
} catch (err) {
return json({ error: err instanceof Error ? err.message : String(err) });
}
},
},
{ name: "feishu_bitable_list_records" },
);
// Tool 3: feishu_bitable_get_record
api.registerTool(
{
name: "feishu_bitable_get_record",
label: "Feishu Bitable Get Record",
description: "Get a single record by ID from a Bitable table",
parameters: GetRecordSchema,
async execute(_toolCallId, params) {
const { app_token, table_id, record_id } = params as {
app_token: string;
table_id: string;
record_id: string;
};
try {
const result = await getRecord(getClient(), app_token, table_id, record_id);
return json(result);
} catch (err) {
return json({ error: err instanceof Error ? err.message : String(err) });
}
},
},
{ name: "feishu_bitable_get_record" },
);
// Tool 4: feishu_bitable_create_record
api.registerTool(
{
name: "feishu_bitable_create_record",
label: "Feishu Bitable Create Record",
description: "Create a new record (row) in a Bitable table",
parameters: CreateRecordSchema,
async execute(_toolCallId, params) {
const { app_token, table_id, fields } = params as {
app_token: string;
table_id: string;
fields: Record<string, unknown>;
};
try {
const result = await createRecord(getClient(), app_token, table_id, fields);
return json(result);
} catch (err) {
return json({ error: err instanceof Error ? err.message : String(err) });
}
},
},
{ name: "feishu_bitable_create_record" },
);
// Tool 5: feishu_bitable_update_record
api.registerTool(
{
name: "feishu_bitable_update_record",
label: "Feishu Bitable Update Record",
description: "Update an existing record (row) in a Bitable table",
parameters: UpdateRecordSchema,
async execute(_toolCallId, params) {
const { app_token, table_id, record_id, fields } = params as {
app_token: string;
table_id: string;
record_id: string;
fields: Record<string, unknown>;
};
try {
const result = await updateRecord(getClient(), app_token, table_id, record_id, fields);
return json(result);
} catch (err) {
return json({ error: err instanceof Error ? err.message : String(err) });
}
},
},
{ name: "feishu_bitable_update_record" },
);
api.logger.info?.(`feishu_bitable: Registered 6 bitable tools`);
}

View File

@@ -0,0 +1,823 @@
import type { ClawdbotConfig, RuntimeEnv } from "openclaw/plugin-sdk";
import {
buildPendingHistoryContextFromMap,
recordPendingHistoryEntryIfEnabled,
clearHistoryEntriesIfEnabled,
DEFAULT_GROUP_HISTORY_LIMIT,
type HistoryEntry,
} from "openclaw/plugin-sdk";
import type { FeishuConfig, FeishuMessageContext, FeishuMediaInfo } from "./types.js";
import { createFeishuClient } from "./client.js";
import { downloadImageFeishu, downloadMessageResourceFeishu } from "./media.js";
import { extractMentionTargets, extractMessageBody, isMentionForwardRequest } from "./mention.js";
import {
resolveFeishuGroupConfig,
resolveFeishuReplyPolicy,
resolveFeishuAllowlistMatch,
isFeishuGroupAllowed,
} from "./policy.js";
import { createFeishuReplyDispatcher } from "./reply-dispatcher.js";
import { getFeishuRuntime } from "./runtime.js";
import { getMessageFeishu } from "./send.js";
// --- Permission error extraction ---
// Extract permission grant URL from Feishu API error response.
type PermissionError = {
code: number;
message: string;
grantUrl?: string;
};
function extractPermissionError(err: unknown): PermissionError | null {
if (!err || typeof err !== "object") return null;
// Axios error structure: err.response.data contains the Feishu error
const axiosErr = err as { response?: { data?: unknown } };
const data = axiosErr.response?.data;
if (!data || typeof data !== "object") return null;
const feishuErr = data as {
code?: number;
msg?: string;
error?: { permission_violations?: Array<{ uri?: string }> };
};
// Feishu permission error code: 99991672
if (feishuErr.code !== 99991672) return null;
// Extract the grant URL from the error message (contains the direct link)
const msg = feishuErr.msg ?? "";
const urlMatch = msg.match(/https:\/\/[^\s,]+\/app\/[^\s,]+/);
const grantUrl = urlMatch?.[0];
return {
code: feishuErr.code,
message: msg,
grantUrl,
};
}
// --- Sender name resolution (so the agent can distinguish who is speaking in group chats) ---
// Cache display names by open_id to avoid an API call on every message.
const SENDER_NAME_TTL_MS = 10 * 60 * 1000;
const senderNameCache = new Map<string, { name: string; expireAt: number }>();
// Cache permission errors to avoid spamming the user with repeated notifications.
// Key: appId or "default", Value: timestamp of last notification
const permissionErrorNotifiedAt = new Map<string, number>();
const PERMISSION_ERROR_COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes
type SenderNameResult = {
name?: string;
permissionError?: PermissionError;
};
async function resolveFeishuSenderName(params: {
feishuCfg?: FeishuConfig;
senderOpenId: string;
log: (...args: any[]) => void;
}): Promise<SenderNameResult> {
const { feishuCfg, senderOpenId, log } = params;
if (!feishuCfg) return {};
if (!senderOpenId) return {};
const cached = senderNameCache.get(senderOpenId);
const now = Date.now();
if (cached && cached.expireAt > now) return { name: cached.name };
try {
const client = createFeishuClient(feishuCfg);
// contact/v3/users/:user_id?user_id_type=open_id
const res: any = await client.contact.user.get({
path: { user_id: senderOpenId },
params: { user_id_type: "open_id" },
});
const name: string | undefined =
res?.data?.user?.name ||
res?.data?.user?.display_name ||
res?.data?.user?.nickname ||
res?.data?.user?.en_name;
if (name && typeof name === "string") {
senderNameCache.set(senderOpenId, { name, expireAt: now + SENDER_NAME_TTL_MS });
return { name };
}
return {};
} catch (err) {
// Check if this is a permission error
const permErr = extractPermissionError(err);
if (permErr) {
log(`feishu: permission error resolving sender name: code=${permErr.code}`);
return { permissionError: permErr };
}
// Best-effort. Don't fail message handling if name lookup fails.
log(`feishu: failed to resolve sender name for ${senderOpenId}: ${String(err)}`);
return {};
}
}
export type FeishuMessageEvent = {
sender: {
sender_id: {
open_id?: string;
user_id?: string;
union_id?: string;
};
sender_type?: string;
tenant_key?: string;
};
message: {
message_id: string;
root_id?: string;
parent_id?: string;
chat_id: string;
chat_type: "p2p" | "group";
message_type: string;
content: string;
mentions?: Array<{
key: string;
id: {
open_id?: string;
user_id?: string;
union_id?: string;
};
name: string;
tenant_key?: string;
}>;
};
};
export type FeishuBotAddedEvent = {
chat_id: string;
operator_id: {
open_id?: string;
user_id?: string;
union_id?: string;
};
external: boolean;
operator_tenant_key?: string;
};
function parseMessageContent(content: string, messageType: string): string {
try {
const parsed = JSON.parse(content);
if (messageType === "text") {
return parsed.text || "";
}
if (messageType === "post") {
// Extract text content from rich text post
const { textContent } = parsePostContent(content);
return textContent;
}
return content;
} catch {
return content;
}
}
function checkBotMentioned(event: FeishuMessageEvent, botOpenId?: string): boolean {
const mentions = event.message.mentions ?? [];
if (mentions.length === 0) return false;
if (!botOpenId) return mentions.length > 0;
return mentions.some((m) => m.id.open_id === botOpenId);
}
function stripBotMention(
text: string,
mentions?: FeishuMessageEvent["message"]["mentions"],
): string {
if (!mentions || mentions.length === 0) return text;
let result = text;
for (const mention of mentions) {
result = result.replace(new RegExp(`@${mention.name}\\s*`, "g"), "").trim();
result = result.replace(new RegExp(mention.key, "g"), "").trim();
}
return result;
}
/**
* Parse media keys from message content based on message type.
*/
function parseMediaKeys(
content: string,
messageType: string,
): {
imageKey?: string;
fileKey?: string;
fileName?: string;
} {
try {
const parsed = JSON.parse(content);
switch (messageType) {
case "image":
return { imageKey: parsed.image_key };
case "file":
return { fileKey: parsed.file_key, fileName: parsed.file_name };
case "audio":
return { fileKey: parsed.file_key };
case "video":
// Video has both file_key (video) and image_key (thumbnail)
return { fileKey: parsed.file_key, imageKey: parsed.image_key };
case "sticker":
return { fileKey: parsed.file_key };
default:
return {};
}
} catch {
return {};
}
}
/**
* Parse post (rich text) content and extract embedded image keys.
* Post structure: { title?: string, content: [[{ tag, text?, image_key?, ... }]] }
*/
function parsePostContent(content: string): {
textContent: string;
imageKeys: string[];
} {
try {
const parsed = JSON.parse(content);
const title = parsed.title || "";
const contentBlocks = parsed.content || [];
let textContent = title ? `${title}\n\n` : "";
const imageKeys: string[] = [];
for (const paragraph of contentBlocks) {
if (Array.isArray(paragraph)) {
for (const element of paragraph) {
if (element.tag === "text") {
textContent += element.text || "";
} else if (element.tag === "a") {
// Link: show text or href
textContent += element.text || element.href || "";
} else if (element.tag === "at") {
// Mention: @username
textContent += `@${element.user_name || element.user_id || ""}`;
} else if (element.tag === "img" && element.image_key) {
// Embedded image
imageKeys.push(element.image_key);
}
}
textContent += "\n";
}
}
return {
textContent: textContent.trim() || "[富文本消息]",
imageKeys,
};
} catch {
return { textContent: "[富文本消息]", imageKeys: [] };
}
}
/**
* Infer placeholder text based on message type.
*/
function inferPlaceholder(messageType: string): string {
switch (messageType) {
case "image":
return "<media:image>";
case "file":
return "<media:document>";
case "audio":
return "<media:audio>";
case "video":
return "<media:video>";
case "sticker":
return "<media:sticker>";
default:
return "<media:document>";
}
}
/**
* Resolve media from a Feishu message, downloading and saving to disk.
* Similar to Discord's resolveMediaList().
*/
async function resolveFeishuMediaList(params: {
cfg: ClawdbotConfig;
messageId: string;
messageType: string;
content: string;
maxBytes: number;
log?: (msg: string) => void;
}): Promise<FeishuMediaInfo[]> {
const { cfg, messageId, messageType, content, maxBytes, log } = params;
// Only process media message types (including post for embedded images)
const mediaTypes = ["image", "file", "audio", "video", "sticker", "post"];
if (!mediaTypes.includes(messageType)) {
return [];
}
const out: FeishuMediaInfo[] = [];
const core = getFeishuRuntime();
// Handle post (rich text) messages with embedded images
if (messageType === "post") {
const { imageKeys } = parsePostContent(content);
if (imageKeys.length === 0) {
return [];
}
log?.(`feishu: post message contains ${imageKeys.length} embedded image(s)`);
for (const imageKey of imageKeys) {
try {
// Embedded images in post use messageResource API with image_key as file_key
const result = await downloadMessageResourceFeishu({
cfg,
messageId,
fileKey: imageKey,
type: "image",
});
let contentType = result.contentType;
if (!contentType) {
contentType = await core.media.detectMime({ buffer: result.buffer });
}
const saved = await core.channel.media.saveMediaBuffer(
result.buffer,
contentType,
"inbound",
maxBytes,
);
out.push({
path: saved.path,
contentType: saved.contentType,
placeholder: "<media:image>",
});
log?.(`feishu: downloaded embedded image ${imageKey}, saved to ${saved.path}`);
} catch (err) {
log?.(`feishu: failed to download embedded image ${imageKey}: ${String(err)}`);
}
}
return out;
}
// Handle other media types
const mediaKeys = parseMediaKeys(content, messageType);
if (!mediaKeys.imageKey && !mediaKeys.fileKey) {
return [];
}
try {
let buffer: Buffer;
let contentType: string | undefined;
let fileName: string | undefined;
// For message media, always use messageResource API
// The image.get API is only for images uploaded via im/v1/images, not for message attachments
const fileKey = mediaKeys.imageKey || mediaKeys.fileKey;
if (!fileKey) {
return [];
}
const resourceType = messageType === "image" ? "image" : "file";
const result = await downloadMessageResourceFeishu({
cfg,
messageId,
fileKey,
type: resourceType,
});
buffer = result.buffer;
contentType = result.contentType;
fileName = result.fileName || mediaKeys.fileName;
// Detect mime type if not provided
if (!contentType) {
contentType = await core.media.detectMime({ buffer });
}
// Save to disk using core's saveMediaBuffer
const saved = await core.channel.media.saveMediaBuffer(
buffer,
contentType,
"inbound",
maxBytes,
fileName,
);
out.push({
path: saved.path,
contentType: saved.contentType,
placeholder: inferPlaceholder(messageType),
});
log?.(`feishu: downloaded ${messageType} media, saved to ${saved.path}`);
} catch (err) {
log?.(`feishu: failed to download ${messageType} media: ${String(err)}`);
}
return out;
}
/**
* Build media payload for inbound context.
* Similar to Discord's buildDiscordMediaPayload().
*/
function buildFeishuMediaPayload(mediaList: FeishuMediaInfo[]): {
MediaPath?: string;
MediaType?: string;
MediaUrl?: string;
MediaPaths?: string[];
MediaUrls?: string[];
MediaTypes?: string[];
} {
const first = mediaList[0];
const mediaPaths = mediaList.map((media) => media.path);
const mediaTypes = mediaList.map((media) => media.contentType).filter(Boolean) as string[];
return {
MediaPath: first?.path,
MediaType: first?.contentType,
MediaUrl: first?.path,
MediaPaths: mediaPaths.length > 0 ? mediaPaths : undefined,
MediaUrls: mediaPaths.length > 0 ? mediaPaths : undefined,
MediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined,
};
}
export function parseFeishuMessageEvent(
event: FeishuMessageEvent,
botOpenId?: string,
): FeishuMessageContext {
const rawContent = parseMessageContent(event.message.content, event.message.message_type);
const mentionedBot = checkBotMentioned(event, botOpenId);
const content = stripBotMention(rawContent, event.message.mentions);
const ctx: FeishuMessageContext = {
chatId: event.message.chat_id,
messageId: event.message.message_id,
senderId: event.sender.sender_id.user_id || event.sender.sender_id.open_id || "",
senderOpenId: event.sender.sender_id.open_id || "",
chatType: event.message.chat_type,
mentionedBot,
rootId: event.message.root_id || undefined,
parentId: event.message.parent_id || undefined,
content,
contentType: event.message.message_type,
};
// Detect mention forward request: message mentions bot + at least one other user
if (isMentionForwardRequest(event, botOpenId)) {
const mentionTargets = extractMentionTargets(event, botOpenId);
if (mentionTargets.length > 0) {
ctx.mentionTargets = mentionTargets;
// Extract message body (remove all @ placeholders)
const allMentionKeys = (event.message.mentions ?? []).map((m) => m.key);
ctx.mentionMessageBody = extractMessageBody(content, allMentionKeys);
}
}
return ctx;
}
export async function handleFeishuMessage(params: {
cfg: ClawdbotConfig;
event: FeishuMessageEvent;
botOpenId?: string;
runtime?: RuntimeEnv;
chatHistories?: Map<string, HistoryEntry[]>;
}): Promise<void> {
const { cfg, event, botOpenId, runtime, chatHistories } = params;
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
const log = runtime?.log ?? console.log;
const error = runtime?.error ?? console.error;
let ctx = parseFeishuMessageEvent(event, botOpenId);
const isGroup = ctx.chatType === "group";
// Resolve sender display name (best-effort) so the agent can attribute messages correctly.
const senderResult = await resolveFeishuSenderName({
feishuCfg,
senderOpenId: ctx.senderOpenId,
log,
});
if (senderResult.name) ctx = { ...ctx, senderName: senderResult.name };
// Track permission error to inform agent later (with cooldown to avoid repetition)
let permissionErrorForAgent: PermissionError | undefined;
if (senderResult.permissionError) {
const appKey = feishuCfg?.appId ?? "default";
const now = Date.now();
const lastNotified = permissionErrorNotifiedAt.get(appKey) ?? 0;
if (now - lastNotified > PERMISSION_ERROR_COOLDOWN_MS) {
permissionErrorNotifiedAt.set(appKey, now);
permissionErrorForAgent = senderResult.permissionError;
}
}
log(`feishu: received message from ${ctx.senderOpenId} in ${ctx.chatId} (${ctx.chatType})`);
// Log mention targets if detected
if (ctx.mentionTargets && ctx.mentionTargets.length > 0) {
const names = ctx.mentionTargets.map((t) => t.name).join(", ");
log(`feishu: detected @ forward request, targets: [${names}]`);
}
const historyLimit = Math.max(
0,
feishuCfg?.historyLimit ?? cfg.messages?.groupChat?.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT,
);
if (isGroup) {
const groupPolicy = feishuCfg?.groupPolicy ?? "open";
const groupAllowFrom = feishuCfg?.groupAllowFrom ?? [];
const groupConfig = resolveFeishuGroupConfig({ cfg: feishuCfg, groupId: ctx.chatId });
// Check if this GROUP is allowed (groupAllowFrom contains group IDs like oc_xxx, not user IDs)
const groupAllowed = isFeishuGroupAllowed({
groupPolicy,
allowFrom: groupAllowFrom,
senderId: ctx.chatId, // Check group ID, not sender ID
senderName: undefined,
});
if (!groupAllowed) {
log(`feishu: group ${ctx.chatId} not in allowlist`);
return;
}
// Additional sender-level allowlist check if group has specific allowFrom config
const senderAllowFrom = groupConfig?.allowFrom ?? [];
if (senderAllowFrom.length > 0) {
const senderAllowed = isFeishuGroupAllowed({
groupPolicy: "allowlist",
allowFrom: senderAllowFrom,
senderId: ctx.senderOpenId,
senderName: ctx.senderName,
});
if (!senderAllowed) {
log(`feishu: sender ${ctx.senderOpenId} not in group ${ctx.chatId} sender allowlist`);
return;
}
}
const { requireMention } = resolveFeishuReplyPolicy({
isDirectMessage: false,
globalConfig: feishuCfg,
groupConfig,
});
if (requireMention && !ctx.mentionedBot) {
log(`feishu: message in group ${ctx.chatId} did not mention bot, recording to history`);
if (chatHistories) {
recordPendingHistoryEntryIfEnabled({
historyMap: chatHistories,
historyKey: ctx.chatId,
limit: historyLimit,
entry: {
sender: ctx.senderOpenId,
body: `${ctx.senderName ?? ctx.senderOpenId}: ${ctx.content}`,
timestamp: Date.now(),
messageId: ctx.messageId,
},
});
}
return;
}
} else {
const dmPolicy = feishuCfg?.dmPolicy ?? "pairing";
const allowFrom = feishuCfg?.allowFrom ?? [];
if (dmPolicy === "allowlist") {
const match = resolveFeishuAllowlistMatch({
allowFrom,
senderId: ctx.senderOpenId,
});
if (!match.allowed) {
log(`feishu: sender ${ctx.senderOpenId} not in DM allowlist`);
return;
}
}
}
try {
const core = getFeishuRuntime();
// In group chats, the session is scoped to the group, but the *speaker* is the sender.
// Using a group-scoped From causes the agent to treat different users as the same person.
const feishuFrom = `feishu:${ctx.senderOpenId}`;
const feishuTo = isGroup ? `chat:${ctx.chatId}` : `user:${ctx.senderOpenId}`;
const route = core.channel.routing.resolveAgentRoute({
cfg,
channel: "feishu",
peer: {
kind: isGroup ? "group" : "dm",
id: isGroup ? ctx.chatId : ctx.senderOpenId,
},
});
const preview = ctx.content.replace(/\s+/g, " ").slice(0, 160);
const inboundLabel = isGroup
? `Feishu message in group ${ctx.chatId}`
: `Feishu DM from ${ctx.senderOpenId}`;
core.system.enqueueSystemEvent(`${inboundLabel}: ${preview}`, {
sessionKey: route.sessionKey,
contextKey: `feishu:message:${ctx.chatId}:${ctx.messageId}`,
});
// Resolve media from message
const mediaMaxBytes = (feishuCfg?.mediaMaxMb ?? 30) * 1024 * 1024; // 30MB default
const mediaList = await resolveFeishuMediaList({
cfg,
messageId: ctx.messageId,
messageType: event.message.message_type,
content: event.message.content,
maxBytes: mediaMaxBytes,
log,
});
const mediaPayload = buildFeishuMediaPayload(mediaList);
// Fetch quoted/replied message content if parentId exists
let quotedContent: string | undefined;
if (ctx.parentId) {
try {
const quotedMsg = await getMessageFeishu({ cfg, messageId: ctx.parentId });
if (quotedMsg) {
quotedContent = quotedMsg.content;
log(`feishu: fetched quoted message: ${quotedContent?.slice(0, 100)}`);
}
} catch (err) {
log(`feishu: failed to fetch quoted message: ${String(err)}`);
}
}
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
// Build message body with quoted content if available
let messageBody = ctx.content;
if (quotedContent) {
messageBody = `[Replying to: "${quotedContent}"]\n\n${ctx.content}`;
}
// Include a readable speaker label so the model can attribute instructions.
// (DMs already have per-sender sessions, but the prefix is still useful for clarity.)
const speaker = ctx.senderName ?? ctx.senderOpenId;
messageBody = `${speaker}: ${messageBody}`;
// If there are mention targets, inform the agent that replies will auto-mention them
if (ctx.mentionTargets && ctx.mentionTargets.length > 0) {
const targetNames = ctx.mentionTargets.map((t) => t.name).join(", ");
messageBody += `\n\n[System: Your reply will automatically @mention: ${targetNames}. Do not write @xxx yourself.]`;
}
const envelopeFrom = isGroup ? `${ctx.chatId}:${ctx.senderOpenId}` : ctx.senderOpenId;
// If there's a permission error, dispatch a separate notification first
if (permissionErrorForAgent) {
const grantUrl = permissionErrorForAgent.grantUrl ?? "";
const permissionNotifyBody = `[System: The bot encountered a Feishu API permission error. Please inform the user about this issue and provide the permission grant URL for the admin to authorize. Permission grant URL: ${grantUrl}]`;
const permissionBody = core.channel.reply.formatAgentEnvelope({
channel: "Feishu",
from: envelopeFrom,
timestamp: new Date(),
envelope: envelopeOptions,
body: permissionNotifyBody,
});
const permissionCtx = core.channel.reply.finalizeInboundContext({
Body: permissionBody,
RawBody: permissionNotifyBody,
CommandBody: permissionNotifyBody,
From: feishuFrom,
To: feishuTo,
SessionKey: route.sessionKey,
AccountId: route.accountId,
ChatType: isGroup ? "group" : "direct",
GroupSubject: isGroup ? ctx.chatId : undefined,
SenderName: "system",
SenderId: "system",
Provider: "feishu" as const,
Surface: "feishu" as const,
MessageSid: `${ctx.messageId}:permission-error`,
Timestamp: Date.now(),
WasMentioned: false,
CommandAuthorized: true,
OriginatingChannel: "feishu" as const,
OriginatingTo: feishuTo,
});
const {
dispatcher: permDispatcher,
replyOptions: permReplyOptions,
markDispatchIdle: markPermIdle,
} = createFeishuReplyDispatcher({
cfg,
agentId: route.agentId,
runtime: runtime as RuntimeEnv,
chatId: ctx.chatId,
replyToMessageId: ctx.messageId,
});
log(`feishu: dispatching permission error notification to agent`);
await core.channel.reply.dispatchReplyFromConfig({
ctx: permissionCtx,
cfg,
dispatcher: permDispatcher,
replyOptions: permReplyOptions,
});
markPermIdle();
}
const body = core.channel.reply.formatAgentEnvelope({
channel: "Feishu",
from: envelopeFrom,
timestamp: new Date(),
envelope: envelopeOptions,
body: messageBody,
});
let combinedBody = body;
const historyKey = isGroup ? ctx.chatId : undefined;
if (isGroup && historyKey && chatHistories) {
combinedBody = buildPendingHistoryContextFromMap({
historyMap: chatHistories,
historyKey,
limit: historyLimit,
currentMessage: combinedBody,
formatEntry: (entry) =>
core.channel.reply.formatAgentEnvelope({
channel: "Feishu",
// Preserve speaker identity in group history as well.
from: `${ctx.chatId}:${entry.sender}`,
timestamp: entry.timestamp,
body: entry.body,
envelope: envelopeOptions,
}),
});
}
const ctxPayload = core.channel.reply.finalizeInboundContext({
Body: combinedBody,
RawBody: ctx.content,
CommandBody: ctx.content,
From: feishuFrom,
To: feishuTo,
SessionKey: route.sessionKey,
AccountId: route.accountId,
ChatType: isGroup ? "group" : "direct",
GroupSubject: isGroup ? ctx.chatId : undefined,
SenderName: ctx.senderName ?? ctx.senderOpenId,
SenderId: ctx.senderOpenId,
Provider: "feishu" as const,
Surface: "feishu" as const,
MessageSid: ctx.messageId,
Timestamp: Date.now(),
WasMentioned: ctx.mentionedBot,
CommandAuthorized: true,
OriginatingChannel: "feishu" as const,
OriginatingTo: feishuTo,
...mediaPayload,
});
const { dispatcher, replyOptions, markDispatchIdle } = createFeishuReplyDispatcher({
cfg,
agentId: route.agentId,
runtime: runtime as RuntimeEnv,
chatId: ctx.chatId,
replyToMessageId: ctx.messageId,
mentionTargets: ctx.mentionTargets,
});
log(`feishu: dispatching to agent (session=${route.sessionKey})`);
const { queuedFinal, counts } = await core.channel.reply.dispatchReplyFromConfig({
ctx: ctxPayload,
cfg,
dispatcher,
replyOptions,
});
markDispatchIdle();
if (isGroup && historyKey && chatHistories) {
clearHistoryEntriesIfEnabled({
historyMap: chatHistories,
historyKey,
limit: historyLimit,
});
}
log(`feishu: dispatch complete (queuedFinal=${queuedFinal}, replies=${counts.final})`);
} catch (err) {
error(`feishu: failed to dispatch message: ${String(err)}`);
}
}

View File

@@ -1,55 +1,45 @@
import type { ChannelPlugin, ClawdbotConfig } from "openclaw/plugin-sdk";
import { DEFAULT_ACCOUNT_ID, PAIRING_APPROVED_MESSAGE } from "openclaw/plugin-sdk";
import type { ResolvedFeishuAccount, FeishuConfig } from "./types.js";
import { resolveFeishuAccount, resolveFeishuCredentials } from "./accounts.js";
import {
buildChannelConfigSchema,
DEFAULT_ACCOUNT_ID,
deleteAccountFromConfigSection,
feishuOutbound,
formatPairingApproveHint,
listFeishuAccountIds,
monitorFeishuProvider,
normalizeFeishuTarget,
PAIRING_APPROVED_MESSAGE,
probeFeishu,
resolveDefaultFeishuAccountId,
resolveFeishuAccount,
resolveFeishuConfig,
resolveFeishuGroupRequireMention,
setAccountEnabledInConfigSection,
type ChannelAccountSnapshot,
type ChannelPlugin,
type ChannelStatusIssue,
type ResolvedFeishuAccount,
} from "openclaw/plugin-sdk";
import { FeishuConfigSchema } from "./config-schema.js";
listFeishuDirectoryPeers,
listFeishuDirectoryGroups,
listFeishuDirectoryPeersLive,
listFeishuDirectoryGroupsLive,
} from "./directory.js";
import { feishuOnboardingAdapter } from "./onboarding.js";
import { feishuOutbound } from "./outbound.js";
import { resolveFeishuGroupToolPolicy } from "./policy.js";
import { probeFeishu } from "./probe.js";
import { sendMessageFeishu } from "./send.js";
import { normalizeFeishuTarget, looksLikeFeishuId } from "./targets.js";
const meta = {
id: "feishu",
label: "Feishu",
selectionLabel: "Feishu (Lark Open Platform)",
detailLabel: "Feishu Bot",
selectionLabel: "Feishu/Lark (飞书)",
docsPath: "/channels/feishu",
docsLabel: "feishu",
blurb: "Feishu/Lark bot via WebSocket.",
blurb: "飞书/Lark enterprise messaging.",
aliases: ["lark"],
order: 35,
quickstartAllowFrom: true,
};
const normalizeAllowEntry = (entry: string) => entry.replace(/^(feishu|lark):/i, "").trim();
order: 70,
} as const;
export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
id: "feishu",
meta,
onboarding: feishuOnboardingAdapter,
meta: {
...meta,
},
pairing: {
idLabel: "feishuOpenId",
normalizeAllowEntry: normalizeAllowEntry,
idLabel: "feishuUserId",
normalizeAllowEntry: (entry) => entry.replace(/^(feishu|user|open_id):/i, ""),
notifyApproval: async ({ cfg, id }) => {
const account = resolveFeishuAccount({ cfg });
if (!account.config.appId || !account.config.appSecret) {
throw new Error("Feishu app credentials not configured");
}
await feishuOutbound.sendText({ cfg, to: id, text: PAIRING_APPROVED_MESSAGE });
await sendMessageFeishu({
cfg,
to: id,
text: PAIRING_APPROVED_MESSAGE,
});
},
},
capabilities: {
@@ -61,113 +51,136 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
nativeCommands: true,
blockStreaming: true,
},
agentPrompt: {
messageToolHints: () => [
"- Feishu targeting: omit `target` to reply to the current conversation (auto-inferred). Explicit targets: `user:open_id` or `chat:chat_id`.",
"- Feishu supports interactive cards for rich messages.",
],
},
groups: {
resolveToolPolicy: resolveFeishuGroupToolPolicy,
},
reload: { configPrefixes: ["channels.feishu"] },
outbound: feishuOutbound,
messaging: {
normalizeTarget: normalizeFeishuTarget,
targetResolver: {
looksLikeId: (raw, normalized) => {
const value = (normalized ?? raw).trim();
if (!value) {
return false;
}
return /^o[cun]_[a-zA-Z0-9]+$/.test(value) || /^(user|group|chat):/i.test(value);
configSchema: {
schema: {
type: "object",
additionalProperties: false,
properties: {
enabled: { type: "boolean" },
appId: { type: "string" },
appSecret: { type: "string" },
encryptKey: { type: "string" },
verificationToken: { type: "string" },
domain: {
oneOf: [
{ type: "string", enum: ["feishu", "lark"] },
{ type: "string", format: "uri", pattern: "^https://" },
],
},
connectionMode: { type: "string", enum: ["websocket", "webhook"] },
webhookPath: { type: "string" },
webhookPort: { type: "integer", minimum: 1 },
dmPolicy: { type: "string", enum: ["open", "pairing", "allowlist"] },
allowFrom: { type: "array", items: { oneOf: [{ type: "string" }, { type: "number" }] } },
groupPolicy: { type: "string", enum: ["open", "allowlist", "disabled"] },
groupAllowFrom: {
type: "array",
items: { oneOf: [{ type: "string" }, { type: "number" }] },
},
requireMention: { type: "boolean" },
historyLimit: { type: "integer", minimum: 0 },
dmHistoryLimit: { type: "integer", minimum: 0 },
textChunkLimit: { type: "integer", minimum: 1 },
chunkMode: { type: "string", enum: ["length", "newline"] },
mediaMaxMb: { type: "number", minimum: 0 },
renderMode: { type: "string", enum: ["auto", "raw", "card"] },
},
hint: "<open_id|union_id|chat_id>",
},
},
configSchema: buildChannelConfigSchema(FeishuConfigSchema),
config: {
listAccountIds: (cfg) => listFeishuAccountIds(cfg),
resolveAccount: (cfg, accountId) => resolveFeishuAccount({ cfg, accountId }),
defaultAccountId: (cfg) => resolveDefaultFeishuAccountId(cfg),
setAccountEnabled: ({ cfg, accountId, enabled }) =>
setAccountEnabledInConfigSection({
cfg,
sectionKey: "feishu",
accountId,
enabled,
allowTopLevel: true,
}),
deleteAccount: ({ cfg, accountId }) =>
deleteAccountFromConfigSection({
cfg,
sectionKey: "feishu",
accountId,
clearBaseFields: ["appId", "appSecret", "appSecretFile", "name", "botName"],
}),
isConfigured: (account) => account.tokenSource !== "none",
describeAccount: (account): ChannelAccountSnapshot => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: account.tokenSource !== "none",
tokenSource: account.tokenSource,
listAccountIds: () => [DEFAULT_ACCOUNT_ID],
resolveAccount: (cfg) => resolveFeishuAccount({ cfg }),
defaultAccountId: () => DEFAULT_ACCOUNT_ID,
setAccountEnabled: ({ cfg, enabled }) => ({
...cfg,
channels: {
...cfg.channels,
feishu: {
...cfg.channels?.feishu,
enabled,
},
},
}),
resolveAllowFrom: ({ cfg, accountId }) =>
resolveFeishuConfig({ cfg, accountId: accountId ?? undefined }).allowFrom.map((entry) =>
String(entry),
),
deleteAccount: ({ cfg }) => {
const next = { ...cfg } as ClawdbotConfig;
const nextChannels = { ...cfg.channels };
delete (nextChannels as Record<string, unknown>).feishu;
if (Object.keys(nextChannels).length > 0) {
next.channels = nextChannels;
} else {
delete next.channels;
}
return next;
},
isConfigured: (_account, cfg) =>
Boolean(resolveFeishuCredentials(cfg.channels?.feishu as FeishuConfig | undefined)),
describeAccount: (account) => ({
accountId: account.accountId,
enabled: account.enabled,
configured: account.configured,
}),
resolveAllowFrom: ({ cfg }) =>
(cfg.channels?.feishu as FeishuConfig | undefined)?.allowFrom ?? [],
formatAllowFrom: ({ allowFrom }) =>
allowFrom
.map((entry) => String(entry).trim())
.filter(Boolean)
.map((entry) => (entry === "*" ? entry : normalizeAllowEntry(entry)))
.map((entry) => (entry === "*" ? entry : entry.toLowerCase())),
.map((entry) => entry.toLowerCase()),
},
security: {
resolveDmPolicy: ({ cfg, accountId, account }) => {
const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
const useAccountPath = Boolean(cfg.channels?.feishu?.accounts?.[resolvedAccountId]);
const basePath = useAccountPath
? `channels.feishu.accounts.${resolvedAccountId}.`
: "channels.feishu.";
return {
policy: account.config.dmPolicy ?? "pairing",
allowFrom: account.config.allowFrom ?? [],
policyPath: `${basePath}dmPolicy`,
allowFromPath: basePath,
approveHint: formatPairingApproveHint("feishu"),
normalizeEntry: normalizeAllowEntry,
};
collectWarnings: ({ cfg }) => {
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
const defaultGroupPolicy = (
cfg.channels as Record<string, { groupPolicy?: string }> | undefined
)?.defaults?.groupPolicy;
const groupPolicy = feishuCfg?.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
if (groupPolicy !== "open") return [];
return [
`- Feishu groups: groupPolicy="open" allows any member to trigger (mention-gated). Set channels.feishu.groupPolicy="allowlist" + channels.feishu.groupAllowFrom to restrict senders.`,
];
},
},
groups: {
resolveRequireMention: ({ cfg, accountId, groupId }) => {
if (!groupId) {
return true;
}
return resolveFeishuGroupRequireMention({
cfg,
accountId: accountId ?? undefined,
chatId: groupId,
});
setup: {
resolveAccountId: () => DEFAULT_ACCOUNT_ID,
applyAccountConfig: ({ cfg }) => ({
...cfg,
channels: {
...cfg.channels,
feishu: {
...cfg.channels?.feishu,
enabled: true,
},
},
}),
},
onboarding: feishuOnboardingAdapter,
messaging: {
normalizeTarget: normalizeFeishuTarget,
targetResolver: {
looksLikeId: looksLikeFeishuId,
hint: "<chatId|user:openId|chat:chatId>",
},
},
directory: {
self: async () => null,
listPeers: async ({ cfg, accountId, query, limit }) => {
const resolved = resolveFeishuConfig({ cfg, accountId: accountId ?? undefined });
const normalizedQuery = query?.trim().toLowerCase() ?? "";
const peers = resolved.allowFrom
.map((entry) => String(entry).trim())
.filter((entry) => Boolean(entry) && entry !== "*")
.map((entry) => normalizeAllowEntry(entry))
.filter((entry) => (normalizedQuery ? entry.toLowerCase().includes(normalizedQuery) : true))
.slice(0, limit && limit > 0 ? limit : undefined)
.map((id) => ({ kind: "user", id }) as const);
return peers;
},
listGroups: async ({ cfg, accountId, query, limit }) => {
const resolved = resolveFeishuConfig({ cfg, accountId: accountId ?? undefined });
const normalizedQuery = query?.trim().toLowerCase() ?? "";
const groups = Object.keys(resolved.groups ?? {})
.filter((id) => (normalizedQuery ? id.toLowerCase().includes(normalizedQuery) : true))
.slice(0, limit && limit > 0 ? limit : undefined)
.map((id) => ({ kind: "group", id }) as const);
return groups;
},
listPeers: async ({ cfg, query, limit }) => listFeishuDirectoryPeers({ cfg, query, limit }),
listGroups: async ({ cfg, query, limit }) => listFeishuDirectoryGroups({ cfg, query, limit }),
listPeersLive: async ({ cfg, query, limit }) =>
listFeishuDirectoryPeersLive({ cfg, query, limit }),
listGroupsLive: async ({ cfg, query, limit }) =>
listFeishuDirectoryGroupsLive({ cfg, query, limit }),
},
outbound: feishuOutbound,
status: {
defaultRuntime: {
accountId: DEFAULT_ACCOUNT_ID,
@@ -175,102 +188,45 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
lastStartAt: null,
lastStopAt: null,
lastError: null,
port: null,
},
collectStatusIssues: (accounts) => {
const issues: ChannelStatusIssue[] = [];
for (const account of accounts) {
if (!account.configured) {
issues.push({
channel: "feishu",
accountId: account.accountId ?? DEFAULT_ACCOUNT_ID,
kind: "config",
message: "Feishu app ID/secret not configured",
});
}
}
return issues;
},
buildChannelSummary: async ({ snapshot }) => ({
buildChannelSummary: ({ snapshot }) => ({
configured: snapshot.configured ?? false,
tokenSource: snapshot.tokenSource ?? "none",
running: snapshot.running ?? false,
lastStartAt: snapshot.lastStartAt ?? null,
lastStopAt: snapshot.lastStopAt ?? null,
lastError: snapshot.lastError ?? null,
port: snapshot.port ?? null,
probe: snapshot.probe,
lastProbeAt: snapshot.lastProbeAt ?? null,
}),
probeAccount: async ({ account, timeoutMs }) =>
probeFeishu(account.config.appId, account.config.appSecret, timeoutMs, account.config.domain),
buildAccountSnapshot: ({ account, runtime, probe }) => {
const configured = account.tokenSource !== "none";
return {
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured,
tokenSource: account.tokenSource,
running: runtime?.running ?? false,
lastStartAt: runtime?.lastStartAt ?? null,
lastStopAt: runtime?.lastStopAt ?? null,
lastError: runtime?.lastError ?? null,
probe,
lastInboundAt: runtime?.lastInboundAt ?? null,
lastOutboundAt: runtime?.lastOutboundAt ?? null,
};
},
logSelfId: ({ account, runtime }) => {
const appId = account.config.appId;
if (appId) {
runtime.log?.(`feishu:${appId}`);
}
},
probeAccount: async ({ cfg }) =>
await probeFeishu(cfg.channels?.feishu as FeishuConfig | undefined),
buildAccountSnapshot: ({ account, runtime, probe }) => ({
accountId: account.accountId,
enabled: account.enabled,
configured: account.configured,
running: runtime?.running ?? false,
lastStartAt: runtime?.lastStartAt ?? null,
lastStopAt: runtime?.lastStopAt ?? null,
lastError: runtime?.lastError ?? null,
port: runtime?.port ?? null,
probe,
}),
},
gateway: {
startAccount: async (ctx) => {
const { account, log, setStatus, abortSignal, cfg, runtime } = ctx;
const { appId, appSecret, domain } = account.config;
if (!appId || !appSecret) {
throw new Error("Feishu app ID/secret not configured");
}
let feishuBotLabel = "";
try {
const probe = await probeFeishu(appId, appSecret, 5000, domain);
if (probe.ok && probe.bot?.appName) {
feishuBotLabel = ` (${probe.bot.appName})`;
}
if (probe.ok && probe.bot) {
setStatus({ accountId: account.accountId, bot: probe.bot });
}
} catch (err) {
log?.debug?.(`[${account.accountId}] bot probe failed: ${String(err)}`);
}
log?.info(`[${account.accountId}] starting Feishu provider${feishuBotLabel}`);
setStatus({
accountId: account.accountId,
running: true,
lastStartAt: Date.now(),
const { monitorFeishuProvider } = await import("./monitor.js");
const feishuCfg = ctx.cfg.channels?.feishu as FeishuConfig | undefined;
const port = feishuCfg?.webhookPort ?? null;
ctx.setStatus({ accountId: ctx.accountId, port });
ctx.log?.info(`starting feishu provider (mode: ${feishuCfg?.connectionMode ?? "websocket"})`);
return monitorFeishuProvider({
config: ctx.cfg,
runtime: ctx.runtime,
abortSignal: ctx.abortSignal,
accountId: ctx.accountId,
});
try {
await monitorFeishuProvider({
appId,
appSecret,
accountId: account.accountId,
config: cfg,
runtime,
abortSignal,
});
} catch (err) {
setStatus({
accountId: account.accountId,
running: false,
lastError: err instanceof Error ? err.message : String(err),
});
throw err;
}
},
},
};

View File

@@ -0,0 +1,68 @@
import * as Lark from "@larksuiteoapi/node-sdk";
import type { FeishuConfig, FeishuDomain } from "./types.js";
import { resolveFeishuCredentials } from "./accounts.js";
let cachedClient: Lark.Client | null = null;
let cachedConfig: { appId: string; appSecret: string; domain: FeishuDomain } | null = null;
function resolveDomain(domain: FeishuDomain): Lark.Domain | string {
if (domain === "lark") return Lark.Domain.Lark;
if (domain === "feishu") return Lark.Domain.Feishu;
return domain.replace(/\/+$/, ""); // Custom URL, remove trailing slashes
}
export function createFeishuClient(cfg: FeishuConfig): Lark.Client {
const creds = resolveFeishuCredentials(cfg);
if (!creds) {
throw new Error("Feishu credentials not configured (appId, appSecret required)");
}
if (
cachedClient &&
cachedConfig &&
cachedConfig.appId === creds.appId &&
cachedConfig.appSecret === creds.appSecret &&
cachedConfig.domain === creds.domain
) {
return cachedClient;
}
const client = new Lark.Client({
appId: creds.appId,
appSecret: creds.appSecret,
appType: Lark.AppType.SelfBuild,
domain: resolveDomain(creds.domain),
});
cachedClient = client;
cachedConfig = { appId: creds.appId, appSecret: creds.appSecret, domain: creds.domain };
return client;
}
export function createFeishuWSClient(cfg: FeishuConfig): Lark.WSClient {
const creds = resolveFeishuCredentials(cfg);
if (!creds) {
throw new Error("Feishu credentials not configured (appId, appSecret required)");
}
return new Lark.WSClient({
appId: creds.appId,
appSecret: creds.appSecret,
domain: resolveDomain(creds.domain),
loggerLevel: Lark.LoggerLevel.info,
});
}
export function createEventDispatcher(cfg: FeishuConfig): Lark.EventDispatcher {
const creds = resolveFeishuCredentials(cfg);
return new Lark.EventDispatcher({
encryptKey: creds?.encryptKey,
verificationToken: creds?.verificationToken,
});
}
export function clearClientCache() {
cachedClient = null;
cachedConfig = null;
}

View File

@@ -1,47 +1,131 @@
import { MarkdownConfigSchema, ToolPolicySchema } from "openclaw/plugin-sdk";
import { z } from "zod";
export { z };
const allowFromEntry = z.union([z.string(), z.number()]);
const toolsBySenderSchema = z.record(z.string(), ToolPolicySchema).optional();
const DmPolicySchema = z.enum(["open", "pairing", "allowlist"]);
const GroupPolicySchema = z.enum(["open", "allowlist", "disabled"]);
const FeishuDomainSchema = z.union([
z.enum(["feishu", "lark"]),
z.string().url().startsWith("https://"),
]);
const FeishuConnectionModeSchema = z.enum(["websocket", "webhook"]);
const FeishuGroupSchema = z
const ToolPolicySchema = z
.object({
allow: z.array(z.string()).optional(),
deny: z.array(z.string()).optional(),
})
.strict()
.optional();
const DmConfigSchema = z
.object({
enabled: z.boolean().optional(),
requireMention: z.boolean().optional(),
allowFrom: z.array(allowFromEntry).optional(),
tools: ToolPolicySchema,
toolsBySender: toolsBySenderSchema,
systemPrompt: z.string().optional(),
})
.strict()
.optional();
const MarkdownConfigSchema = z
.object({
mode: z.enum(["native", "escape", "strip"]).optional(),
tableMode: z.enum(["native", "ascii", "simple"]).optional(),
})
.strict()
.optional();
// Message render mode: auto (default) = detect markdown, raw = plain text, card = always card
const RenderModeSchema = z.enum(["auto", "raw", "card"]).optional();
const BlockStreamingCoalesceSchema = z
.object({
enabled: z.boolean().optional(),
minDelayMs: z.number().int().positive().optional(),
maxDelayMs: z.number().int().positive().optional(),
})
.strict()
.optional();
const ChannelHeartbeatVisibilitySchema = z
.object({
visibility: z.enum(["visible", "hidden"]).optional(),
intervalMs: z.number().int().positive().optional(),
})
.strict()
.optional();
/**
* Feishu tools configuration.
* Controls which tool categories are enabled.
*
* Dependencies:
* - wiki requires doc (wiki content is edited via doc tools)
* - perm can work independently but is typically used with drive
*/
const FeishuToolsConfigSchema = z
.object({
doc: z.boolean().optional(), // Document operations (default: true)
wiki: z.boolean().optional(), // Knowledge base operations (default: true, requires doc)
drive: z.boolean().optional(), // Cloud storage operations (default: true)
perm: z.boolean().optional(), // Permission management (default: false, sensitive)
scopes: z.boolean().optional(), // App scopes diagnostic (default: true)
})
.strict()
.optional();
export const FeishuGroupSchema = z
.object({
requireMention: z.boolean().optional(),
tools: ToolPolicySchema,
skills: z.array(z.string()).optional(),
enabled: z.boolean().optional(),
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
systemPrompt: z.string().optional(),
})
.strict();
const FeishuAccountSchema = z
export const FeishuConfigSchema = z
.object({
name: z.string().optional(),
enabled: z.boolean().optional(),
appId: z.string().optional(),
appSecret: z.string().optional(),
appSecretFile: z.string().optional(),
domain: z.string().optional(),
botName: z.string().optional(),
encryptKey: z.string().optional(),
verificationToken: z.string().optional(),
domain: FeishuDomainSchema.optional().default("feishu"),
connectionMode: FeishuConnectionModeSchema.optional().default("websocket"),
webhookPath: z.string().optional().default("/feishu/events"),
webhookPort: z.number().int().positive().optional(),
capabilities: z.array(z.string()).optional(),
markdown: MarkdownConfigSchema,
dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),
groupPolicy: z.enum(["open", "allowlist", "disabled"]).optional(),
allowFrom: z.array(allowFromEntry).optional(),
groupAllowFrom: z.array(allowFromEntry).optional(),
historyLimit: z.number().optional(),
dmHistoryLimit: z.number().optional(),
textChunkLimit: z.number().optional(),
chunkMode: z.enum(["length", "newline"]).optional(),
blockStreaming: z.boolean().optional(),
streaming: z.boolean().optional(),
mediaMaxMb: z.number().optional(),
responsePrefix: z.string().optional(),
configWrites: z.boolean().optional(),
dmPolicy: DmPolicySchema.optional().default("pairing"),
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
requireMention: z.boolean().optional().default(true),
groups: z.record(z.string(), FeishuGroupSchema.optional()).optional(),
historyLimit: z.number().int().min(0).optional(),
dmHistoryLimit: z.number().int().min(0).optional(),
dms: z.record(z.string(), DmConfigSchema).optional(),
textChunkLimit: z.number().int().positive().optional(),
chunkMode: z.enum(["length", "newline"]).optional(),
blockStreamingCoalesce: BlockStreamingCoalesceSchema,
mediaMaxMb: z.number().positive().optional(),
heartbeat: ChannelHeartbeatVisibilitySchema,
renderMode: RenderModeSchema, // raw = plain text (default), card = interactive card with markdown
tools: FeishuToolsConfigSchema,
})
.strict();
export const FeishuConfigSchema = FeishuAccountSchema.extend({
accounts: z.object({}).catchall(FeishuAccountSchema).optional(),
});
.strict()
.superRefine((value, ctx) => {
if (value.dmPolicy === "open") {
const allowFrom = value.allowFrom ?? [];
const hasWildcard = allowFrom.some((entry) => String(entry).trim() === "*");
if (!hasWildcard) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["allowFrom"],
message:
'channels.feishu.dmPolicy="open" requires channels.feishu.allowFrom to include "*"',
});
}
}
});

View File

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

View File

@@ -0,0 +1,47 @@
import { Type, type Static } from "@sinclair/typebox";
export const FeishuDocSchema = Type.Union([
Type.Object({
action: Type.Literal("read"),
doc_token: Type.String({ description: "Document token (extract from URL /docx/XXX)" }),
}),
Type.Object({
action: Type.Literal("write"),
doc_token: Type.String({ description: "Document token" }),
content: Type.String({
description: "Markdown content to write (replaces entire document content)",
}),
}),
Type.Object({
action: Type.Literal("append"),
doc_token: Type.String({ description: "Document token" }),
content: Type.String({ description: "Markdown content to append to end of document" }),
}),
Type.Object({
action: Type.Literal("create"),
title: Type.String({ description: "Document title" }),
folder_token: Type.Optional(Type.String({ description: "Target folder token (optional)" })),
}),
Type.Object({
action: Type.Literal("list_blocks"),
doc_token: Type.String({ description: "Document token" }),
}),
Type.Object({
action: Type.Literal("get_block"),
doc_token: Type.String({ description: "Document token" }),
block_id: Type.String({ description: "Block ID (from list_blocks)" }),
}),
Type.Object({
action: Type.Literal("update_block"),
doc_token: Type.String({ description: "Document token" }),
block_id: Type.String({ description: "Block ID (from list_blocks)" }),
content: Type.String({ description: "New text content" }),
}),
Type.Object({
action: Type.Literal("delete_block"),
doc_token: Type.String({ description: "Document token" }),
block_id: Type.String({ description: "Block ID" }),
}),
]);
export type FeishuDocParams = Static<typeof FeishuDocSchema>;

View File

@@ -0,0 +1,470 @@
import type * as Lark from "@larksuiteoapi/node-sdk";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { Type } from "@sinclair/typebox";
import { Readable } from "stream";
import type { FeishuConfig } from "./types.js";
import { createFeishuClient } from "./client.js";
import { FeishuDocSchema, type FeishuDocParams } from "./doc-schema.js";
import { resolveToolsConfig } from "./tools-config.js";
// ============ Helpers ============
function json(data: unknown) {
return {
content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }],
details: data,
};
}
/** Extract image URLs from markdown content */
function extractImageUrls(markdown: string): string[] {
const regex = /!\[[^\]]*\]\(([^)]+)\)/g;
const urls: string[] = [];
let match;
while ((match = regex.exec(markdown)) !== null) {
const url = match[1].trim();
if (url.startsWith("http://") || url.startsWith("https://")) {
urls.push(url);
}
}
return urls;
}
const BLOCK_TYPE_NAMES: Record<number, string> = {
1: "Page",
2: "Text",
3: "Heading1",
4: "Heading2",
5: "Heading3",
12: "Bullet",
13: "Ordered",
14: "Code",
15: "Quote",
17: "Todo",
18: "Bitable",
21: "Diagram",
22: "Divider",
23: "File",
27: "Image",
30: "Sheet",
31: "Table",
32: "TableCell",
};
// Block types that cannot be created via documentBlockChildren.create API
const UNSUPPORTED_CREATE_TYPES = new Set([31, 32]);
/** Clean blocks for insertion (remove unsupported types and read-only fields) */
function cleanBlocksForInsert(blocks: any[]): { cleaned: any[]; skipped: string[] } {
const skipped: string[] = [];
const cleaned = blocks
.filter((block) => {
if (UNSUPPORTED_CREATE_TYPES.has(block.block_type)) {
const typeName = BLOCK_TYPE_NAMES[block.block_type] || `type_${block.block_type}`;
skipped.push(typeName);
return false;
}
return true;
})
.map((block) => {
if (block.block_type === 31 && block.table?.merge_info) {
const { merge_info, ...tableRest } = block.table;
return { ...block, table: tableRest };
}
return block;
});
return { cleaned, skipped };
}
// ============ Core Functions ============
async function convertMarkdown(client: Lark.Client, markdown: string) {
const res = await client.docx.document.convert({
data: { content_type: "markdown", content: markdown },
});
if (res.code !== 0) throw new Error(res.msg);
return {
blocks: res.data?.blocks ?? [],
firstLevelBlockIds: res.data?.first_level_block_ids ?? [],
};
}
async function insertBlocks(
client: Lark.Client,
docToken: string,
blocks: any[],
parentBlockId?: string,
): Promise<{ children: any[]; skipped: string[] }> {
const { cleaned, skipped } = cleanBlocksForInsert(blocks);
const blockId = parentBlockId ?? docToken;
if (cleaned.length === 0) {
return { children: [], skipped };
}
const res = await client.docx.documentBlockChildren.create({
path: { document_id: docToken, block_id: blockId },
data: { children: cleaned },
});
if (res.code !== 0) throw new Error(res.msg);
return { children: res.data?.children ?? [], skipped };
}
async function clearDocumentContent(client: Lark.Client, docToken: string) {
const existing = await client.docx.documentBlock.list({
path: { document_id: docToken },
});
if (existing.code !== 0) throw new Error(existing.msg);
const childIds =
existing.data?.items
?.filter((b) => b.parent_id === docToken && b.block_type !== 1)
.map((b) => b.block_id) ?? [];
if (childIds.length > 0) {
const res = await client.docx.documentBlockChildren.batchDelete({
path: { document_id: docToken, block_id: docToken },
data: { start_index: 0, end_index: childIds.length },
});
if (res.code !== 0) throw new Error(res.msg);
}
return childIds.length;
}
async function uploadImageToDocx(
client: Lark.Client,
blockId: string,
imageBuffer: Buffer,
fileName: string,
): Promise<string> {
const res = await client.drive.media.uploadAll({
data: {
file_name: fileName,
parent_type: "docx_image",
parent_node: blockId,
size: imageBuffer.length,
file: Readable.from(imageBuffer) as any,
},
});
const fileToken = res?.file_token;
if (!fileToken) {
throw new Error("Image upload failed: no file_token returned");
}
return fileToken;
}
async function downloadImage(url: string): Promise<Buffer> {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to download image: ${response.status} ${response.statusText}`);
}
return Buffer.from(await response.arrayBuffer());
}
async function processImages(
client: Lark.Client,
docToken: string,
markdown: string,
insertedBlocks: any[],
): Promise<number> {
const imageUrls = extractImageUrls(markdown);
if (imageUrls.length === 0) return 0;
const imageBlocks = insertedBlocks.filter((b) => b.block_type === 27);
let processed = 0;
for (let i = 0; i < Math.min(imageUrls.length, imageBlocks.length); i++) {
const url = imageUrls[i];
const blockId = imageBlocks[i].block_id;
try {
const buffer = await downloadImage(url);
const urlPath = new URL(url).pathname;
const fileName = urlPath.split("/").pop() || `image_${i}.png`;
const fileToken = await uploadImageToDocx(client, blockId, buffer, fileName);
await client.docx.documentBlock.patch({
path: { document_id: docToken, block_id: blockId },
data: {
replace_image: { token: fileToken },
},
});
processed++;
} catch (err) {
console.error(`Failed to process image ${url}:`, err);
}
}
return processed;
}
// ============ Actions ============
const STRUCTURED_BLOCK_TYPES = new Set([14, 18, 21, 23, 27, 30, 31, 32]);
async function readDoc(client: Lark.Client, docToken: string) {
const [contentRes, infoRes, blocksRes] = await Promise.all([
client.docx.document.rawContent({ path: { document_id: docToken } }),
client.docx.document.get({ path: { document_id: docToken } }),
client.docx.documentBlock.list({ path: { document_id: docToken } }),
]);
if (contentRes.code !== 0) throw new Error(contentRes.msg);
const blocks = blocksRes.data?.items ?? [];
const blockCounts: Record<string, number> = {};
const structuredTypes: string[] = [];
for (const b of blocks) {
const type = b.block_type ?? 0;
const name = BLOCK_TYPE_NAMES[type] || `type_${type}`;
blockCounts[name] = (blockCounts[name] || 0) + 1;
if (STRUCTURED_BLOCK_TYPES.has(type) && !structuredTypes.includes(name)) {
structuredTypes.push(name);
}
}
let hint: string | undefined;
if (structuredTypes.length > 0) {
hint = `This document contains ${structuredTypes.join(", ")} which are NOT included in the plain text above. Use feishu_doc with action: "list_blocks" to get full content.`;
}
return {
title: infoRes.data?.document?.title,
content: contentRes.data?.content,
revision_id: infoRes.data?.document?.revision_id,
block_count: blocks.length,
block_types: blockCounts,
...(hint && { hint }),
};
}
async function createDoc(client: Lark.Client, title: string, folderToken?: string) {
const res = await client.docx.document.create({
data: { title, folder_token: folderToken },
});
if (res.code !== 0) throw new Error(res.msg);
const doc = res.data?.document;
return {
document_id: doc?.document_id,
title: doc?.title,
url: `https://feishu.cn/docx/${doc?.document_id}`,
};
}
async function writeDoc(client: Lark.Client, docToken: string, markdown: string) {
const deleted = await clearDocumentContent(client, docToken);
const { blocks } = await convertMarkdown(client, markdown);
if (blocks.length === 0) {
return { success: true, blocks_deleted: deleted, blocks_added: 0, images_processed: 0 };
}
const { children: inserted, skipped } = await insertBlocks(client, docToken, blocks);
const imagesProcessed = await processImages(client, docToken, markdown, inserted);
return {
success: true,
blocks_deleted: deleted,
blocks_added: inserted.length,
images_processed: imagesProcessed,
...(skipped.length > 0 && {
warning: `Skipped unsupported block types: ${skipped.join(", ")}. Tables are not supported via this API.`,
}),
};
}
async function appendDoc(client: Lark.Client, docToken: string, markdown: string) {
const { blocks } = await convertMarkdown(client, markdown);
if (blocks.length === 0) {
throw new Error("Content is empty");
}
const { children: inserted, skipped } = await insertBlocks(client, docToken, blocks);
const imagesProcessed = await processImages(client, docToken, markdown, inserted);
return {
success: true,
blocks_added: inserted.length,
images_processed: imagesProcessed,
block_ids: inserted.map((b: any) => b.block_id),
...(skipped.length > 0 && {
warning: `Skipped unsupported block types: ${skipped.join(", ")}. Tables are not supported via this API.`,
}),
};
}
async function updateBlock(
client: Lark.Client,
docToken: string,
blockId: string,
content: string,
) {
const blockInfo = await client.docx.documentBlock.get({
path: { document_id: docToken, block_id: blockId },
});
if (blockInfo.code !== 0) throw new Error(blockInfo.msg);
const res = await client.docx.documentBlock.patch({
path: { document_id: docToken, block_id: blockId },
data: {
update_text_elements: {
elements: [{ text_run: { content } }],
},
},
});
if (res.code !== 0) throw new Error(res.msg);
return { success: true, block_id: blockId };
}
async function deleteBlock(client: Lark.Client, docToken: string, blockId: string) {
const blockInfo = await client.docx.documentBlock.get({
path: { document_id: docToken, block_id: blockId },
});
if (blockInfo.code !== 0) throw new Error(blockInfo.msg);
const parentId = blockInfo.data?.block?.parent_id ?? docToken;
const children = await client.docx.documentBlockChildren.get({
path: { document_id: docToken, block_id: parentId },
});
if (children.code !== 0) throw new Error(children.msg);
const items = children.data?.items ?? [];
const index = items.findIndex((item: any) => item.block_id === blockId);
if (index === -1) throw new Error("Block not found");
const res = await client.docx.documentBlockChildren.batchDelete({
path: { document_id: docToken, block_id: parentId },
data: { start_index: index, end_index: index + 1 },
});
if (res.code !== 0) throw new Error(res.msg);
return { success: true, deleted_block_id: blockId };
}
async function listBlocks(client: Lark.Client, docToken: string) {
const res = await client.docx.documentBlock.list({
path: { document_id: docToken },
});
if (res.code !== 0) throw new Error(res.msg);
return {
blocks: res.data?.items ?? [],
};
}
async function getBlock(client: Lark.Client, docToken: string, blockId: string) {
const res = await client.docx.documentBlock.get({
path: { document_id: docToken, block_id: blockId },
});
if (res.code !== 0) throw new Error(res.msg);
return {
block: res.data?.block,
};
}
async function listAppScopes(client: Lark.Client) {
const res = await client.application.scope.list({});
if (res.code !== 0) throw new Error(res.msg);
const scopes = res.data?.scopes ?? [];
const granted = scopes.filter((s) => s.grant_status === 1);
const pending = scopes.filter((s) => s.grant_status !== 1);
return {
granted: granted.map((s) => ({ name: s.scope_name, type: s.scope_type })),
pending: pending.map((s) => ({ name: s.scope_name, type: s.scope_type })),
summary: `${granted.length} granted, ${pending.length} pending`,
};
}
// ============ Tool Registration ============
export function registerFeishuDocTools(api: OpenClawPluginApi) {
const feishuCfg = api.config?.channels?.feishu as FeishuConfig | undefined;
if (!feishuCfg?.appId || !feishuCfg?.appSecret) {
api.logger.debug?.("feishu_doc: Feishu credentials not configured, skipping doc tools");
return;
}
const toolsCfg = resolveToolsConfig(feishuCfg.tools);
const getClient = () => createFeishuClient(feishuCfg);
const registered: string[] = [];
// Main document tool with action-based dispatch
if (toolsCfg.doc) {
api.registerTool(
{
name: "feishu_doc",
label: "Feishu Doc",
description:
"Feishu document operations. Actions: read, write, append, create, list_blocks, get_block, update_block, delete_block",
parameters: FeishuDocSchema,
async execute(_toolCallId, params) {
const p = params as FeishuDocParams;
try {
const client = getClient();
switch (p.action) {
case "read":
return json(await readDoc(client, p.doc_token));
case "write":
return json(await writeDoc(client, p.doc_token, p.content));
case "append":
return json(await appendDoc(client, p.doc_token, p.content));
case "create":
return json(await createDoc(client, p.title, p.folder_token));
case "list_blocks":
return json(await listBlocks(client, p.doc_token));
case "get_block":
return json(await getBlock(client, p.doc_token, p.block_id));
case "update_block":
return json(await updateBlock(client, p.doc_token, p.block_id, p.content));
case "delete_block":
return json(await deleteBlock(client, p.doc_token, p.block_id));
default:
return json({ error: `Unknown action: ${(p as any).action}` });
}
} catch (err) {
return json({ error: err instanceof Error ? err.message : String(err) });
}
},
},
{ name: "feishu_doc" },
);
registered.push("feishu_doc");
}
// Keep feishu_app_scopes as independent tool
if (toolsCfg.scopes) {
api.registerTool(
{
name: "feishu_app_scopes",
label: "Feishu App Scopes",
description:
"List current app permissions (scopes). Use to debug permission issues or check available capabilities.",
parameters: Type.Object({}),
async execute() {
try {
const result = await listAppScopes(getClient());
return json(result);
} catch (err) {
return json({ error: err instanceof Error ? err.message : String(err) });
}
},
},
{ name: "feishu_app_scopes" },
);
registered.push("feishu_app_scopes");
}
if (registered.length > 0) {
api.logger.info?.(`feishu_doc: Registered ${registered.join(", ")}`);
}
}

View File

@@ -0,0 +1,46 @@
import { Type, type Static } from "@sinclair/typebox";
const FileType = Type.Union([
Type.Literal("doc"),
Type.Literal("docx"),
Type.Literal("sheet"),
Type.Literal("bitable"),
Type.Literal("folder"),
Type.Literal("file"),
Type.Literal("mindnote"),
Type.Literal("shortcut"),
]);
export const FeishuDriveSchema = Type.Union([
Type.Object({
action: Type.Literal("list"),
folder_token: Type.Optional(
Type.String({ description: "Folder token (optional, omit for root directory)" }),
),
}),
Type.Object({
action: Type.Literal("info"),
file_token: Type.String({ description: "File or folder token" }),
type: FileType,
}),
Type.Object({
action: Type.Literal("create_folder"),
name: Type.String({ description: "Folder name" }),
folder_token: Type.Optional(
Type.String({ description: "Parent folder token (optional, omit for root)" }),
),
}),
Type.Object({
action: Type.Literal("move"),
file_token: Type.String({ description: "File token to move" }),
type: FileType,
folder_token: Type.String({ description: "Target folder token" }),
}),
Type.Object({
action: Type.Literal("delete"),
file_token: Type.String({ description: "File token to delete" }),
type: FileType,
}),
]);
export type FeishuDriveParams = Static<typeof FeishuDriveSchema>;

View File

@@ -0,0 +1,204 @@
import type * as Lark from "@larksuiteoapi/node-sdk";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import type { FeishuConfig } from "./types.js";
import { createFeishuClient } from "./client.js";
import { FeishuDriveSchema, type FeishuDriveParams } from "./drive-schema.js";
import { resolveToolsConfig } from "./tools-config.js";
// ============ Helpers ============
function json(data: unknown) {
return {
content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }],
details: data,
};
}
// ============ Actions ============
async function getRootFolderToken(client: Lark.Client): Promise<string> {
// Use generic HTTP client to call the root folder meta API
// as it's not directly exposed in the SDK
const domain = (client as any).domain ?? "https://open.feishu.cn";
const res = (await (client as any).httpInstance.get(
`${domain}/open-apis/drive/explorer/v2/root_folder/meta`,
)) as { code: number; msg?: string; data?: { token?: string } };
if (res.code !== 0) throw new Error(res.msg ?? "Failed to get root folder");
const token = res.data?.token;
if (!token) throw new Error("Root folder token not found");
return token;
}
async function listFolder(client: Lark.Client, folderToken?: string) {
// Filter out invalid folder_token values (empty, "0", etc.)
const validFolderToken = folderToken && folderToken !== "0" ? folderToken : undefined;
const res = await client.drive.file.list({
params: validFolderToken ? { folder_token: validFolderToken } : {},
});
if (res.code !== 0) throw new Error(res.msg);
return {
files:
res.data?.files?.map((f) => ({
token: f.token,
name: f.name,
type: f.type,
url: f.url,
created_time: f.created_time,
modified_time: f.modified_time,
owner_id: f.owner_id,
})) ?? [],
next_page_token: res.data?.next_page_token,
};
}
async function getFileInfo(client: Lark.Client, fileToken: string, folderToken?: string) {
// Use list with folder_token to find file info
const res = await client.drive.file.list({
params: folderToken ? { folder_token: folderToken } : {},
});
if (res.code !== 0) throw new Error(res.msg);
const file = res.data?.files?.find((f) => f.token === fileToken);
if (!file) {
throw new Error(`File not found: ${fileToken}`);
}
return {
token: file.token,
name: file.name,
type: file.type,
url: file.url,
created_time: file.created_time,
modified_time: file.modified_time,
owner_id: file.owner_id,
};
}
async function createFolder(client: Lark.Client, name: string, folderToken?: string) {
// Feishu supports using folder_token="0" as the root folder.
// We *try* to resolve the real root token (explorer API), but fall back to "0"
// because some tenants/apps return 400 for that explorer endpoint.
let effectiveToken = folderToken && folderToken !== "0" ? folderToken : "0";
if (effectiveToken === "0") {
try {
effectiveToken = await getRootFolderToken(client);
} catch {
// ignore and keep "0"
}
}
const res = await client.drive.file.createFolder({
data: {
name,
folder_token: effectiveToken,
},
});
if (res.code !== 0) throw new Error(res.msg);
return {
token: res.data?.token,
url: res.data?.url,
};
}
async function moveFile(client: Lark.Client, fileToken: string, type: string, folderToken: string) {
const res = await client.drive.file.move({
path: { file_token: fileToken },
data: {
type: type as
| "doc"
| "docx"
| "sheet"
| "bitable"
| "folder"
| "file"
| "mindnote"
| "slides",
folder_token: folderToken,
},
});
if (res.code !== 0) throw new Error(res.msg);
return {
success: true,
task_id: res.data?.task_id,
};
}
async function deleteFile(client: Lark.Client, fileToken: string, type: string) {
const res = await client.drive.file.delete({
path: { file_token: fileToken },
params: {
type: type as
| "doc"
| "docx"
| "sheet"
| "bitable"
| "folder"
| "file"
| "mindnote"
| "slides"
| "shortcut",
},
});
if (res.code !== 0) throw new Error(res.msg);
return {
success: true,
task_id: res.data?.task_id,
};
}
// ============ Tool Registration ============
export function registerFeishuDriveTools(api: OpenClawPluginApi) {
const feishuCfg = api.config?.channels?.feishu as FeishuConfig | undefined;
if (!feishuCfg?.appId || !feishuCfg?.appSecret) {
api.logger.debug?.("feishu_drive: Feishu credentials not configured, skipping drive tools");
return;
}
const toolsCfg = resolveToolsConfig(feishuCfg.tools);
if (!toolsCfg.drive) {
api.logger.debug?.("feishu_drive: drive tool disabled in config");
return;
}
const getClient = () => createFeishuClient(feishuCfg);
api.registerTool(
{
name: "feishu_drive",
label: "Feishu Drive",
description:
"Feishu cloud storage operations. Actions: list, info, create_folder, move, delete",
parameters: FeishuDriveSchema,
async execute(_toolCallId, params) {
const p = params as FeishuDriveParams;
try {
const client = getClient();
switch (p.action) {
case "list":
return json(await listFolder(client, p.folder_token));
case "info":
return json(await getFileInfo(client, p.file_token));
case "create_folder":
return json(await createFolder(client, p.name, p.folder_token));
case "move":
return json(await moveFile(client, p.file_token, p.type, p.folder_token));
case "delete":
return json(await deleteFile(client, p.file_token, p.type));
default:
return json({ error: `Unknown action: ${(p as any).action}` });
}
} catch (err) {
return json({ error: err instanceof Error ? err.message : String(err) });
}
},
},
{ name: "feishu_drive" },
);
api.logger.info?.(`feishu_drive: Registered feishu_drive tool`);
}

View File

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

View File

@@ -0,0 +1,118 @@
import type { FeishuMessageEvent } from "./bot.js";
/**
* Mention target user info
*/
export type MentionTarget = {
openId: string;
name: string;
key: string; // Placeholder in original message, e.g. @_user_1
};
/**
* Extract mention targets from message event (excluding the bot itself)
*/
export function extractMentionTargets(
event: FeishuMessageEvent,
botOpenId?: string,
): MentionTarget[] {
const mentions = event.message.mentions ?? [];
return mentions
.filter((m) => {
// Exclude the bot itself
if (botOpenId && m.id.open_id === botOpenId) return false;
// Must have open_id
return !!m.id.open_id;
})
.map((m) => ({
openId: m.id.open_id!,
name: m.name,
key: m.key,
}));
}
/**
* Check if message is a mention forward request
* Rules:
* - Group: message mentions bot + at least one other user
* - DM: message mentions any user (no need to mention bot)
*/
export function isMentionForwardRequest(event: FeishuMessageEvent, botOpenId?: string): boolean {
const mentions = event.message.mentions ?? [];
if (mentions.length === 0) return false;
const isDirectMessage = event.message.chat_type === "p2p";
const hasOtherMention = mentions.some((m) => m.id.open_id !== botOpenId);
if (isDirectMessage) {
// DM: trigger if any non-bot user is mentioned
return hasOtherMention;
} else {
// Group: need to mention both bot and other users
const hasBotMention = mentions.some((m) => m.id.open_id === botOpenId);
return hasBotMention && hasOtherMention;
}
}
/**
* Extract message body from text (remove @ placeholders)
*/
export function extractMessageBody(text: string, allMentionKeys: string[]): string {
let result = text;
// Remove all @ placeholders
for (const key of allMentionKeys) {
result = result.replace(new RegExp(key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g"), "");
}
return result.replace(/\s+/g, " ").trim();
}
/**
* Format @mention for text message
*/
export function formatMentionForText(target: MentionTarget): string {
return `<at user_id="${target.openId}">${target.name}</at>`;
}
/**
* Format @everyone for text message
*/
export function formatMentionAllForText(): string {
return `<at user_id="all">Everyone</at>`;
}
/**
* Format @mention for card message (lark_md)
*/
export function formatMentionForCard(target: MentionTarget): string {
return `<at id=${target.openId}></at>`;
}
/**
* Format @everyone for card message
*/
export function formatMentionAllForCard(): string {
return `<at id=all></at>`;
}
/**
* Build complete message with @mentions (text format)
*/
export function buildMentionedMessage(targets: MentionTarget[], message: string): string {
if (targets.length === 0) return message;
const mentionParts = targets.map((t) => formatMentionForText(t));
return `${mentionParts.join(" ")} ${message}`;
}
/**
* Build card content with @mentions (Markdown format)
*/
export function buildMentionedCardContent(targets: MentionTarget[], message: string): string {
if (targets.length === 0) return message;
const mentionParts = targets.map((t) => formatMentionForCard(t));
return `${mentionParts.join(" ")} ${message}`;
}

View File

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

View File

@@ -1,124 +1,110 @@
import type {
ChannelOnboardingAdapter,
ChannelOnboardingDmPolicy,
ClawdbotConfig,
DmPolicy,
OpenClawConfig,
WizardPrompter,
} from "openclaw/plugin-sdk";
import {
addWildcardAllowFrom,
DEFAULT_ACCOUNT_ID,
formatDocsLink,
normalizeAccountId,
promptAccountId,
} from "openclaw/plugin-sdk";
import {
listFeishuAccountIds,
resolveDefaultFeishuAccountId,
resolveFeishuAccount,
} from "openclaw/plugin-sdk";
import { addWildcardAllowFrom, DEFAULT_ACCOUNT_ID, formatDocsLink } from "openclaw/plugin-sdk";
import type { FeishuConfig } from "./types.js";
import { resolveFeishuCredentials } from "./accounts.js";
import { probeFeishu } from "./probe.js";
const channel = "feishu" as const;
function setFeishuDmPolicy(cfg: OpenClawConfig, policy: DmPolicy): OpenClawConfig {
function setFeishuDmPolicy(cfg: ClawdbotConfig, dmPolicy: DmPolicy): ClawdbotConfig {
const allowFrom =
policy === "open" ? addWildcardAllowFrom(cfg.channels?.feishu?.allowFrom) : undefined;
dmPolicy === "open"
? addWildcardAllowFrom(cfg.channels?.feishu?.allowFrom)?.map((entry) => String(entry))
: undefined;
return {
...cfg,
channels: {
...cfg.channels,
feishu: {
...cfg.channels?.feishu,
enabled: true,
dmPolicy: policy,
dmPolicy,
...(allowFrom ? { allowFrom } : {}),
},
},
};
}
async function noteFeishuSetup(prompter: WizardPrompter): Promise<void> {
await prompter.note(
[
"Create a Feishu/Lark app and enable Bot + Event Subscription (WebSocket).",
"Copy the App ID and App Secret from the app credentials page.",
'Lark (global): use open.larksuite.com and set domain="lark".',
`Docs: ${formatDocsLink("/channels/feishu", "channels/feishu")}`,
].join("\n"),
"Feishu setup",
);
function setFeishuAllowFrom(cfg: ClawdbotConfig, allowFrom: string[]): ClawdbotConfig {
return {
...cfg,
channels: {
...cfg.channels,
feishu: {
...cfg.channels?.feishu,
allowFrom,
},
},
};
}
function normalizeAllowEntry(entry: string): string {
return entry.replace(/^(feishu|lark):/i, "").trim();
}
function resolveDomainChoice(domain?: string | null): "feishu" | "lark" {
const normalized = String(domain ?? "").toLowerCase();
if (normalized.includes("lark") || normalized.includes("larksuite")) {
return "lark";
}
return "feishu";
function parseAllowFromInput(raw: string): string[] {
return raw
.split(/[\n,;]+/g)
.map((entry) => entry.trim())
.filter(Boolean);
}
async function promptFeishuAllowFrom(params: {
cfg: OpenClawConfig;
cfg: ClawdbotConfig;
prompter: WizardPrompter;
accountId?: string | null;
}): Promise<OpenClawConfig> {
const { cfg, prompter } = params;
const accountId = normalizeAccountId(params.accountId);
const isDefault = accountId === DEFAULT_ACCOUNT_ID;
const existingAllowFrom = isDefault
? (cfg.channels?.feishu?.allowFrom ?? [])
: (cfg.channels?.feishu?.accounts?.[accountId]?.allowFrom ?? []);
}): Promise<ClawdbotConfig> {
const existing = params.cfg.channels?.feishu?.allowFrom ?? [];
await params.prompter.note(
[
"Allowlist Feishu DMs by open_id or user_id.",
"You can find user open_id in Feishu admin console or via API.",
"Examples:",
"- ou_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"- on_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
].join("\n"),
"Feishu allowlist",
);
const entry = await prompter.text({
message: "Feishu allowFrom (open_id or union_id)",
placeholder: "ou_xxx",
initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined,
validate: (value) => {
const raw = String(value ?? "").trim();
if (!raw) {
return "Required";
}
const entries = raw
.split(/[\n,;]+/g)
.map((item) => normalizeAllowEntry(item))
.filter(Boolean);
const invalid = entries.filter((item) => item !== "*" && !/^o[un]_[a-zA-Z0-9]+$/.test(item));
if (invalid.length > 0) {
return `Invalid Feishu ids: ${invalid.join(", ")}`;
}
return undefined;
},
});
while (true) {
const entry = await params.prompter.text({
message: "Feishu allowFrom (user open_ids)",
placeholder: "ou_xxxxx, ou_yyyyy",
initialValue: existing[0] ? String(existing[0]) : undefined,
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
});
const parts = parseAllowFromInput(String(entry));
if (parts.length === 0) {
await params.prompter.note("Enter at least one user.", "Feishu allowlist");
continue;
}
const parsed = String(entry)
.split(/[\n,;]+/g)
.map((item) => normalizeAllowEntry(item))
.filter(Boolean);
const merged = [
...existingAllowFrom.map((item) => normalizeAllowEntry(String(item))),
...parsed,
].filter(Boolean);
const unique = Array.from(new Set(merged));
if (isDefault) {
return {
...cfg,
channels: {
...cfg.channels,
feishu: {
...cfg.channels?.feishu,
enabled: true,
dmPolicy: "allowlist",
allowFrom: unique,
},
},
};
const unique = [
...new Set([...existing.map((v) => String(v).trim()).filter(Boolean), ...parts]),
];
return setFeishuAllowFrom(params.cfg, unique);
}
}
async function noteFeishuCredentialHelp(prompter: WizardPrompter): Promise<void> {
await prompter.note(
[
"1) Go to Feishu Open Platform (open.feishu.cn)",
"2) Create a self-built app",
"3) Get App ID and App Secret from Credentials page",
"4) Enable required permissions: im:message, im:chat, contact:user.base:readonly",
"5) Publish the app or add it to a test group",
"Tip: you can also set FEISHU_APP_ID / FEISHU_APP_SECRET env vars.",
`Docs: ${formatDocsLink("/channels/feishu", "feishu")}`,
].join("\n"),
"Feishu credentials",
);
}
function setFeishuGroupPolicy(
cfg: ClawdbotConfig,
groupPolicy: "open" | "allowlist" | "disabled",
): ClawdbotConfig {
return {
...cfg,
channels: {
@@ -126,15 +112,20 @@ async function promptFeishuAllowFrom(params: {
feishu: {
...cfg.channels?.feishu,
enabled: true,
accounts: {
...cfg.channels?.feishu?.accounts,
[accountId]: {
...cfg.channels?.feishu?.accounts?.[accountId],
enabled: cfg.channels?.feishu?.accounts?.[accountId]?.enabled ?? true,
dmPolicy: "allowlist",
allowFrom: unique,
},
},
groupPolicy,
},
},
};
}
function setFeishuGroupAllowFrom(cfg: ClawdbotConfig, groupAllowFrom: string[]): ClawdbotConfig {
return {
...cfg,
channels: {
...cfg.channels,
feishu: {
...cfg.channels?.feishu,
groupAllowFrom,
},
},
};
@@ -145,134 +136,221 @@ const dmPolicy: ChannelOnboardingDmPolicy = {
channel,
policyKey: "channels.feishu.dmPolicy",
allowFromKey: "channels.feishu.allowFrom",
getCurrent: (cfg) => cfg.channels?.feishu?.dmPolicy ?? "pairing",
getCurrent: (cfg) => (cfg.channels?.feishu as FeishuConfig | undefined)?.dmPolicy ?? "pairing",
setPolicy: (cfg, policy) => setFeishuDmPolicy(cfg, policy),
promptAllowFrom: promptFeishuAllowFrom,
};
function updateFeishuConfig(
cfg: OpenClawConfig,
accountId: string,
updates: { appId?: string; appSecret?: string; domain?: string; enabled?: boolean },
): OpenClawConfig {
const isDefault = accountId === DEFAULT_ACCOUNT_ID;
const next = { ...cfg } as OpenClawConfig;
const feishu = { ...next.channels?.feishu } as Record<string, unknown>;
const accounts = feishu.accounts
? { ...(feishu.accounts as Record<string, unknown>) }
: undefined;
if (isDefault && !accounts) {
return {
...next,
channels: {
...next.channels,
feishu: {
...feishu,
...updates,
enabled: updates.enabled ?? true,
},
},
};
}
const resolvedAccounts = accounts ?? {};
const existing = (resolvedAccounts[accountId] as Record<string, unknown>) ?? {};
resolvedAccounts[accountId] = {
...existing,
...updates,
enabled: updates.enabled ?? true,
};
return {
...next,
channels: {
...next.channels,
feishu: {
...feishu,
accounts: resolvedAccounts,
},
},
};
}
export const feishuOnboardingAdapter: ChannelOnboardingAdapter = {
channel,
dmPolicy,
getStatus: async ({ cfg }) => {
const configured = listFeishuAccountIds(cfg).some((id) => {
const acc = resolveFeishuAccount({ cfg, accountId: id });
return acc.tokenSource !== "none";
});
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
const configured = Boolean(resolveFeishuCredentials(feishuCfg));
// Try to probe if configured
let probeResult = null;
if (configured && feishuCfg) {
try {
probeResult = await probeFeishu(feishuCfg);
} catch {
// Ignore probe errors
}
}
const statusLines: string[] = [];
if (!configured) {
statusLines.push("Feishu: needs app credentials");
} else if (probeResult?.ok) {
statusLines.push(
`Feishu: connected as ${probeResult.botName ?? probeResult.botOpenId ?? "bot"}`,
);
} else {
statusLines.push("Feishu: configured (connection not verified)");
}
return {
channel,
configured,
statusLines: [`Feishu: ${configured ? "configured" : "needs app credentials"}`],
selectionHint: configured ? "configured" : "requires app credentials",
quickstartScore: configured ? 1 : 10,
statusLines,
selectionHint: configured ? "configured" : "needs app creds",
quickstartScore: configured ? 2 : 0,
};
},
configure: async ({ cfg, prompter, accountOverrides, shouldPromptAccountIds }) => {
let next = cfg;
const override = accountOverrides.feishu?.trim();
const defaultId = resolveDefaultFeishuAccountId(next);
let accountId = override ? normalizeAccountId(override) : defaultId;
if (shouldPromptAccountIds && !override) {
accountId = await promptAccountId({
cfg: next,
prompter,
label: "Feishu",
currentId: accountId,
listAccountIds: listFeishuAccountIds,
defaultAccountId: defaultId,
});
configure: async ({ cfg, prompter }) => {
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
const resolved = resolveFeishuCredentials(feishuCfg);
const hasConfigCreds = Boolean(feishuCfg?.appId?.trim() && feishuCfg?.appSecret?.trim());
const canUseEnv = Boolean(
!hasConfigCreds && process.env.FEISHU_APP_ID?.trim() && process.env.FEISHU_APP_SECRET?.trim(),
);
let next = cfg;
let appId: string | null = null;
let appSecret: string | null = null;
if (!resolved) {
await noteFeishuCredentialHelp(prompter);
}
await noteFeishuSetup(prompter);
const resolved = resolveFeishuAccount({ cfg: next, accountId });
const domainChoice = await prompter.select({
message: "Feishu domain",
options: [
{ value: "feishu", label: "Feishu (China) — open.feishu.cn" },
{ value: "lark", label: "Lark (global) — open.larksuite.com" },
],
initialValue: resolveDomainChoice(resolved.config.domain),
});
const domain = domainChoice === "lark" ? "lark" : "feishu";
const isDefault = accountId === DEFAULT_ACCOUNT_ID;
const envAppId = process.env.FEISHU_APP_ID?.trim();
const envSecret = process.env.FEISHU_APP_SECRET?.trim();
if (isDefault && envAppId && envSecret) {
const useEnv = await prompter.confirm({
message: "FEISHU_APP_ID/FEISHU_APP_SECRET detected. Use env vars?",
if (canUseEnv) {
const keepEnv = await prompter.confirm({
message: "FEISHU_APP_ID + FEISHU_APP_SECRET detected. Use env vars?",
initialValue: true,
});
if (useEnv) {
next = updateFeishuConfig(next, accountId, { enabled: true, domain });
return { cfg: next, accountId };
if (keepEnv) {
next = {
...next,
channels: {
...next.channels,
feishu: { ...next.channels?.feishu, enabled: true },
},
};
} else {
appId = String(
await prompter.text({
message: "Enter Feishu App ID",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
appSecret = String(
await prompter.text({
message: "Enter Feishu App Secret",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
}
} else if (hasConfigCreds) {
const keep = await prompter.confirm({
message: "Feishu credentials already configured. Keep them?",
initialValue: true,
});
if (!keep) {
appId = String(
await prompter.text({
message: "Enter Feishu App ID",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
appSecret = String(
await prompter.text({
message: "Enter Feishu App Secret",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
}
} else {
appId = String(
await prompter.text({
message: "Enter Feishu App ID",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
appSecret = String(
await prompter.text({
message: "Enter Feishu App Secret",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
}
if (appId && appSecret) {
next = {
...next,
channels: {
...next.channels,
feishu: {
...next.channels?.feishu,
enabled: true,
appId,
appSecret,
},
},
};
// Test connection
const testCfg = next.channels?.feishu as FeishuConfig;
try {
const probe = await probeFeishu(testCfg);
if (probe.ok) {
await prompter.note(
`Connected as ${probe.botName ?? probe.botOpenId ?? "bot"}`,
"Feishu connection test",
);
} else {
await prompter.note(
`Connection failed: ${probe.error ?? "unknown error"}`,
"Feishu connection test",
);
}
} catch (err) {
await prompter.note(`Connection test failed: ${String(err)}`, "Feishu connection test");
}
}
const appId = String(
await prompter.text({
message: "Feishu App ID (cli_...)",
initialValue: resolved.config.appId?.trim() || undefined,
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
}),
).trim();
const appSecret = String(
await prompter.text({
message: "Feishu App Secret",
initialValue: resolved.config.appSecret?.trim() || undefined,
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
}),
).trim();
// Domain selection
const currentDomain = (next.channels?.feishu as FeishuConfig | undefined)?.domain ?? "feishu";
const domain = await prompter.select({
message: "Which Feishu domain?",
options: [
{ value: "feishu", label: "Feishu (feishu.cn) - China" },
{ value: "lark", label: "Lark (larksuite.com) - International" },
],
initialValue: currentDomain,
});
if (domain) {
next = {
...next,
channels: {
...next.channels,
feishu: {
...next.channels?.feishu,
domain: domain as "feishu" | "lark",
},
},
};
}
next = updateFeishuConfig(next, accountId, { appId, appSecret, domain, enabled: true });
// Group policy
const groupPolicy = await prompter.select({
message: "Group chat policy",
options: [
{ value: "allowlist", label: "Allowlist - only respond in specific groups" },
{ value: "open", label: "Open - respond in all groups (requires mention)" },
{ value: "disabled", label: "Disabled - don't respond in groups" },
],
initialValue: (next.channels?.feishu as FeishuConfig | undefined)?.groupPolicy ?? "allowlist",
});
if (groupPolicy) {
next = setFeishuGroupPolicy(next, groupPolicy as "open" | "allowlist" | "disabled");
}
return { cfg: next, accountId };
// Group allowlist if needed
if (groupPolicy === "allowlist") {
const existing = (next.channels?.feishu as FeishuConfig | undefined)?.groupAllowFrom ?? [];
const entry = await prompter.text({
message: "Group chat allowlist (chat_ids)",
placeholder: "oc_xxxxx, oc_yyyyy",
initialValue: existing.length > 0 ? existing.map(String).join(", ") : undefined,
});
if (entry) {
const parts = parseAllowFromInput(String(entry));
if (parts.length > 0) {
next = setFeishuGroupAllowFrom(next, parts);
}
}
}
return { cfg: next, accountId: DEFAULT_ACCOUNT_ID };
},
dmPolicy,
disable: (cfg) => ({
...cfg,
channels: {
...cfg.channels,
feishu: { ...cfg.channels?.feishu, enabled: false },
},
}),
};

View File

@@ -0,0 +1,40 @@
import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk";
import { sendMediaFeishu } from "./media.js";
import { getFeishuRuntime } from "./runtime.js";
import { sendMessageFeishu } from "./send.js";
export const feishuOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct",
chunker: (text, limit) => getFeishuRuntime().channel.text.chunkMarkdownText(text, limit),
chunkerMode: "markdown",
textChunkLimit: 4000,
sendText: async ({ cfg, to, text }) => {
const result = await sendMessageFeishu({ cfg, to, text });
return { channel: "feishu", ...result };
},
sendMedia: async ({ cfg, to, text, mediaUrl }) => {
// Send text first if provided
if (text?.trim()) {
await sendMessageFeishu({ cfg, to, text });
}
// Upload and send media if URL provided
if (mediaUrl) {
try {
const result = await sendMediaFeishu({ cfg, to, mediaUrl });
return { channel: "feishu", ...result };
} catch (err) {
// Log the error for debugging
console.error(`[feishu] sendMediaFeishu failed:`, err);
// Fallback to URL link if upload fails
const fallbackText = `📎 ${mediaUrl}`;
const result = await sendMessageFeishu({ cfg, to, text: fallbackText });
return { channel: "feishu", ...result };
}
}
// No media URL, just return text result
const result = await sendMessageFeishu({ cfg, to, text: text ?? "" });
return { channel: "feishu", ...result };
},
};

View File

@@ -0,0 +1,52 @@
import { Type, type Static } from "@sinclair/typebox";
const TokenType = Type.Union([
Type.Literal("doc"),
Type.Literal("docx"),
Type.Literal("sheet"),
Type.Literal("bitable"),
Type.Literal("folder"),
Type.Literal("file"),
Type.Literal("wiki"),
Type.Literal("mindnote"),
]);
const MemberType = Type.Union([
Type.Literal("email"),
Type.Literal("openid"),
Type.Literal("userid"),
Type.Literal("unionid"),
Type.Literal("openchat"),
Type.Literal("opendepartmentid"),
]);
const Permission = Type.Union([
Type.Literal("view"),
Type.Literal("edit"),
Type.Literal("full_access"),
]);
export const FeishuPermSchema = Type.Union([
Type.Object({
action: Type.Literal("list"),
token: Type.String({ description: "File token" }),
type: TokenType,
}),
Type.Object({
action: Type.Literal("add"),
token: Type.String({ description: "File token" }),
type: TokenType,
member_type: MemberType,
member_id: Type.String({ description: "Member ID (email, open_id, user_id, etc.)" }),
perm: Permission,
}),
Type.Object({
action: Type.Literal("remove"),
token: Type.String({ description: "File token" }),
type: TokenType,
member_type: MemberType,
member_id: Type.String({ description: "Member ID to remove" }),
}),
]);
export type FeishuPermParams = Static<typeof FeishuPermSchema>;

View File

@@ -0,0 +1,160 @@
import type * as Lark from "@larksuiteoapi/node-sdk";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import type { FeishuConfig } from "./types.js";
import { createFeishuClient } from "./client.js";
import { FeishuPermSchema, type FeishuPermParams } from "./perm-schema.js";
import { resolveToolsConfig } from "./tools-config.js";
// ============ Helpers ============
function json(data: unknown) {
return {
content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }],
details: data,
};
}
type ListTokenType =
| "doc"
| "sheet"
| "file"
| "wiki"
| "bitable"
| "docx"
| "mindnote"
| "minutes"
| "slides";
type CreateTokenType =
| "doc"
| "sheet"
| "file"
| "wiki"
| "bitable"
| "docx"
| "folder"
| "mindnote"
| "minutes"
| "slides";
type MemberType =
| "email"
| "openid"
| "unionid"
| "openchat"
| "opendepartmentid"
| "userid"
| "groupid"
| "wikispaceid";
type PermType = "view" | "edit" | "full_access";
// ============ Actions ============
async function listMembers(client: Lark.Client, token: string, type: string) {
const res = await client.drive.permissionMember.list({
path: { token },
params: { type: type as ListTokenType },
});
if (res.code !== 0) throw new Error(res.msg);
return {
members:
res.data?.items?.map((m) => ({
member_type: m.member_type,
member_id: m.member_id,
perm: m.perm,
name: m.name,
})) ?? [],
};
}
async function addMember(
client: Lark.Client,
token: string,
type: string,
memberType: string,
memberId: string,
perm: string,
) {
const res = await client.drive.permissionMember.create({
path: { token },
params: { type: type as CreateTokenType, need_notification: false },
data: {
member_type: memberType as MemberType,
member_id: memberId,
perm: perm as PermType,
},
});
if (res.code !== 0) throw new Error(res.msg);
return {
success: true,
member: res.data?.member,
};
}
async function removeMember(
client: Lark.Client,
token: string,
type: string,
memberType: string,
memberId: string,
) {
const res = await client.drive.permissionMember.delete({
path: { token, member_id: memberId },
params: { type: type as CreateTokenType, member_type: memberType as MemberType },
});
if (res.code !== 0) throw new Error(res.msg);
return {
success: true,
};
}
// ============ Tool Registration ============
export function registerFeishuPermTools(api: OpenClawPluginApi) {
const feishuCfg = api.config?.channels?.feishu as FeishuConfig | undefined;
if (!feishuCfg?.appId || !feishuCfg?.appSecret) {
api.logger.debug?.("feishu_perm: Feishu credentials not configured, skipping perm tools");
return;
}
const toolsCfg = resolveToolsConfig(feishuCfg.tools);
if (!toolsCfg.perm) {
api.logger.debug?.("feishu_perm: perm tool disabled in config (default: false)");
return;
}
const getClient = () => createFeishuClient(feishuCfg);
api.registerTool(
{
name: "feishu_perm",
label: "Feishu Perm",
description: "Feishu permission management. Actions: list, add, remove",
parameters: FeishuPermSchema,
async execute(_toolCallId, params) {
const p = params as FeishuPermParams;
try {
const client = getClient();
switch (p.action) {
case "list":
return json(await listMembers(client, p.token, p.type));
case "add":
return json(
await addMember(client, p.token, p.type, p.member_type, p.member_id, p.perm),
);
case "remove":
return json(await removeMember(client, p.token, p.type, p.member_type, p.member_id));
default:
return json({ error: `Unknown action: ${(p as any).action}` });
}
} catch (err) {
return json({ error: err instanceof Error ? err.message : String(err) });
}
},
},
{ name: "feishu_perm" },
);
api.logger.info?.(`feishu_perm: Registered feishu_perm tool`);
}

View File

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

View File

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

View File

@@ -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,161 @@
import {
createReplyPrefixContext,
createTypingCallbacks,
logTypingFailure,
type ClawdbotConfig,
type RuntimeEnv,
type ReplyPayload,
} from "openclaw/plugin-sdk";
import type { MentionTarget } from "./mention.js";
import type { FeishuConfig } from "./types.js";
import { getFeishuRuntime } from "./runtime.js";
import { sendMessageFeishu, sendMarkdownCardFeishu } from "./send.js";
import { addTypingIndicator, removeTypingIndicator, type TypingIndicatorState } from "./typing.js";
/**
* Detect if text contains markdown elements that benefit from card rendering.
* Used by auto render mode.
*/
function shouldUseCard(text: string): boolean {
// Code blocks (fenced)
if (/```[\s\S]*?```/.test(text)) return true;
// Tables (at least header + separator row with |)
if (/\|.+\|[\r\n]+\|[-:| ]+\|/.test(text)) return true;
return false;
}
export type CreateFeishuReplyDispatcherParams = {
cfg: ClawdbotConfig;
agentId: string;
runtime: RuntimeEnv;
chatId: string;
replyToMessageId?: string;
/** Mention targets, will be auto-included in replies */
mentionTargets?: MentionTarget[];
};
export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherParams) {
const core = getFeishuRuntime();
const { cfg, agentId, chatId, replyToMessageId, mentionTargets } = params;
const prefixContext = createReplyPrefixContext({
cfg,
agentId,
});
// Feishu doesn't have a native typing indicator API.
// We use message reactions as a typing indicator substitute.
let typingState: TypingIndicatorState | null = null;
const typingCallbacks = createTypingCallbacks({
start: async () => {
if (!replyToMessageId) return;
typingState = await addTypingIndicator({ cfg, messageId: replyToMessageId });
params.runtime.log?.(`feishu: added typing indicator reaction`);
},
stop: async () => {
if (!typingState) return;
await removeTypingIndicator({ cfg, state: typingState });
typingState = null;
params.runtime.log?.(`feishu: removed typing indicator reaction`);
},
onStartError: (err) => {
logTypingFailure({
log: (message) => params.runtime.log?.(message),
channel: "feishu",
action: "start",
error: err,
});
},
onStopError: (err) => {
logTypingFailure({
log: (message) => params.runtime.log?.(message),
channel: "feishu",
action: "stop",
error: err,
});
},
});
const textChunkLimit = core.channel.text.resolveTextChunkLimit({
cfg,
channel: "feishu",
defaultLimit: 4000,
});
const chunkMode = core.channel.text.resolveChunkMode(cfg, "feishu");
const tableMode = core.channel.text.resolveMarkdownTableMode({
cfg,
channel: "feishu",
});
const { dispatcher, replyOptions, markDispatchIdle } =
core.channel.reply.createReplyDispatcherWithTyping({
responsePrefix: prefixContext.responsePrefix,
responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, agentId),
onReplyStart: typingCallbacks.onReplyStart,
deliver: async (payload: ReplyPayload) => {
params.runtime.log?.(`feishu deliver called: text=${payload.text?.slice(0, 100)}`);
const text = payload.text ?? "";
if (!text.trim()) {
params.runtime.log?.(`feishu deliver: empty text, skipping`);
return;
}
// Check render mode: auto (default), raw, or card
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
const renderMode = feishuCfg?.renderMode ?? "auto";
// Determine if we should use card for this message
const useCard = renderMode === "card" || (renderMode === "auto" && shouldUseCard(text));
// Only include @mentions in the first chunk (avoid duplicate @s)
let isFirstChunk = true;
if (useCard) {
// Card mode: send as interactive card with markdown rendering
const chunks = core.channel.text.chunkTextWithMode(text, textChunkLimit, chunkMode);
params.runtime.log?.(`feishu deliver: sending ${chunks.length} card chunks to ${chatId}`);
for (const chunk of chunks) {
await sendMarkdownCardFeishu({
cfg,
to: chatId,
text: chunk,
replyToMessageId,
mentions: isFirstChunk ? mentionTargets : undefined,
});
isFirstChunk = false;
}
} else {
// Raw mode: send as plain text with table conversion
const converted = core.channel.text.convertMarkdownTables(text, tableMode);
const chunks = core.channel.text.chunkTextWithMode(converted, textChunkLimit, chunkMode);
params.runtime.log?.(`feishu deliver: sending ${chunks.length} text chunks to ${chatId}`);
for (const chunk of chunks) {
await sendMessageFeishu({
cfg,
to: chatId,
text: chunk,
replyToMessageId,
mentions: isFirstChunk ? mentionTargets : undefined,
});
isFirstChunk = false;
}
}
},
onError: (err, info) => {
params.runtime.error?.(`feishu ${info.kind} reply failed: ${String(err)}`);
typingCallbacks.onIdle?.();
},
onIdle: typingCallbacks.onIdle,
});
return {
dispatcher,
replyOptions: {
...replyOptions,
onModelSelected: prefixContext.onModelSelected,
},
markDispatchIdle,
};
}

View File

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

View File

@@ -0,0 +1,356 @@
import type { ClawdbotConfig } from "openclaw/plugin-sdk";
import type { MentionTarget } from "./mention.js";
import type { FeishuConfig, FeishuSendResult } from "./types.js";
import { createFeishuClient } from "./client.js";
import { buildMentionedMessage, buildMentionedCardContent } from "./mention.js";
import { getFeishuRuntime } from "./runtime.js";
import { resolveReceiveIdType, normalizeFeishuTarget } from "./targets.js";
export type FeishuMessageInfo = {
messageId: string;
chatId: string;
senderId?: string;
senderOpenId?: string;
content: string;
contentType: string;
createTime?: number;
};
/**
* Get a message by its ID.
* Useful for fetching quoted/replied message content.
*/
export async function getMessageFeishu(params: {
cfg: ClawdbotConfig;
messageId: string;
}): Promise<FeishuMessageInfo | null> {
const { cfg, messageId } = params;
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
if (!feishuCfg) {
throw new Error("Feishu channel not configured");
}
const client = createFeishuClient(feishuCfg);
try {
const response = (await client.im.message.get({
path: { message_id: messageId },
})) as {
code?: number;
msg?: string;
data?: {
items?: Array<{
message_id?: string;
chat_id?: string;
msg_type?: string;
body?: { content?: string };
sender?: {
id?: string;
id_type?: string;
sender_type?: string;
};
create_time?: string;
}>;
};
};
if (response.code !== 0) {
return null;
}
const item = response.data?.items?.[0];
if (!item) {
return null;
}
// Parse content based on message type
let content = item.body?.content ?? "";
try {
const parsed = JSON.parse(content);
if (item.msg_type === "text" && parsed.text) {
content = parsed.text;
}
} catch {
// Keep raw content if parsing fails
}
return {
messageId: item.message_id ?? messageId,
chatId: item.chat_id ?? "",
senderId: item.sender?.id,
senderOpenId: item.sender?.id_type === "open_id" ? item.sender?.id : undefined,
content,
contentType: item.msg_type ?? "text",
createTime: item.create_time ? parseInt(item.create_time, 10) : undefined,
};
} catch {
return null;
}
}
export type SendFeishuMessageParams = {
cfg: ClawdbotConfig;
to: string;
text: string;
replyToMessageId?: string;
/** Mention target users */
mentions?: MentionTarget[];
};
function buildFeishuPostMessagePayload(params: { feishuCfg: FeishuConfig; messageText: string }): {
content: string;
msgType: string;
} {
const { messageText } = params;
return {
content: JSON.stringify({
zh_cn: {
content: [
[
{
tag: "md",
text: messageText,
},
],
],
},
}),
msgType: "post",
};
}
export async function sendMessageFeishu(
params: SendFeishuMessageParams,
): Promise<FeishuSendResult> {
const { cfg, to, text, replyToMessageId, mentions } = params;
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
if (!feishuCfg) {
throw new Error("Feishu channel not configured");
}
const client = createFeishuClient(feishuCfg);
const receiveId = normalizeFeishuTarget(to);
if (!receiveId) {
throw new Error(`Invalid Feishu target: ${to}`);
}
const receiveIdType = resolveReceiveIdType(receiveId);
const tableMode = getFeishuRuntime().channel.text.resolveMarkdownTableMode({
cfg,
channel: "feishu",
});
// Build message content (with @mention support)
let rawText = text ?? "";
if (mentions && mentions.length > 0) {
rawText = buildMentionedMessage(mentions, rawText);
}
const messageText = getFeishuRuntime().channel.text.convertMarkdownTables(rawText, tableMode);
const { content, msgType } = buildFeishuPostMessagePayload({
feishuCfg,
messageText,
});
if (replyToMessageId) {
const response = await client.im.message.reply({
path: { message_id: replyToMessageId },
data: {
content,
msg_type: msgType,
},
});
if (response.code !== 0) {
throw new Error(`Feishu reply failed: ${response.msg || `code ${response.code}`}`);
}
return {
messageId: response.data?.message_id ?? "unknown",
chatId: receiveId,
};
}
const response = await client.im.message.create({
params: { receive_id_type: receiveIdType },
data: {
receive_id: receiveId,
content,
msg_type: msgType,
},
});
if (response.code !== 0) {
throw new Error(`Feishu send failed: ${response.msg || `code ${response.code}`}`);
}
return {
messageId: response.data?.message_id ?? "unknown",
chatId: receiveId,
};
}
export type SendFeishuCardParams = {
cfg: ClawdbotConfig;
to: string;
card: Record<string, unknown>;
replyToMessageId?: string;
};
export async function sendCardFeishu(params: SendFeishuCardParams): Promise<FeishuSendResult> {
const { cfg, to, card, replyToMessageId } = params;
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
if (!feishuCfg) {
throw new Error("Feishu channel not configured");
}
const client = createFeishuClient(feishuCfg);
const receiveId = normalizeFeishuTarget(to);
if (!receiveId) {
throw new Error(`Invalid Feishu target: ${to}`);
}
const receiveIdType = resolveReceiveIdType(receiveId);
const content = JSON.stringify(card);
if (replyToMessageId) {
const response = await client.im.message.reply({
path: { message_id: replyToMessageId },
data: {
content,
msg_type: "interactive",
},
});
if (response.code !== 0) {
throw new Error(`Feishu card reply failed: ${response.msg || `code ${response.code}`}`);
}
return {
messageId: response.data?.message_id ?? "unknown",
chatId: receiveId,
};
}
const response = await client.im.message.create({
params: { receive_id_type: receiveIdType },
data: {
receive_id: receiveId,
content,
msg_type: "interactive",
},
});
if (response.code !== 0) {
throw new Error(`Feishu card send failed: ${response.msg || `code ${response.code}`}`);
}
return {
messageId: response.data?.message_id ?? "unknown",
chatId: receiveId,
};
}
export async function updateCardFeishu(params: {
cfg: ClawdbotConfig;
messageId: string;
card: Record<string, unknown>;
}): Promise<void> {
const { cfg, messageId, card } = params;
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
if (!feishuCfg) {
throw new Error("Feishu channel not configured");
}
const client = createFeishuClient(feishuCfg);
const content = JSON.stringify(card);
const response = await client.im.message.patch({
path: { message_id: messageId },
data: { content },
});
if (response.code !== 0) {
throw new Error(`Feishu card update failed: ${response.msg || `code ${response.code}`}`);
}
}
/**
* Build a Feishu interactive card with markdown content.
* Cards render markdown properly (code blocks, tables, links, etc.)
*/
export function buildMarkdownCard(text: string): Record<string, unknown> {
return {
config: {
wide_screen_mode: true,
},
elements: [
{
tag: "markdown",
content: text,
},
],
};
}
/**
* Send a message as a markdown card (interactive message).
* This renders markdown properly in Feishu (code blocks, tables, bold/italic, etc.)
*/
export async function sendMarkdownCardFeishu(params: {
cfg: ClawdbotConfig;
to: string;
text: string;
replyToMessageId?: string;
/** Mention target users */
mentions?: MentionTarget[];
}): Promise<FeishuSendResult> {
const { cfg, to, text, replyToMessageId, mentions } = params;
// Build message content (with @mention support)
let cardText = text;
if (mentions && mentions.length > 0) {
cardText = buildMentionedCardContent(mentions, text);
}
const card = buildMarkdownCard(cardText);
return sendCardFeishu({ cfg, to, card, replyToMessageId });
}
/**
* Edit an existing text message.
* Note: Feishu only allows editing messages within 24 hours.
*/
export async function editMessageFeishu(params: {
cfg: ClawdbotConfig;
messageId: string;
text: string;
}): Promise<void> {
const { cfg, messageId, text } = params;
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
if (!feishuCfg) {
throw new Error("Feishu channel not configured");
}
const client = createFeishuClient(feishuCfg);
const tableMode = getFeishuRuntime().channel.text.resolveMarkdownTableMode({
cfg,
channel: "feishu",
});
const messageText = getFeishuRuntime().channel.text.convertMarkdownTables(text ?? "", tableMode);
const { content, msgType } = buildFeishuPostMessagePayload({
feishuCfg,
messageText,
});
const response = await client.im.message.update({
path: { message_id: messageId },
data: {
msg_type: msgType,
content,
},
});
if (response.code !== 0) {
throw new Error(`Feishu message edit failed: ${response.msg || `code ${response.code}`}`);
}
}

View File

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

View File

@@ -0,0 +1,21 @@
import type { FeishuToolsConfig } from "./types.js";
/**
* Default tool configuration.
* - doc, wiki, drive, scopes: enabled by default
* - perm: disabled by default (sensitive operation)
*/
export const DEFAULT_TOOLS_CONFIG: Required<FeishuToolsConfig> = {
doc: true,
wiki: true,
drive: true,
perm: false,
scopes: true,
};
/**
* Resolve tools config with defaults.
*/
export function resolveToolsConfig(cfg?: FeishuToolsConfig): Required<FeishuToolsConfig> {
return { ...DEFAULT_TOOLS_CONFIG, ...cfg };
}

View File

@@ -0,0 +1,63 @@
import type { FeishuConfigSchema, FeishuGroupSchema, z } from "./config-schema.js";
import type { MentionTarget } from "./mention.js";
export type FeishuConfig = z.infer<typeof FeishuConfigSchema>;
export type FeishuGroupConfig = z.infer<typeof FeishuGroupSchema>;
export type FeishuDomain = "feishu" | "lark" | (string & {});
export type FeishuConnectionMode = "websocket" | "webhook";
export type ResolvedFeishuAccount = {
accountId: string;
enabled: boolean;
configured: boolean;
appId?: string;
domain: FeishuDomain;
};
export type FeishuIdType = "open_id" | "user_id" | "union_id" | "chat_id";
export type FeishuMessageContext = {
chatId: string;
messageId: string;
senderId: string;
senderOpenId: string;
senderName?: string;
chatType: "p2p" | "group";
mentionedBot: boolean;
rootId?: string;
parentId?: string;
content: string;
contentType: string;
/** Mention forward targets (excluding the bot itself) */
mentionTargets?: MentionTarget[];
/** Extracted message body (after removing @ placeholders) */
mentionMessageBody?: string;
};
export type FeishuSendResult = {
messageId: string;
chatId: string;
};
export type FeishuProbeResult = {
ok: boolean;
error?: string;
appId?: string;
botName?: string;
botOpenId?: string;
};
export type FeishuMediaInfo = {
path: string;
contentType?: string;
placeholder: string;
};
export type FeishuToolsConfig = {
doc?: boolean;
wiki?: boolean;
drive?: boolean;
perm?: boolean;
scopes?: boolean;
};

View File

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

View File

@@ -0,0 +1,55 @@
import { Type, type Static } from "@sinclair/typebox";
export const FeishuWikiSchema = Type.Union([
Type.Object({
action: Type.Literal("spaces"),
}),
Type.Object({
action: Type.Literal("nodes"),
space_id: Type.String({ description: "Knowledge space ID" }),
parent_node_token: Type.Optional(
Type.String({ description: "Parent node token (optional, omit for root)" }),
),
}),
Type.Object({
action: Type.Literal("get"),
token: Type.String({ description: "Wiki node token (from URL /wiki/XXX)" }),
}),
Type.Object({
action: Type.Literal("search"),
query: Type.String({ description: "Search query" }),
space_id: Type.Optional(Type.String({ description: "Limit search to this space (optional)" })),
}),
Type.Object({
action: Type.Literal("create"),
space_id: Type.String({ description: "Knowledge space ID" }),
title: Type.String({ description: "Node title" }),
obj_type: Type.Optional(
Type.Union([Type.Literal("docx"), Type.Literal("sheet"), Type.Literal("bitable")], {
description: "Object type (default: docx)",
}),
),
parent_node_token: Type.Optional(
Type.String({ description: "Parent node token (optional, omit for root)" }),
),
}),
Type.Object({
action: Type.Literal("move"),
space_id: Type.String({ description: "Source knowledge space ID" }),
node_token: Type.String({ description: "Node token to move" }),
target_space_id: Type.Optional(
Type.String({ description: "Target space ID (optional, same space if omitted)" }),
),
target_parent_token: Type.Optional(
Type.String({ description: "Target parent node token (optional, root if omitted)" }),
),
}),
Type.Object({
action: Type.Literal("rename"),
space_id: Type.String({ description: "Knowledge space ID" }),
node_token: Type.String({ description: "Node token to rename" }),
title: Type.String({ description: "New title" }),
}),
]);
export type FeishuWikiParams = Static<typeof FeishuWikiSchema>;

View File

@@ -0,0 +1,213 @@
import type * as Lark from "@larksuiteoapi/node-sdk";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import type { FeishuConfig } from "./types.js";
import { createFeishuClient } from "./client.js";
import { resolveToolsConfig } from "./tools-config.js";
import { FeishuWikiSchema, type FeishuWikiParams } from "./wiki-schema.js";
// ============ Helpers ============
function json(data: unknown) {
return {
content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }],
details: data,
};
}
type ObjType = "doc" | "sheet" | "mindnote" | "bitable" | "file" | "docx" | "slides";
// ============ Actions ============
const WIKI_ACCESS_HINT =
"To grant wiki access: Open wiki space → Settings → Members → Add the bot. " +
"See: https://open.feishu.cn/document/server-docs/docs/wiki-v2/wiki-qa#a40ad4ca";
async function listSpaces(client: Lark.Client) {
const res = await client.wiki.space.list({});
if (res.code !== 0) throw new Error(res.msg);
const spaces =
res.data?.items?.map((s) => ({
space_id: s.space_id,
name: s.name,
description: s.description,
visibility: s.visibility,
})) ?? [];
return {
spaces,
...(spaces.length === 0 && { hint: WIKI_ACCESS_HINT }),
};
}
async function listNodes(client: Lark.Client, spaceId: string, parentNodeToken?: string) {
const res = await client.wiki.spaceNode.list({
path: { space_id: spaceId },
params: { parent_node_token: parentNodeToken },
});
if (res.code !== 0) throw new Error(res.msg);
return {
nodes:
res.data?.items?.map((n) => ({
node_token: n.node_token,
obj_token: n.obj_token,
obj_type: n.obj_type,
title: n.title,
has_child: n.has_child,
})) ?? [],
};
}
async function getNode(client: Lark.Client, token: string) {
const res = await client.wiki.space.getNode({
params: { token },
});
if (res.code !== 0) throw new Error(res.msg);
const node = res.data?.node;
return {
node_token: node?.node_token,
space_id: node?.space_id,
obj_token: node?.obj_token,
obj_type: node?.obj_type,
title: node?.title,
parent_node_token: node?.parent_node_token,
has_child: node?.has_child,
creator: node?.creator,
create_time: node?.node_create_time,
};
}
async function createNode(
client: Lark.Client,
spaceId: string,
title: string,
objType?: string,
parentNodeToken?: string,
) {
const res = await client.wiki.spaceNode.create({
path: { space_id: spaceId },
data: {
obj_type: (objType as ObjType) || "docx",
node_type: "origin" as const,
title,
parent_node_token: parentNodeToken,
},
});
if (res.code !== 0) throw new Error(res.msg);
const node = res.data?.node;
return {
node_token: node?.node_token,
obj_token: node?.obj_token,
obj_type: node?.obj_type,
title: node?.title,
};
}
async function moveNode(
client: Lark.Client,
spaceId: string,
nodeToken: string,
targetSpaceId?: string,
targetParentToken?: string,
) {
const res = await client.wiki.spaceNode.move({
path: { space_id: spaceId, node_token: nodeToken },
data: {
target_space_id: targetSpaceId || spaceId,
target_parent_token: targetParentToken,
},
});
if (res.code !== 0) throw new Error(res.msg);
return {
success: true,
node_token: res.data?.node?.node_token,
};
}
async function renameNode(client: Lark.Client, spaceId: string, nodeToken: string, title: string) {
const res = await client.wiki.spaceNode.updateTitle({
path: { space_id: spaceId, node_token: nodeToken },
data: { title },
});
if (res.code !== 0) throw new Error(res.msg);
return {
success: true,
node_token: nodeToken,
title,
};
}
// ============ Tool Registration ============
export function registerFeishuWikiTools(api: OpenClawPluginApi) {
const feishuCfg = api.config?.channels?.feishu as FeishuConfig | undefined;
if (!feishuCfg?.appId || !feishuCfg?.appSecret) {
api.logger.debug?.("feishu_wiki: Feishu credentials not configured, skipping wiki tools");
return;
}
const toolsCfg = resolveToolsConfig(feishuCfg.tools);
if (!toolsCfg.wiki) {
api.logger.debug?.("feishu_wiki: wiki tool disabled in config");
return;
}
const getClient = () => createFeishuClient(feishuCfg);
api.registerTool(
{
name: "feishu_wiki",
label: "Feishu Wiki",
description:
"Feishu knowledge base operations. Actions: spaces, nodes, get, create, move, rename",
parameters: FeishuWikiSchema,
async execute(_toolCallId, params) {
const p = params as FeishuWikiParams;
try {
const client = getClient();
switch (p.action) {
case "spaces":
return json(await listSpaces(client));
case "nodes":
return json(await listNodes(client, p.space_id, p.parent_node_token));
case "get":
return json(await getNode(client, p.token));
case "search":
return json({
error:
"Search is not available. Use feishu_wiki with action: 'nodes' to browse or action: 'get' to lookup by token.",
});
case "create":
return json(
await createNode(client, p.space_id, p.title, p.obj_type, p.parent_node_token),
);
case "move":
return json(
await moveNode(
client,
p.space_id,
p.node_token,
p.target_space_id,
p.target_parent_token,
),
);
case "rename":
return json(await renameNode(client, p.space_id, p.node_token, p.title));
default:
return json({ error: `Unknown action: ${(p as any).action}` });
}
} catch (err) {
return json({ error: err instanceof Error ? err.message : String(err) });
}
},
},
{ name: "feishu_wiki" },
);
api.logger.info?.(`feishu_wiki: Registered feishu_wiki tool`);
}