Include full contents of all nested repositories

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-27 16:25:02 +01:00
parent 14ff8fd54c
commit 2401ed446f
7271 changed files with 1310112 additions and 6 deletions

View File

@@ -0,0 +1,144 @@
import type { ClawdbotConfig } from "openclaw/plugin-sdk";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import type {
FeishuConfig,
FeishuAccountConfig,
FeishuDomain,
ResolvedFeishuAccount,
} from "./types.js";
/**
* List all configured account IDs from the accounts field.
*/
function listConfiguredAccountIds(cfg: ClawdbotConfig): string[] {
const accounts = (cfg.channels?.feishu as FeishuConfig)?.accounts;
if (!accounts || typeof accounts !== "object") {
return [];
}
return Object.keys(accounts).filter(Boolean);
}
/**
* List all Feishu account IDs.
* If no accounts are configured, returns [DEFAULT_ACCOUNT_ID] for backward compatibility.
*/
export function listFeishuAccountIds(cfg: ClawdbotConfig): string[] {
const ids = listConfiguredAccountIds(cfg);
if (ids.length === 0) {
// Backward compatibility: no accounts configured, use default
return [DEFAULT_ACCOUNT_ID];
}
return [...ids].toSorted((a, b) => a.localeCompare(b));
}
/**
* Resolve the default account ID.
*/
export function resolveDefaultFeishuAccountId(cfg: ClawdbotConfig): string {
const ids = listFeishuAccountIds(cfg);
if (ids.includes(DEFAULT_ACCOUNT_ID)) {
return DEFAULT_ACCOUNT_ID;
}
return ids[0] ?? DEFAULT_ACCOUNT_ID;
}
/**
* Get the raw account-specific config.
*/
function resolveAccountConfig(
cfg: ClawdbotConfig,
accountId: string,
): FeishuAccountConfig | undefined {
const accounts = (cfg.channels?.feishu as FeishuConfig)?.accounts;
if (!accounts || typeof accounts !== "object") {
return undefined;
}
return accounts[accountId];
}
/**
* Merge top-level config with account-specific config.
* Account-specific fields override top-level fields.
*/
function mergeFeishuAccountConfig(cfg: ClawdbotConfig, accountId: string): FeishuConfig {
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
// Extract base config (exclude accounts field to avoid recursion)
const { accounts: _ignored, ...base } = feishuCfg ?? {};
// Get account-specific overrides
const account = resolveAccountConfig(cfg, accountId) ?? {};
// Merge: account config overrides base config
return { ...base, ...account } as FeishuConfig;
}
/**
* Resolve Feishu credentials from a config.
*/
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",
};
}
/**
* Resolve a complete Feishu account with merged config.
*/
export function resolveFeishuAccount(params: {
cfg: ClawdbotConfig;
accountId?: string | null;
}): ResolvedFeishuAccount {
const accountId = normalizeAccountId(params.accountId);
const feishuCfg = params.cfg.channels?.feishu as FeishuConfig | undefined;
// Base enabled state (top-level)
const baseEnabled = feishuCfg?.enabled !== false;
// Merge configs
const merged = mergeFeishuAccountConfig(params.cfg, accountId);
// Account-level enabled state
const accountEnabled = merged.enabled !== false;
const enabled = baseEnabled && accountEnabled;
// Resolve credentials from merged config
const creds = resolveFeishuCredentials(merged);
return {
accountId,
enabled,
configured: Boolean(creds),
name: (merged as FeishuAccountConfig).name?.trim() || undefined,
appId: creds?.appId,
appSecret: creds?.appSecret,
encryptKey: creds?.encryptKey,
verificationToken: creds?.verificationToken,
domain: creds?.domain ?? "feishu",
config: merged,
};
}
/**
* List all enabled and configured accounts.
*/
export function listEnabledFeishuAccounts(cfg: ClawdbotConfig): ResolvedFeishuAccount[] {
return listFeishuAccountIds(cfg)
.map((accountId) => resolveFeishuAccount({ cfg, accountId }))
.filter((account) => account.enabled && account.configured);
}

View File

@@ -0,0 +1,713 @@
import type * as Lark from "@larksuiteoapi/node-sdk";
import { Type } from "@sinclair/typebox";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { listEnabledFeishuAccounts } from "./accounts.js";
import { createFeishuToolClient } from "./tool-account.js";
// ============ Helpers ============
function json(data: unknown) {
return {
content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }],
details: data,
};
}
/** Field type ID to human-readable name */
const FIELD_TYPE_NAMES: Record<number, string> = {
1: "Text",
2: "Number",
3: "SingleSelect",
4: "MultiSelect",
5: "DateTime",
7: "Checkbox",
11: "User",
13: "Phone",
15: "URL",
17: "Attachment",
18: "SingleLink",
19: "Lookup",
20: "Formula",
21: "DuplexLink",
22: "Location",
23: "GroupChat",
1001: "CreatedTime",
1002: "ModifiedTime",
1003: "CreatedUser",
1004: "ModifiedUser",
1005: "AutoNumber",
};
// ============ Core Functions ============
/** Parse bitable URL and extract tokens */
function parseBitableUrl(url: string): { token: string; tableId?: string; isWiki: boolean } | null {
try {
const u = new URL(url);
const tableId = u.searchParams.get("table") ?? undefined;
// Wiki format: /wiki/XXXXX?table=YYY
const wikiMatch = u.pathname.match(/\/wiki\/([A-Za-z0-9]+)/);
if (wikiMatch) {
return { token: wikiMatch[1], tableId, isWiki: true };
}
// Base format: /base/XXXXX?table=YYY
const baseMatch = u.pathname.match(/\/base\/([A-Za-z0-9]+)/);
if (baseMatch) {
return { token: baseMatch[1], tableId, isWiki: false };
}
return null;
} catch {
return null;
}
}
/** Get app_token from wiki node_token */
async function getAppTokenFromWiki(client: Lark.Client, nodeToken: string): Promise<string> {
const res = await client.wiki.space.getNode({
params: { token: nodeToken },
});
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: Lark.Client, url: string) {
const parsed = parseBitableUrl(url);
if (!parsed) {
throw new Error("Invalid URL format. Expected /base/XXX or /wiki/XXX URL");
}
let appToken: string;
if (parsed.isWiki) {
appToken = await getAppTokenFromWiki(client, parsed.token);
} else {
appToken = parsed.token;
}
// Get bitable app info
const res = await client.bitable.app.get({
path: { app_token: appToken },
});
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: Lark.Client, 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: Lark.Client,
appToken: string,
tableId: string,
pageSize?: number,
pageToken?: string,
) {
const res = await client.bitable.appTableRecord.list({
path: { app_token: appToken, table_id: tableId },
params: {
page_size: pageSize ?? 100,
...(pageToken && { page_token: pageToken }),
},
});
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: Lark.Client, appToken: string, tableId: string, recordId: string) {
const res = await client.bitable.appTableRecord.get({
path: { app_token: appToken, table_id: tableId, record_id: recordId },
});
if (res.code !== 0) {
throw new Error(res.msg);
}
return {
record: res.data?.record,
};
}
async function createRecord(
client: Lark.Client,
appToken: string,
tableId: string,
fields: Record<string, unknown>,
) {
const res = await client.bitable.appTableRecord.create({
path: { app_token: appToken, table_id: tableId },
// oxlint-disable-next-line typescript/no-explicit-any
data: { fields: fields as any },
});
if (res.code !== 0) {
throw new Error(res.msg);
}
return {
record: res.data?.record,
};
}
/** Logger interface for cleanup operations */
type CleanupLogger = {
debug: (msg: string) => void;
warn: (msg: string) => void;
};
/** Default field types created for new Bitable tables (to be cleaned up) */
const DEFAULT_CLEANUP_FIELD_TYPES = new Set([3, 5, 17]); // SingleSelect, DateTime, Attachment
/** Clean up default placeholder rows and fields in a newly created Bitable table */
async function cleanupNewBitable(
client: Lark.Client,
appToken: string,
tableId: string,
tableName: string,
logger: CleanupLogger,
): Promise<{ cleanedRows: number; cleanedFields: number }> {
let cleanedRows = 0;
let cleanedFields = 0;
// Step 1: Clean up default fields
const fieldsRes = await client.bitable.appTableField.list({
path: { app_token: appToken, table_id: tableId },
});
if (fieldsRes.code === 0 && fieldsRes.data?.items) {
// Step 1a: Rename primary field to the table name (works for both Feishu and Lark)
const primaryField = fieldsRes.data.items.find((f) => f.is_primary);
if (primaryField?.field_id) {
try {
const newFieldName = tableName.length <= 20 ? tableName : "Name";
await client.bitable.appTableField.update({
path: {
app_token: appToken,
table_id: tableId,
field_id: primaryField.field_id,
},
data: {
field_name: newFieldName,
type: 1,
},
});
cleanedFields++;
} catch (err) {
logger.debug(`Failed to rename primary field: ${err}`);
}
}
// Step 1b: Delete default placeholder fields by type (works for both Feishu and Lark)
const defaultFieldsToDelete = fieldsRes.data.items.filter(
(f) => !f.is_primary && DEFAULT_CLEANUP_FIELD_TYPES.has(f.type ?? 0),
);
for (const field of defaultFieldsToDelete) {
if (field.field_id) {
try {
await client.bitable.appTableField.delete({
path: {
app_token: appToken,
table_id: tableId,
field_id: field.field_id,
},
});
cleanedFields++;
} catch (err) {
logger.debug(`Failed to delete default field ${field.field_name}: ${err}`);
}
}
}
}
// Step 2: Delete empty placeholder rows (batch when possible)
const recordsRes = await client.bitable.appTableRecord.list({
path: { app_token: appToken, table_id: tableId },
params: { page_size: 100 },
});
if (recordsRes.code === 0 && recordsRes.data?.items) {
const emptyRecordIds = recordsRes.data.items
.filter((r) => !r.fields || Object.keys(r.fields).length === 0)
.map((r) => r.record_id)
.filter((id): id is string => Boolean(id));
if (emptyRecordIds.length > 0) {
try {
await client.bitable.appTableRecord.batchDelete({
path: { app_token: appToken, table_id: tableId },
data: { records: emptyRecordIds },
});
cleanedRows = emptyRecordIds.length;
} catch {
// Fallback: delete one by one if batch API is unavailable
for (const recordId of emptyRecordIds) {
try {
await client.bitable.appTableRecord.delete({
path: { app_token: appToken, table_id: tableId, record_id: recordId },
});
cleanedRows++;
} catch (err) {
logger.debug(`Failed to delete empty row ${recordId}: ${err}`);
}
}
}
}
}
return { cleanedRows, cleanedFields };
}
async function createApp(
client: Lark.Client,
name: string,
folderToken?: string,
logger?: CleanupLogger,
) {
const res = await client.bitable.app.create({
data: {
name,
...(folderToken && { folder_token: folderToken }),
},
});
if (res.code !== 0) {
throw new Error(res.msg);
}
const appToken = res.data?.app?.app_token;
if (!appToken) {
throw new Error("Failed to create Bitable: no app_token returned");
}
const log: CleanupLogger = logger ?? { debug: () => {}, warn: () => {} };
let tableId: string | undefined;
let cleanedRows = 0;
let cleanedFields = 0;
try {
const tablesRes = await client.bitable.appTable.list({
path: { app_token: appToken },
});
if (tablesRes.code === 0 && tablesRes.data?.items && tablesRes.data.items.length > 0) {
tableId = tablesRes.data.items[0].table_id ?? undefined;
if (tableId) {
const cleanup = await cleanupNewBitable(client, appToken, tableId, name, log);
cleanedRows = cleanup.cleanedRows;
cleanedFields = cleanup.cleanedFields;
}
}
} catch (err) {
log.debug(`Cleanup failed (non-critical): ${err}`);
}
return {
app_token: appToken,
table_id: tableId,
name: res.data?.app?.name,
url: res.data?.app?.url,
cleaned_placeholder_rows: cleanedRows,
cleaned_default_fields: cleanedFields,
hint: tableId
? `Table created. Use app_token="${appToken}" and table_id="${tableId}" for other bitable tools.`
: "Table created. Use feishu_bitable_get_meta to get table_id and field details.",
};
}
async function createField(
client: Lark.Client,
appToken: string,
tableId: string,
fieldName: string,
fieldType: number,
property?: Record<string, unknown>,
) {
const res = await client.bitable.appTableField.create({
path: { app_token: appToken, table_id: tableId },
data: {
field_name: fieldName,
type: fieldType,
...(property && { property }),
},
});
if (res.code !== 0) {
throw new Error(res.msg);
}
return {
field_id: res.data?.field?.field_id,
field_name: res.data?.field?.field_name,
type: res.data?.field?.type,
type_name: FIELD_TYPE_NAMES[res.data?.field?.type ?? 0] || `type_${res.data?.field?.type}`,
};
}
async function updateRecord(
client: Lark.Client,
appToken: string,
tableId: string,
recordId: string,
fields: Record<string, unknown>,
) {
const res = await client.bitable.appTableRecord.update({
path: { app_token: appToken, table_id: tableId, record_id: recordId },
// oxlint-disable-next-line typescript/no-explicit-any
data: { fields: fields as any },
});
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 CreateAppSchema = Type.Object({
name: Type.String({
description: "Name for the new Bitable application",
}),
folder_token: Type.Optional(
Type.String({
description: "Optional folder token to place the Bitable in a specific folder",
}),
),
});
const CreateFieldSchema = Type.Object({
app_token: Type.String({
description:
"Bitable app token (use feishu_bitable_get_meta to get from URL, or feishu_bitable_create_app to create new)",
}),
table_id: Type.String({ description: "Table ID (from URL: ?table=YYY)" }),
field_name: Type.String({ description: "Name for the new field" }),
field_type: Type.Number({
description:
"Field type ID: 1=Text, 2=Number, 3=SingleSelect, 4=MultiSelect, 5=DateTime, 7=Checkbox, 11=User, 13=Phone, 15=URL, 17=Attachment, 18=SingleLink, 19=Lookup, 20=Formula, 21=DuplexLink, 22=Location, 23=GroupChat, 1001=CreatedTime, 1002=ModifiedTime, 1003=CreatedUser, 1004=ModifiedUser, 1005=AutoNumber",
minimum: 1,
}),
property: Type.Optional(
Type.Record(Type.String(), Type.Any(), {
description: "Field-specific properties (e.g., options for SingleSelect, format for Number)",
}),
),
});
const UpdateRecordSchema = Type.Object({
app_token: Type.String({
description: "Bitable app token (use feishu_bitable_get_meta to get from URL)",
}),
table_id: Type.String({ description: "Table ID (from URL: ?table=YYY)" }),
record_id: Type.String({ description: "Record ID to update" }),
fields: Type.Record(Type.String(), Type.Any(), {
description: "Field values to update (same format as create_record)",
}),
});
// ============ Tool Registration ============
export function registerFeishuBitableTools(api: OpenClawPluginApi) {
if (!api.config) {
api.logger.debug?.("feishu_bitable: No config available, skipping bitable tools");
return;
}
const accounts = listEnabledFeishuAccounts(api.config);
if (accounts.length === 0) {
api.logger.debug?.("feishu_bitable: No Feishu accounts configured, skipping bitable tools");
return;
}
type AccountAwareParams = { accountId?: string };
const getClient = (params: AccountAwareParams | undefined, defaultAccountId?: string) =>
createFeishuToolClient({ api, executeParams: params, defaultAccountId });
const registerBitableTool = <TParams extends AccountAwareParams>(params: {
name: string;
label: string;
description: string;
parameters: unknown;
execute: (args: { params: TParams; defaultAccountId?: string }) => Promise<unknown>;
}) => {
api.registerTool(
(ctx) => ({
name: params.name,
label: params.label,
description: params.description,
parameters: params.parameters,
async execute(_toolCallId, rawParams) {
try {
return json(
await params.execute({
params: rawParams as TParams,
defaultAccountId: ctx.agentAccountId,
}),
);
} catch (err) {
return json({ error: err instanceof Error ? err.message : String(err) });
}
},
}),
{ name: params.name },
);
};
registerBitableTool<{ url: string; accountId?: string }>({
name: "feishu_bitable_get_meta",
label: "Feishu Bitable Get Meta",
description:
"Parse a Bitable URL and get app_token, table_id, and table list. Use this first when given a /wiki/ or /base/ URL.",
parameters: GetMetaSchema,
async execute({ params, defaultAccountId }) {
return getBitableMeta(getClient(params, defaultAccountId), params.url);
},
});
registerBitableTool<{ app_token: string; table_id: string; accountId?: string }>({
name: "feishu_bitable_list_fields",
label: "Feishu Bitable List Fields",
description: "List all fields (columns) in a Bitable table with their types and properties",
parameters: ListFieldsSchema,
async execute({ params, defaultAccountId }) {
return listFields(getClient(params, defaultAccountId), params.app_token, params.table_id);
},
});
registerBitableTool<{
app_token: string;
table_id: string;
page_size?: number;
page_token?: string;
accountId?: string;
}>({
name: "feishu_bitable_list_records",
label: "Feishu Bitable List Records",
description: "List records (rows) from a Bitable table with pagination support",
parameters: ListRecordsSchema,
async execute({ params, defaultAccountId }) {
return listRecords(
getClient(params, defaultAccountId),
params.app_token,
params.table_id,
params.page_size,
params.page_token,
);
},
});
registerBitableTool<{
app_token: string;
table_id: string;
record_id: string;
accountId?: string;
}>({
name: "feishu_bitable_get_record",
label: "Feishu Bitable Get Record",
description: "Get a single record by ID from a Bitable table",
parameters: GetRecordSchema,
async execute({ params, defaultAccountId }) {
return getRecord(
getClient(params, defaultAccountId),
params.app_token,
params.table_id,
params.record_id,
);
},
});
registerBitableTool<{
app_token: string;
table_id: string;
fields: Record<string, unknown>;
accountId?: string;
}>({
name: "feishu_bitable_create_record",
label: "Feishu Bitable Create Record",
description: "Create a new record (row) in a Bitable table",
parameters: CreateRecordSchema,
async execute({ params, defaultAccountId }) {
return createRecord(
getClient(params, defaultAccountId),
params.app_token,
params.table_id,
params.fields,
);
},
});
registerBitableTool<{
app_token: string;
table_id: string;
record_id: string;
fields: Record<string, unknown>;
accountId?: string;
}>({
name: "feishu_bitable_update_record",
label: "Feishu Bitable Update Record",
description: "Update an existing record (row) in a Bitable table",
parameters: UpdateRecordSchema,
async execute({ params, defaultAccountId }) {
return updateRecord(
getClient(params, defaultAccountId),
params.app_token,
params.table_id,
params.record_id,
params.fields,
);
},
});
registerBitableTool<{ name: string; folder_token?: string; accountId?: string }>({
name: "feishu_bitable_create_app",
label: "Feishu Bitable Create App",
description: "Create a new Bitable (multidimensional table) application",
parameters: CreateAppSchema,
async execute({ params, defaultAccountId }) {
return createApp(getClient(params, defaultAccountId), params.name, params.folder_token, {
debug: (msg) => api.logger.debug?.(msg),
warn: (msg) => api.logger.warn?.(msg),
});
},
});
registerBitableTool<{
app_token: string;
table_id: string;
field_name: string;
field_type: number;
property?: Record<string, unknown>;
accountId?: string;
}>({
name: "feishu_bitable_create_field",
label: "Feishu Bitable Create Field",
description: "Create a new field (column) in a Bitable table",
parameters: CreateFieldSchema,
async execute({ params, defaultAccountId }) {
return createField(
getClient(params, defaultAccountId),
params.app_token,
params.table_id,
params.field_name,
params.field_type,
params.property,
);
},
});
api.logger.info?.("feishu_bitable: Registered bitable tools");
}

View File

@@ -0,0 +1,130 @@
import { describe, it, expect } from "vitest";
import { parseFeishuMessageEvent } from "./bot.js";
// Helper to build a minimal FeishuMessageEvent for testing
function makeEvent(
chatType: "p2p" | "group",
mentions?: Array<{ key: string; name: string; id: { open_id?: string } }>,
text = "hello",
) {
return {
sender: {
sender_id: { user_id: "u1", open_id: "ou_sender" },
},
message: {
message_id: "msg_1",
chat_id: "oc_chat1",
chat_type: chatType,
message_type: "text",
content: JSON.stringify({ text }),
mentions,
},
};
}
function makePostEvent(content: unknown) {
return {
sender: { sender_id: { user_id: "u1", open_id: "ou_sender" } },
message: {
message_id: "msg_1",
chat_id: "oc_chat1",
chat_type: "group",
message_type: "post",
content: JSON.stringify(content),
mentions: [],
},
};
}
describe("parseFeishuMessageEvent mentionedBot", () => {
const BOT_OPEN_ID = "ou_bot_123";
it("returns mentionedBot=false when there are no mentions", () => {
const event = makeEvent("group", []);
const ctx = parseFeishuMessageEvent(event as any, BOT_OPEN_ID);
expect(ctx.mentionedBot).toBe(false);
});
it("returns mentionedBot=true when bot is mentioned", () => {
const event = makeEvent("group", [
{ key: "@_user_1", name: "Bot", id: { open_id: BOT_OPEN_ID } },
]);
const ctx = parseFeishuMessageEvent(event as any, BOT_OPEN_ID);
expect(ctx.mentionedBot).toBe(true);
});
it("returns mentionedBot=false when only other users are mentioned", () => {
const event = makeEvent("group", [
{ key: "@_user_1", name: "Alice", id: { open_id: "ou_alice" } },
]);
const ctx = parseFeishuMessageEvent(event as any, BOT_OPEN_ID);
expect(ctx.mentionedBot).toBe(false);
});
it("returns mentionedBot=false when botOpenId is undefined (unknown bot)", () => {
const event = makeEvent("group", [
{ key: "@_user_1", name: "Alice", id: { open_id: "ou_alice" } },
]);
const ctx = parseFeishuMessageEvent(event as any, undefined);
expect(ctx.mentionedBot).toBe(false);
});
it("returns mentionedBot=false when botOpenId is empty string (probe failed)", () => {
const event = makeEvent("group", [
{ key: "@_user_1", name: "Alice", id: { open_id: "ou_alice" } },
]);
const ctx = parseFeishuMessageEvent(event as any, "");
expect(ctx.mentionedBot).toBe(false);
});
it("treats mention.name regex metacharacters as literals when stripping", () => {
const event = makeEvent(
"group",
[{ key: "@_bot_1", name: ".*", id: { open_id: BOT_OPEN_ID } }],
"@NotBot hello",
);
const ctx = parseFeishuMessageEvent(event as any, BOT_OPEN_ID);
expect(ctx.content).toBe("@NotBot hello");
});
it("treats mention.key regex metacharacters as literals when stripping", () => {
const event = makeEvent(
"group",
[{ key: ".*", name: "Bot", id: { open_id: BOT_OPEN_ID } }],
"hello world",
);
const ctx = parseFeishuMessageEvent(event as any, BOT_OPEN_ID);
expect(ctx.content).toBe("hello world");
});
it("returns mentionedBot=true for post message with at (no top-level mentions)", () => {
const BOT_OPEN_ID = "ou_bot_123";
const event = makePostEvent({
content: [
[{ tag: "at", user_id: BOT_OPEN_ID, user_name: "claw" }],
[{ tag: "text", text: "What does this document say" }],
],
});
const ctx = parseFeishuMessageEvent(event as any, BOT_OPEN_ID);
expect(ctx.mentionedBot).toBe(true);
});
it("returns mentionedBot=false for post message with no at", () => {
const event = makePostEvent({
content: [[{ tag: "text", text: "hello" }]],
});
const ctx = parseFeishuMessageEvent(event as any, "ou_bot_123");
expect(ctx.mentionedBot).toBe(false);
});
it("returns mentionedBot=false for post message with at for another user", () => {
const event = makePostEvent({
content: [
[{ tag: "at", user_id: "ou_other", user_name: "other" }],
[{ tag: "text", text: "hello" }],
],
});
const ctx = parseFeishuMessageEvent(event as any, "ou_bot_123");
expect(ctx.mentionedBot).toBe(false);
});
});

View File

@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import { stripBotMention, type FeishuMessageEvent } from "./bot.js";
type Mentions = FeishuMessageEvent["message"]["mentions"];
describe("stripBotMention", () => {
it("returns original text when mentions are missing", () => {
expect(stripBotMention("hello world", undefined)).toBe("hello world");
});
it("strips mention name and key for normal mentions", () => {
const mentions: Mentions = [{ key: "@_bot_1", name: "Bot", id: { open_id: "ou_bot" } }];
expect(stripBotMention("@Bot hello @_bot_1", mentions)).toBe("hello");
});
it("treats mention.name regex metacharacters as literal text", () => {
const mentions: Mentions = [{ key: "@_bot_1", name: ".*", id: { open_id: "ou_bot" } }];
expect(stripBotMention("@NotBot hello", mentions)).toBe("@NotBot hello");
});
it("treats mention.key regex metacharacters as literal text", () => {
const mentions: Mentions = [{ key: ".*", name: "Bot", id: { open_id: "ou_bot" } }];
expect(stripBotMention("hello world", mentions)).toBe("hello world");
});
it("trims once after all mention replacements", () => {
const mentions: Mentions = [{ key: "@_bot_1", name: "Bot", id: { open_id: "ou_bot" } }];
expect(stripBotMention(" @_bot_1 hello ", mentions)).toBe("hello");
});
it("strips multiple mentions in one pass", () => {
const mentions: Mentions = [
{ key: "@_bot_1", name: "Bot One", id: { open_id: "ou_bot_1" } },
{ key: "@_bot_2", name: "Bot Two", id: { open_id: "ou_bot_2" } },
];
expect(stripBotMention("@Bot One @_bot_1 hi @Bot Two @_bot_2", mentions)).toBe("hi");
});
});

View File

@@ -0,0 +1,543 @@
import type { ClawdbotConfig, PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { FeishuMessageEvent } from "./bot.js";
import { buildFeishuAgentBody, handleFeishuMessage } from "./bot.js";
import { setFeishuRuntime } from "./runtime.js";
const {
mockCreateFeishuReplyDispatcher,
mockSendMessageFeishu,
mockGetMessageFeishu,
mockDownloadMessageResourceFeishu,
mockCreateFeishuClient,
} = vi.hoisted(() => ({
mockCreateFeishuReplyDispatcher: vi.fn(() => ({
dispatcher: vi.fn(),
replyOptions: {},
markDispatchIdle: vi.fn(),
})),
mockSendMessageFeishu: vi.fn().mockResolvedValue({ messageId: "pairing-msg", chatId: "oc-dm" }),
mockGetMessageFeishu: vi.fn().mockResolvedValue(null),
mockDownloadMessageResourceFeishu: vi.fn().mockResolvedValue({
buffer: Buffer.from("video"),
contentType: "video/mp4",
fileName: "clip.mp4",
}),
mockCreateFeishuClient: vi.fn(),
}));
vi.mock("./reply-dispatcher.js", () => ({
createFeishuReplyDispatcher: mockCreateFeishuReplyDispatcher,
}));
vi.mock("./send.js", () => ({
sendMessageFeishu: mockSendMessageFeishu,
getMessageFeishu: mockGetMessageFeishu,
}));
vi.mock("./media.js", () => ({
downloadMessageResourceFeishu: mockDownloadMessageResourceFeishu,
}));
vi.mock("./client.js", () => ({
createFeishuClient: mockCreateFeishuClient,
}));
function createRuntimeEnv(): RuntimeEnv {
return {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn((code: number): never => {
throw new Error(`exit ${code}`);
}),
} as RuntimeEnv;
}
async function dispatchMessage(params: { cfg: ClawdbotConfig; event: FeishuMessageEvent }) {
await handleFeishuMessage({
cfg: params.cfg,
event: params.event,
runtime: createRuntimeEnv(),
});
}
describe("buildFeishuAgentBody", () => {
it("builds message id, speaker, quoted content, mentions, and permission notice in order", () => {
const body = buildFeishuAgentBody({
ctx: {
content: "hello world",
senderName: "Sender Name",
senderOpenId: "ou-sender",
messageId: "msg-42",
mentionTargets: [{ openId: "ou-target", name: "Target User", key: "@_user_1" }],
},
quotedContent: "previous message",
permissionErrorForAgent: {
code: 99991672,
message: "permission denied",
grantUrl: "https://open.feishu.cn/app/cli_test",
},
});
expect(body).toBe(
'[message_id: msg-42]\nSender Name: [Replying to: "previous message"]\n\nhello world\n\n[System: Your reply will automatically @mention: Target User. Do not write @xxx yourself.]\n\n[System: The bot encountered a Feishu API permission error. Please inform the user about this issue and provide the permission grant URL for the admin to authorize. Permission grant URL: https://open.feishu.cn/app/cli_test]',
);
});
});
describe("handleFeishuMessage command authorization", () => {
const mockFinalizeInboundContext = vi.fn((ctx: unknown) => ctx);
const mockDispatchReplyFromConfig = vi
.fn()
.mockResolvedValue({ queuedFinal: false, counts: { final: 1 } });
const mockWithReplyDispatcher = vi.fn(
async ({
dispatcher,
run,
onSettled,
}: Parameters<PluginRuntime["channel"]["reply"]["withReplyDispatcher"]>[0]) => {
try {
return await run();
} finally {
dispatcher.markComplete();
try {
await dispatcher.waitForIdle();
} finally {
await onSettled?.();
}
}
},
);
const mockResolveCommandAuthorizedFromAuthorizers = vi.fn(() => false);
const mockShouldComputeCommandAuthorized = vi.fn(() => true);
const mockReadAllowFromStore = vi.fn().mockResolvedValue([]);
const mockUpsertPairingRequest = vi.fn().mockResolvedValue({ code: "ABCDEFGH", created: false });
const mockBuildPairingReply = vi.fn(() => "Pairing response");
const mockSaveMediaBuffer = vi.fn().mockResolvedValue({
path: "/tmp/inbound-clip.mp4",
contentType: "video/mp4",
});
beforeEach(() => {
vi.clearAllMocks();
mockCreateFeishuClient.mockReturnValue({
contact: {
user: {
get: vi.fn().mockResolvedValue({ data: { user: { name: "Sender" } } }),
},
},
});
setFeishuRuntime({
system: {
enqueueSystemEvent: vi.fn(),
},
channel: {
routing: {
resolveAgentRoute: vi.fn(() => ({
agentId: "main",
accountId: "default",
sessionKey: "agent:main:feishu:dm:ou-attacker",
matchedBy: "default",
})),
},
reply: {
resolveEnvelopeFormatOptions: vi.fn(() => ({ template: "channel+name+time" })),
formatAgentEnvelope: vi.fn((params: { body: string }) => params.body),
finalizeInboundContext: mockFinalizeInboundContext,
dispatchReplyFromConfig: mockDispatchReplyFromConfig,
withReplyDispatcher: mockWithReplyDispatcher,
},
commands: {
shouldComputeCommandAuthorized: mockShouldComputeCommandAuthorized,
resolveCommandAuthorizedFromAuthorizers: mockResolveCommandAuthorizedFromAuthorizers,
},
media: {
saveMediaBuffer: mockSaveMediaBuffer,
},
pairing: {
readAllowFromStore: mockReadAllowFromStore,
upsertPairingRequest: mockUpsertPairingRequest,
buildPairingReply: mockBuildPairingReply,
},
},
media: {
detectMime: vi.fn(async () => "application/octet-stream"),
},
} as unknown as PluginRuntime);
});
it("uses authorizer resolution instead of hardcoded CommandAuthorized=true", async () => {
const cfg: ClawdbotConfig = {
commands: { useAccessGroups: true },
channels: {
feishu: {
dmPolicy: "open",
allowFrom: ["ou-admin"],
},
},
} as ClawdbotConfig;
const event: FeishuMessageEvent = {
sender: {
sender_id: {
open_id: "ou-attacker",
},
},
message: {
message_id: "msg-auth-bypass-regression",
chat_id: "oc-dm",
chat_type: "p2p",
message_type: "text",
content: JSON.stringify({ text: "/status" }),
},
};
await dispatchMessage({ cfg, event });
expect(mockResolveCommandAuthorizedFromAuthorizers).toHaveBeenCalledWith({
useAccessGroups: true,
authorizers: [{ configured: true, allowed: false }],
});
expect(mockFinalizeInboundContext).toHaveBeenCalledTimes(1);
expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
expect.objectContaining({
CommandAuthorized: false,
SenderId: "ou-attacker",
Surface: "feishu",
}),
);
});
it("reads pairing allow store for non-command DMs when dmPolicy is pairing", async () => {
mockShouldComputeCommandAuthorized.mockReturnValue(false);
mockReadAllowFromStore.mockResolvedValue(["ou-attacker"]);
const cfg: ClawdbotConfig = {
commands: { useAccessGroups: true },
channels: {
feishu: {
dmPolicy: "pairing",
allowFrom: [],
},
},
} as ClawdbotConfig;
const event: FeishuMessageEvent = {
sender: {
sender_id: {
open_id: "ou-attacker",
},
},
message: {
message_id: "msg-read-store-non-command",
chat_id: "oc-dm",
chat_type: "p2p",
message_type: "text",
content: JSON.stringify({ text: "hello there" }),
},
};
await dispatchMessage({ cfg, event });
expect(mockReadAllowFromStore).toHaveBeenCalledWith({
channel: "feishu",
accountId: "default",
});
expect(mockResolveCommandAuthorizedFromAuthorizers).not.toHaveBeenCalled();
expect(mockFinalizeInboundContext).toHaveBeenCalledTimes(1);
expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(1);
});
it("creates pairing request and drops unauthorized DMs in pairing mode", async () => {
mockShouldComputeCommandAuthorized.mockReturnValue(false);
mockReadAllowFromStore.mockResolvedValue([]);
mockUpsertPairingRequest.mockResolvedValue({ code: "ABCDEFGH", created: true });
const cfg: ClawdbotConfig = {
channels: {
feishu: {
dmPolicy: "pairing",
allowFrom: [],
},
},
} as ClawdbotConfig;
const event: FeishuMessageEvent = {
sender: {
sender_id: {
open_id: "ou-unapproved",
},
},
message: {
message_id: "msg-pairing-flow",
chat_id: "oc-dm",
chat_type: "p2p",
message_type: "text",
content: JSON.stringify({ text: "hello" }),
},
};
await dispatchMessage({ cfg, event });
expect(mockUpsertPairingRequest).toHaveBeenCalledWith({
channel: "feishu",
accountId: "default",
id: "ou-unapproved",
meta: { name: undefined },
});
expect(mockBuildPairingReply).toHaveBeenCalledWith({
channel: "feishu",
idLine: "Your Feishu user id: ou-unapproved",
code: "ABCDEFGH",
});
expect(mockSendMessageFeishu).toHaveBeenCalledWith(
expect.objectContaining({
to: "user:ou-unapproved",
accountId: "default",
}),
);
expect(mockFinalizeInboundContext).not.toHaveBeenCalled();
expect(mockDispatchReplyFromConfig).not.toHaveBeenCalled();
});
it("computes group command authorization from group allowFrom", async () => {
mockShouldComputeCommandAuthorized.mockReturnValue(true);
mockResolveCommandAuthorizedFromAuthorizers.mockReturnValue(false);
const cfg: ClawdbotConfig = {
commands: { useAccessGroups: true },
channels: {
feishu: {
groups: {
"oc-group": {
requireMention: false,
},
},
},
},
} as ClawdbotConfig;
const event: FeishuMessageEvent = {
sender: {
sender_id: {
open_id: "ou-attacker",
},
},
message: {
message_id: "msg-group-command-auth",
chat_id: "oc-group",
chat_type: "group",
message_type: "text",
content: JSON.stringify({ text: "/status" }),
},
};
await dispatchMessage({ cfg, event });
expect(mockResolveCommandAuthorizedFromAuthorizers).toHaveBeenCalledWith({
useAccessGroups: true,
authorizers: [{ configured: false, allowed: false }],
});
expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
expect.objectContaining({
ChatType: "group",
CommandAuthorized: false,
SenderId: "ou-attacker",
}),
);
});
it("falls back to top-level allowFrom for group command authorization", async () => {
mockShouldComputeCommandAuthorized.mockReturnValue(true);
mockResolveCommandAuthorizedFromAuthorizers.mockReturnValue(true);
const cfg: ClawdbotConfig = {
commands: { useAccessGroups: true },
channels: {
feishu: {
allowFrom: ["ou-admin"],
groups: {
"oc-group": {
requireMention: false,
},
},
},
},
} as ClawdbotConfig;
const event: FeishuMessageEvent = {
sender: {
sender_id: {
open_id: "ou-admin",
},
},
message: {
message_id: "msg-group-command-fallback",
chat_id: "oc-group",
chat_type: "group",
message_type: "text",
content: JSON.stringify({ text: "/status" }),
},
};
await dispatchMessage({ cfg, event });
expect(mockResolveCommandAuthorizedFromAuthorizers).toHaveBeenCalledWith({
useAccessGroups: true,
authorizers: [{ configured: true, allowed: true }],
});
expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
expect.objectContaining({
ChatType: "group",
CommandAuthorized: true,
SenderId: "ou-admin",
}),
);
});
it("uses video file_key (not thumbnail image_key) for inbound video download", async () => {
mockShouldComputeCommandAuthorized.mockReturnValue(false);
const cfg: ClawdbotConfig = {
channels: {
feishu: {
dmPolicy: "open",
},
},
} as ClawdbotConfig;
const event: FeishuMessageEvent = {
sender: {
sender_id: {
open_id: "ou-sender",
},
},
message: {
message_id: "msg-video-inbound",
chat_id: "oc-dm",
chat_type: "p2p",
message_type: "video",
content: JSON.stringify({
file_key: "file_video_payload",
image_key: "img_thumb_payload",
file_name: "clip.mp4",
}),
},
};
await dispatchMessage({ cfg, event });
expect(mockDownloadMessageResourceFeishu).toHaveBeenCalledWith(
expect.objectContaining({
messageId: "msg-video-inbound",
fileKey: "file_video_payload",
type: "file",
}),
);
expect(mockSaveMediaBuffer).toHaveBeenCalledWith(
expect.any(Buffer),
"video/mp4",
"inbound",
expect.any(Number),
"clip.mp4",
);
});
it("includes message_id in BodyForAgent on its own line", async () => {
mockShouldComputeCommandAuthorized.mockReturnValue(false);
const cfg: ClawdbotConfig = {
channels: {
feishu: {
dmPolicy: "open",
},
},
} as ClawdbotConfig;
const event: FeishuMessageEvent = {
sender: {
sender_id: {
open_id: "ou-msgid",
},
},
message: {
message_id: "msg-message-id-line",
chat_id: "oc-dm",
chat_type: "p2p",
message_type: "text",
content: JSON.stringify({ text: "hello" }),
},
};
await dispatchMessage({ cfg, event });
expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
expect.objectContaining({
BodyForAgent: "[message_id: msg-message-id-line]\nou-msgid: hello",
}),
);
});
it("dispatches once and appends permission notice to the main agent body", async () => {
mockShouldComputeCommandAuthorized.mockReturnValue(false);
mockCreateFeishuClient.mockReturnValue({
contact: {
user: {
get: vi.fn().mockRejectedValue({
response: {
data: {
code: 99991672,
msg: "permission denied https://open.feishu.cn/app/cli_test",
},
},
}),
},
},
});
const cfg: ClawdbotConfig = {
channels: {
feishu: {
appId: "cli_test",
appSecret: "sec_test",
groups: {
"oc-group": {
requireMention: false,
},
},
},
},
} as ClawdbotConfig;
const event: FeishuMessageEvent = {
sender: {
sender_id: {
open_id: "ou-perm",
},
},
message: {
message_id: "msg-perm-1",
chat_id: "oc-group",
chat_type: "group",
message_type: "text",
content: JSON.stringify({ text: "hello group" }),
},
};
await dispatchMessage({ cfg, event });
expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(1);
expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
expect.objectContaining({
BodyForAgent: expect.stringContaining(
"Permission grant URL: https://open.feishu.cn/app/cli_test",
),
}),
);
expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
expect.objectContaining({
BodyForAgent: expect.stringContaining("ou-perm: hello group"),
}),
);
});
});

View File

@@ -0,0 +1,979 @@
import type { ClawdbotConfig, RuntimeEnv } from "openclaw/plugin-sdk";
import {
buildAgentMediaPayload,
buildPendingHistoryContextFromMap,
clearHistoryEntriesIfEnabled,
createScopedPairingAccess,
DEFAULT_GROUP_HISTORY_LIMIT,
type HistoryEntry,
recordPendingHistoryEntryIfEnabled,
resolveOpenProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
warnMissingProviderGroupPolicyFallbackOnce,
} from "openclaw/plugin-sdk";
import { resolveFeishuAccount } from "./accounts.js";
import { createFeishuClient } from "./client.js";
import { tryRecordMessagePersistent } from "./dedup.js";
import { maybeCreateDynamicAgent } from "./dynamic-agent.js";
import { normalizeFeishuExternalKey } from "./external-keys.js";
import { downloadMessageResourceFeishu } from "./media.js";
import {
escapeRegExp,
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, sendMessageFeishu } from "./send.js";
import type { FeishuMessageContext, FeishuMediaInfo, ResolvedFeishuAccount } from "./types.js";
import type { DynamicAgentCreationConfig } from "./types.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: {
account: ResolvedFeishuAccount;
senderOpenId: string;
log: (...args: any[]) => void;
}): Promise<SenderNameResult> {
const { account, senderOpenId, log } = params;
if (!account.configured) 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(account);
// 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 {
if (!botOpenId) return false;
const mentions = event.message.mentions ?? [];
if (mentions.length > 0) {
return mentions.some((m) => m.id.open_id === botOpenId);
}
// Post (rich text) messages may have empty message.mentions when they contain docs/paste
if (event.message.message_type === "post") {
const { mentionedOpenIds } = parsePostContent(event.message.content);
return mentionedOpenIds.some((id) => id === botOpenId);
}
return false;
}
export 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(`@${escapeRegExp(mention.name)}\\s*`, "g"), "");
result = result.replace(new RegExp(escapeRegExp(mention.key), "g"), "");
}
return result.trim();
}
/**
* 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);
const imageKey = normalizeFeishuExternalKey(parsed.image_key);
const fileKey = normalizeFeishuExternalKey(parsed.file_key);
switch (messageType) {
case "image":
return { imageKey };
case "file":
return { fileKey, fileName: parsed.file_name };
case "audio":
return { fileKey };
case "video":
// Video has both file_key (video) and image_key (thumbnail)
return { fileKey, imageKey };
case "sticker":
return { fileKey };
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[];
mentionedOpenIds: string[];
} {
try {
const parsed = JSON.parse(content);
const title = parsed.title || "";
const contentBlocks = parsed.content || [];
let textContent = title ? `${title}\n\n` : "";
const imageKeys: string[] = [];
const mentionedOpenIds: 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 || ""}`;
if (element.user_id) {
mentionedOpenIds.push(element.user_id);
}
} else if (element.tag === "img" && element.image_key) {
// Embedded image
const imageKey = normalizeFeishuExternalKey(element.image_key);
if (imageKey) {
imageKeys.push(imageKey);
}
}
}
textContent += "\n";
}
}
return {
textContent: textContent.trim() || "[Rich text message]",
imageKeys,
mentionedOpenIds,
};
} catch {
return { textContent: "[Rich text message]", imageKeys: [], mentionedOpenIds: [] };
}
}
/**
* 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;
accountId?: string;
}): Promise<FeishuMediaInfo[]> {
const { cfg, messageId, messageType, content, maxBytes, log, accountId } = 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",
accountId,
});
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.fileKey || mediaKeys.imageKey;
if (!fileKey) {
return [];
}
const resourceType = messageType === "image" ? "image" : "file";
const result = await downloadMessageResourceFeishu({
cfg,
messageId,
fileKey,
type: resourceType,
accountId,
});
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().
*/
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 function buildFeishuAgentBody(params: {
ctx: Pick<
FeishuMessageContext,
"content" | "senderName" | "senderOpenId" | "mentionTargets" | "messageId"
>;
quotedContent?: string;
permissionErrorForAgent?: PermissionError;
}): string {
const { ctx, quotedContent, permissionErrorForAgent } = params;
let messageBody = ctx.content;
if (quotedContent) {
messageBody = `[Replying to: "${quotedContent}"]\n\n${ctx.content}`;
}
// DMs already have per-sender sessions, but this label still improves attribution.
const speaker = ctx.senderName ?? ctx.senderOpenId;
messageBody = `${speaker}: ${messageBody}`;
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.]`;
}
// Keep message_id on its own line so shared message-id hint stripping can parse it reliably.
messageBody = `[message_id: ${ctx.messageId}]\n${messageBody}`;
if (permissionErrorForAgent) {
const grantUrl = permissionErrorForAgent.grantUrl ?? "";
messageBody += `\n\n[System: The bot encountered a Feishu API permission error. Please inform the user about this issue and provide the permission grant URL for the admin to authorize. Permission grant URL: ${grantUrl}]`;
}
return messageBody;
}
export async function handleFeishuMessage(params: {
cfg: ClawdbotConfig;
event: FeishuMessageEvent;
botOpenId?: string;
runtime?: RuntimeEnv;
chatHistories?: Map<string, HistoryEntry[]>;
accountId?: string;
}): Promise<void> {
const { cfg, event, botOpenId, runtime, chatHistories, accountId } = params;
// Resolve account with merged config
const account = resolveFeishuAccount({ cfg, accountId });
const feishuCfg = account.config;
const log = runtime?.log ?? console.log;
const error = runtime?.error ?? console.error;
// Dedup check: skip if this message was already processed (memory + disk).
const messageId = event.message.message_id;
if (!(await tryRecordMessagePersistent(messageId, account.accountId, log))) {
log(`feishu: skipping duplicate message ${messageId}`);
return;
}
let ctx = parseFeishuMessageEvent(event, botOpenId);
const isGroup = ctx.chatType === "group";
const senderUserId = event.sender.sender_id.user_id?.trim() || undefined;
// Resolve sender display name (best-effort) so the agent can attribute messages correctly.
const senderResult = await resolveFeishuSenderName({
account,
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 = account.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[${account.accountId}]: 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[${account.accountId}]: detected @ forward request, targets: [${names}]`);
}
const historyLimit = Math.max(
0,
feishuCfg?.historyLimit ?? cfg.messages?.groupChat?.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT,
);
const groupConfig = isGroup
? resolveFeishuGroupConfig({ cfg: feishuCfg, groupId: ctx.chatId })
: undefined;
const dmPolicy = feishuCfg?.dmPolicy ?? "pairing";
const configAllowFrom = feishuCfg?.allowFrom ?? [];
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
if (isGroup) {
const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg);
const { groupPolicy, providerMissingFallbackApplied } = resolveOpenProviderRuntimeGroupPolicy({
providerConfigPresent: cfg.channels?.feishu !== undefined,
groupPolicy: feishuCfg?.groupPolicy,
defaultGroupPolicy,
});
warnMissingProviderGroupPolicyFallbackOnce({
providerMissingFallbackApplied,
providerKey: "feishu",
accountId: account.accountId,
log,
});
const groupAllowFrom = feishuCfg?.groupAllowFrom ?? [];
// DEBUG: log(`feishu[${account.accountId}]: groupPolicy=${groupPolicy}`);
// 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[${account.accountId}]: sender ${ctx.senderOpenId} not in group 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,
senderIds: [senderUserId],
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[${account.accountId}]: 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 {
}
try {
const core = getFeishuRuntime();
const pairing = createScopedPairingAccess({
core,
channel: "feishu",
accountId: account.accountId,
});
const shouldComputeCommandAuthorized = core.channel.commands.shouldComputeCommandAuthorized(
ctx.content,
cfg,
);
const storeAllowFrom =
!isGroup &&
dmPolicy !== "allowlist" &&
(dmPolicy !== "open" || shouldComputeCommandAuthorized)
? await pairing.readAllowFromStore().catch(() => [])
: [];
const effectiveDmAllowFrom = [...configAllowFrom, ...storeAllowFrom];
const dmAllowed = resolveFeishuAllowlistMatch({
allowFrom: effectiveDmAllowFrom,
senderId: ctx.senderOpenId,
senderIds: [senderUserId],
senderName: ctx.senderName,
}).allowed;
if (!isGroup && dmPolicy !== "open" && !dmAllowed) {
if (dmPolicy === "pairing") {
const { code, created } = await pairing.upsertPairingRequest({
id: ctx.senderOpenId,
meta: { name: ctx.senderName },
});
if (created) {
log(`feishu[${account.accountId}]: pairing request sender=${ctx.senderOpenId}`);
try {
await sendMessageFeishu({
cfg,
to: `user:${ctx.senderOpenId}`,
text: core.channel.pairing.buildPairingReply({
channel: "feishu",
idLine: `Your Feishu user id: ${ctx.senderOpenId}`,
code,
}),
accountId: account.accountId,
});
} catch (err) {
log(
`feishu[${account.accountId}]: pairing reply failed for ${ctx.senderOpenId}: ${String(err)}`,
);
}
}
} else {
log(
`feishu[${account.accountId}]: blocked unauthorized sender ${ctx.senderOpenId} (dmPolicy=${dmPolicy})`,
);
}
return;
}
const commandAllowFrom = isGroup
? (groupConfig?.allowFrom ?? configAllowFrom)
: effectiveDmAllowFrom;
const senderAllowedForCommands = resolveFeishuAllowlistMatch({
allowFrom: commandAllowFrom,
senderId: ctx.senderOpenId,
senderIds: [senderUserId],
senderName: ctx.senderName,
}).allowed;
const commandAuthorized = shouldComputeCommandAuthorized
? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
useAccessGroups,
authorizers: [
{ configured: commandAllowFrom.length > 0, allowed: senderAllowedForCommands },
],
})
: undefined;
// 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}`;
// Resolve peer ID for session routing
// When topicSessionMode is enabled, messages within a topic (identified by root_id)
// get a separate session from the main group chat.
let peerId = isGroup ? ctx.chatId : ctx.senderOpenId;
let topicSessionMode: "enabled" | "disabled" = "disabled";
if (isGroup && ctx.rootId) {
const groupConfig = resolveFeishuGroupConfig({ cfg: feishuCfg, groupId: ctx.chatId });
topicSessionMode = groupConfig?.topicSessionMode ?? feishuCfg?.topicSessionMode ?? "disabled";
if (topicSessionMode === "enabled") {
// Use chatId:topic:rootId as peer ID for topic-scoped sessions
peerId = `${ctx.chatId}:topic:${ctx.rootId}`;
log(`feishu[${account.accountId}]: topic session isolation enabled, peer=${peerId}`);
}
}
let route = core.channel.routing.resolveAgentRoute({
cfg,
channel: "feishu",
accountId: account.accountId,
peer: {
kind: isGroup ? "group" : "direct",
id: peerId,
},
// Add parentPeer for binding inheritance in topic mode
parentPeer:
isGroup && ctx.rootId && topicSessionMode === "enabled"
? {
kind: "group",
id: ctx.chatId,
}
: null,
});
// Dynamic agent creation for DM users
// When enabled, creates a unique agent instance with its own workspace for each DM user.
let effectiveCfg = cfg;
if (!isGroup && route.matchedBy === "default") {
const dynamicCfg = feishuCfg?.dynamicAgentCreation as DynamicAgentCreationConfig | undefined;
if (dynamicCfg?.enabled) {
const runtime = getFeishuRuntime();
const result = await maybeCreateDynamicAgent({
cfg,
runtime,
senderOpenId: ctx.senderOpenId,
dynamicCfg,
log: (msg) => log(msg),
});
if (result.created) {
effectiveCfg = result.updatedCfg;
// Re-resolve route with updated config
route = core.channel.routing.resolveAgentRoute({
cfg: result.updatedCfg,
channel: "feishu",
accountId: account.accountId,
peer: { kind: "direct", id: ctx.senderOpenId },
});
log(
`feishu[${account.accountId}]: dynamic agent created, new route: ${route.sessionKey}`,
);
}
}
}
const preview = ctx.content.replace(/\s+/g, " ").slice(0, 160);
const inboundLabel = isGroup
? `Feishu[${account.accountId}] message in group ${ctx.chatId}`
: `Feishu[${account.accountId}] 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,
accountId: account.accountId,
});
const mediaPayload = buildAgentMediaPayload(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,
accountId: account.accountId,
});
if (quotedMsg) {
quotedContent = quotedMsg.content;
log(
`feishu[${account.accountId}]: fetched quoted message: ${quotedContent?.slice(0, 100)}`,
);
}
} catch (err) {
log(`feishu[${account.accountId}]: failed to fetch quoted message: ${String(err)}`);
}
}
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
const messageBody = buildFeishuAgentBody({
ctx,
quotedContent,
permissionErrorForAgent,
});
const envelopeFrom = isGroup ? `${ctx.chatId}:${ctx.senderOpenId}` : ctx.senderOpenId;
if (permissionErrorForAgent) {
// Keep the notice in a single dispatch to avoid duplicate replies (#27372).
log(`feishu[${account.accountId}]: appending permission error notice to message body`);
}
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 inboundHistory =
isGroup && historyKey && historyLimit > 0 && chatHistories
? (chatHistories.get(historyKey) ?? []).map((entry) => ({
sender: entry.sender,
body: entry.body,
timestamp: entry.timestamp,
}))
: undefined;
const ctxPayload = core.channel.reply.finalizeInboundContext({
Body: combinedBody,
BodyForAgent: messageBody,
InboundHistory: inboundHistory,
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,
ReplyToBody: quotedContent ?? undefined,
Timestamp: Date.now(),
WasMentioned: ctx.mentionedBot,
CommandAuthorized: commandAuthorized,
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,
accountId: account.accountId,
});
log(`feishu[${account.accountId}]: dispatching to agent (session=${route.sessionKey})`);
const { queuedFinal, counts } = await core.channel.reply.withReplyDispatcher({
dispatcher,
onSettled: () => {
markDispatchIdle();
},
run: () =>
core.channel.reply.dispatchReplyFromConfig({
ctx: ctxPayload,
cfg,
dispatcher,
replyOptions,
}),
});
if (isGroup && historyKey && chatHistories) {
clearHistoryEntriesIfEnabled({
historyMap: chatHistories,
historyKey,
limit: historyLimit,
});
}
log(
`feishu[${account.accountId}]: dispatch complete (queuedFinal=${queuedFinal}, replies=${counts.final})`,
);
} catch (err) {
error(`feishu[${account.accountId}]: failed to dispatch message: ${String(err)}`);
}
}

View File

@@ -0,0 +1,48 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import { describe, expect, it, vi } from "vitest";
const probeFeishuMock = vi.hoisted(() => vi.fn());
vi.mock("./probe.js", () => ({
probeFeishu: probeFeishuMock,
}));
import { feishuPlugin } from "./channel.js";
describe("feishuPlugin.status.probeAccount", () => {
it("uses current account credentials for multi-account config", async () => {
const cfg = {
channels: {
feishu: {
enabled: true,
accounts: {
main: {
appId: "cli_main",
appSecret: "secret_main",
enabled: true,
},
},
},
},
} as OpenClawConfig;
const account = feishuPlugin.config.resolveAccount(cfg, "main");
probeFeishuMock.mockResolvedValueOnce({ ok: true, appId: "cli_main" });
const result = await feishuPlugin.status?.probeAccount?.({
account,
timeoutMs: 1_000,
cfg,
});
expect(probeFeishuMock).toHaveBeenCalledTimes(1);
expect(probeFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
accountId: "main",
appId: "cli_main",
appSecret: "secret_main",
}),
);
expect(result).toMatchObject({ ok: true, appId: "cli_main" });
});
});

View File

@@ -0,0 +1,359 @@
import type { ChannelMeta, ChannelPlugin, ClawdbotConfig } from "openclaw/plugin-sdk";
import {
buildBaseChannelStatusSummary,
createDefaultChannelRuntimeState,
DEFAULT_ACCOUNT_ID,
PAIRING_APPROVED_MESSAGE,
resolveAllowlistProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
} from "openclaw/plugin-sdk";
import {
resolveFeishuAccount,
resolveFeishuCredentials,
listFeishuAccountIds,
resolveDefaultFeishuAccountId,
} from "./accounts.js";
import {
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, formatFeishuTarget } from "./targets.js";
import type { ResolvedFeishuAccount, FeishuConfig } from "./types.js";
const meta: ChannelMeta = {
id: "feishu",
label: "Feishu",
selectionLabel: "Feishu/Lark (飞书)",
docsPath: "/channels/feishu",
docsLabel: "feishu",
blurb: "飞书/Lark enterprise messaging.",
aliases: ["lark"],
order: 70,
};
export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
id: "feishu",
meta: {
...meta,
},
pairing: {
idLabel: "feishuUserId",
normalizeAllowEntry: (entry) => entry.replace(/^(feishu|user|open_id):/i, ""),
notifyApproval: async ({ cfg, id }) => {
await sendMessageFeishu({
cfg,
to: id,
text: PAIRING_APPROVED_MESSAGE,
});
},
},
capabilities: {
chatTypes: ["direct", "channel"],
polls: false,
threads: true,
media: true,
reactions: true,
edit: true,
reply: true,
},
agentPrompt: {
messageToolHints: () => [
"- Feishu targeting: omit `target` to reply to the current conversation (auto-inferred). Explicit targets: `user:open_id` or `chat:chat_id`.",
"- Feishu supports interactive cards for rich messages.",
],
},
groups: {
resolveToolPolicy: resolveFeishuGroupToolPolicy,
},
reload: { configPrefixes: ["channels.feishu"] },
configSchema: {
schema: {
type: "object",
additionalProperties: false,
properties: {
enabled: { type: "boolean" },
appId: { type: "string" },
appSecret: { type: "string" },
encryptKey: { type: "string" },
verificationToken: { type: "string" },
domain: {
oneOf: [
{ type: "string", enum: ["feishu", "lark"] },
{ type: "string", format: "uri", pattern: "^https://" },
],
},
connectionMode: { type: "string", enum: ["websocket", "webhook"] },
webhookPath: { type: "string" },
webhookHost: { 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" },
topicSessionMode: { type: "string", enum: ["disabled", "enabled"] },
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"] },
accounts: {
type: "object",
additionalProperties: {
type: "object",
properties: {
enabled: { type: "boolean" },
name: { type: "string" },
appId: { type: "string" },
appSecret: { type: "string" },
encryptKey: { type: "string" },
verificationToken: { type: "string" },
domain: { type: "string", enum: ["feishu", "lark"] },
connectionMode: { type: "string", enum: ["websocket", "webhook"] },
webhookHost: { type: "string" },
webhookPath: { type: "string" },
webhookPort: { type: "integer", minimum: 1 },
},
},
},
},
},
},
config: {
listAccountIds: (cfg) => listFeishuAccountIds(cfg),
resolveAccount: (cfg, accountId) => resolveFeishuAccount({ cfg, accountId }),
defaultAccountId: (cfg) => resolveDefaultFeishuAccountId(cfg),
setAccountEnabled: ({ cfg, accountId, enabled }) => {
const account = resolveFeishuAccount({ cfg, accountId });
const isDefault = accountId === DEFAULT_ACCOUNT_ID;
if (isDefault) {
// For default account, set top-level enabled
return {
...cfg,
channels: {
...cfg.channels,
feishu: {
...cfg.channels?.feishu,
enabled,
},
},
};
}
// For named accounts, set enabled in accounts[accountId]
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
return {
...cfg,
channels: {
...cfg.channels,
feishu: {
...feishuCfg,
accounts: {
...feishuCfg?.accounts,
[accountId]: {
...feishuCfg?.accounts?.[accountId],
enabled,
},
},
},
},
};
},
deleteAccount: ({ cfg, accountId }) => {
const isDefault = accountId === DEFAULT_ACCOUNT_ID;
if (isDefault) {
// Delete entire feishu config
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;
}
// Delete specific account from accounts
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
const accounts = { ...feishuCfg?.accounts };
delete accounts[accountId];
return {
...cfg,
channels: {
...cfg.channels,
feishu: {
...feishuCfg,
accounts: Object.keys(accounts).length > 0 ? accounts : undefined,
},
},
};
},
isConfigured: (account) => account.configured,
describeAccount: (account) => ({
accountId: account.accountId,
enabled: account.enabled,
configured: account.configured,
name: account.name,
appId: account.appId,
domain: account.domain,
}),
resolveAllowFrom: ({ cfg, accountId }) => {
const account = resolveFeishuAccount({ cfg, accountId });
return (account.config?.allowFrom ?? []).map((entry) => String(entry));
},
formatAllowFrom: ({ allowFrom }) =>
allowFrom
.map((entry) => String(entry).trim())
.filter(Boolean)
.map((entry) => entry.toLowerCase()),
},
security: {
collectWarnings: ({ cfg, accountId }) => {
const account = resolveFeishuAccount({ cfg, accountId });
const feishuCfg = account.config;
const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg);
const { groupPolicy } = resolveAllowlistProviderRuntimeGroupPolicy({
providerConfigPresent: cfg.channels?.feishu !== undefined,
groupPolicy: feishuCfg?.groupPolicy,
defaultGroupPolicy,
});
if (groupPolicy !== "open") return [];
return [
`- Feishu[${account.accountId}] groups: groupPolicy="open" allows any member to trigger (mention-gated). Set channels.feishu.groupPolicy="allowlist" + channels.feishu.groupAllowFrom to restrict senders.`,
];
},
},
setup: {
resolveAccountId: () => DEFAULT_ACCOUNT_ID,
applyAccountConfig: ({ cfg, accountId }) => {
const isDefault = !accountId || accountId === DEFAULT_ACCOUNT_ID;
if (isDefault) {
return {
...cfg,
channels: {
...cfg.channels,
feishu: {
...cfg.channels?.feishu,
enabled: true,
},
},
};
}
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
return {
...cfg,
channels: {
...cfg.channels,
feishu: {
...feishuCfg,
accounts: {
...feishuCfg?.accounts,
[accountId]: {
...feishuCfg?.accounts?.[accountId],
enabled: true,
},
},
},
},
};
},
},
onboarding: feishuOnboardingAdapter,
messaging: {
normalizeTarget: (raw) => normalizeFeishuTarget(raw) ?? undefined,
targetResolver: {
looksLikeId: looksLikeFeishuId,
hint: "<chatId|user:openId|chat:chatId>",
},
},
directory: {
self: async () => null,
listPeers: async ({ cfg, query, limit, accountId }) =>
listFeishuDirectoryPeers({
cfg,
query: query ?? undefined,
limit: limit ?? undefined,
accountId: accountId ?? undefined,
}),
listGroups: async ({ cfg, query, limit, accountId }) =>
listFeishuDirectoryGroups({
cfg,
query: query ?? undefined,
limit: limit ?? undefined,
accountId: accountId ?? undefined,
}),
listPeersLive: async ({ cfg, query, limit, accountId }) =>
listFeishuDirectoryPeersLive({
cfg,
query: query ?? undefined,
limit: limit ?? undefined,
accountId: accountId ?? undefined,
}),
listGroupsLive: async ({ cfg, query, limit, accountId }) =>
listFeishuDirectoryGroupsLive({
cfg,
query: query ?? undefined,
limit: limit ?? undefined,
accountId: accountId ?? undefined,
}),
},
outbound: feishuOutbound,
status: {
defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID, { port: null }),
buildChannelSummary: ({ snapshot }) => ({
...buildBaseChannelStatusSummary(snapshot),
port: snapshot.port ?? null,
probe: snapshot.probe,
lastProbeAt: snapshot.lastProbeAt ?? null,
}),
probeAccount: async ({ account }) => await probeFeishu(account),
buildAccountSnapshot: ({ account, runtime, probe }) => ({
accountId: account.accountId,
enabled: account.enabled,
configured: account.configured,
name: account.name,
appId: account.appId,
domain: account.domain,
running: runtime?.running ?? false,
lastStartAt: runtime?.lastStartAt ?? null,
lastStopAt: runtime?.lastStopAt ?? null,
lastError: runtime?.lastError ?? null,
port: runtime?.port ?? null,
probe,
}),
},
gateway: {
startAccount: async (ctx) => {
const { monitorFeishuProvider } = await import("./monitor.js");
const account = resolveFeishuAccount({ cfg: ctx.cfg, accountId: ctx.accountId });
const port = account.config?.webhookPort ?? null;
ctx.setStatus({ accountId: ctx.accountId, port });
ctx.log?.info(
`starting feishu[${ctx.accountId}] (mode: ${account.config?.connectionMode ?? "websocket"})`,
);
return monitorFeishuProvider({
config: ctx.cfg,
runtime: ctx.runtime,
abortSignal: ctx.abortSignal,
accountId: ctx.accountId,
});
},
},
};

View File

@@ -0,0 +1,118 @@
import * as Lark from "@larksuiteoapi/node-sdk";
import type { FeishuDomain, ResolvedFeishuAccount } from "./types.js";
// Multi-account client cache
const clientCache = new Map<
string,
{
client: Lark.Client;
config: { appId: string; appSecret: string; domain?: FeishuDomain };
}
>();
function resolveDomain(domain: FeishuDomain | undefined): Lark.Domain | string {
if (domain === "lark") {
return Lark.Domain.Lark;
}
if (domain === "feishu" || !domain) {
return Lark.Domain.Feishu;
}
return domain.replace(/\/+$/, ""); // Custom URL for private deployment
}
/**
* Credentials needed to create a Feishu client.
* Both FeishuConfig and ResolvedFeishuAccount satisfy this interface.
*/
export type FeishuClientCredentials = {
accountId?: string;
appId?: string;
appSecret?: string;
domain?: FeishuDomain;
};
/**
* Create or get a cached Feishu client for an account.
* Accepts any object with appId, appSecret, and optional domain/accountId.
*/
export function createFeishuClient(creds: FeishuClientCredentials): Lark.Client {
const { accountId = "default", appId, appSecret, domain } = creds;
if (!appId || !appSecret) {
throw new Error(`Feishu credentials not configured for account "${accountId}"`);
}
// Check cache
const cached = clientCache.get(accountId);
if (
cached &&
cached.config.appId === appId &&
cached.config.appSecret === appSecret &&
cached.config.domain === domain
) {
return cached.client;
}
// Create new client
const client = new Lark.Client({
appId,
appSecret,
appType: Lark.AppType.SelfBuild,
domain: resolveDomain(domain),
});
// Cache it
clientCache.set(accountId, {
client,
config: { appId, appSecret, domain },
});
return client;
}
/**
* Create a Feishu WebSocket client for an account.
* Note: WSClient is not cached since each call creates a new connection.
*/
export function createFeishuWSClient(account: ResolvedFeishuAccount): Lark.WSClient {
const { accountId, appId, appSecret, domain } = account;
if (!appId || !appSecret) {
throw new Error(`Feishu credentials not configured for account "${accountId}"`);
}
return new Lark.WSClient({
appId,
appSecret,
domain: resolveDomain(domain),
loggerLevel: Lark.LoggerLevel.info,
});
}
/**
* Create an event dispatcher for an account.
*/
export function createEventDispatcher(account: ResolvedFeishuAccount): Lark.EventDispatcher {
return new Lark.EventDispatcher({
encryptKey: account.encryptKey,
verificationToken: account.verificationToken,
});
}
/**
* Get a cached client for an account (if exists).
*/
export function getFeishuClient(accountId: string): Lark.Client | null {
return clientCache.get(accountId)?.client ?? null;
}
/**
* Clear client cache for a specific account or all accounts.
*/
export function clearClientCache(accountId?: string): void {
if (accountId) {
clientCache.delete(accountId);
} else {
clientCache.clear();
}
}

View File

@@ -0,0 +1,88 @@
import { describe, expect, it } from "vitest";
import { FeishuConfigSchema } from "./config-schema.js";
describe("FeishuConfigSchema webhook validation", () => {
it("applies top-level defaults", () => {
const result = FeishuConfigSchema.parse({});
expect(result.domain).toBe("feishu");
expect(result.connectionMode).toBe("websocket");
expect(result.webhookPath).toBe("/feishu/events");
expect(result.dmPolicy).toBe("pairing");
expect(result.groupPolicy).toBe("allowlist");
expect(result.requireMention).toBe(true);
});
it("does not force top-level policy defaults into account config", () => {
const result = FeishuConfigSchema.parse({
accounts: {
main: {},
},
});
expect(result.accounts?.main?.dmPolicy).toBeUndefined();
expect(result.accounts?.main?.groupPolicy).toBeUndefined();
expect(result.accounts?.main?.requireMention).toBeUndefined();
});
it("rejects top-level webhook mode without verificationToken", () => {
const result = FeishuConfigSchema.safeParse({
connectionMode: "webhook",
appId: "cli_top",
appSecret: "secret_top",
});
expect(result.success).toBe(false);
if (!result.success) {
expect(
result.error.issues.some((issue) => issue.path.join(".") === "verificationToken"),
).toBe(true);
}
});
it("accepts top-level webhook mode with verificationToken", () => {
const result = FeishuConfigSchema.safeParse({
connectionMode: "webhook",
verificationToken: "token_top",
appId: "cli_top",
appSecret: "secret_top",
});
expect(result.success).toBe(true);
});
it("rejects account webhook mode without verificationToken", () => {
const result = FeishuConfigSchema.safeParse({
accounts: {
main: {
connectionMode: "webhook",
appId: "cli_main",
appSecret: "secret_main",
},
},
});
expect(result.success).toBe(false);
if (!result.success) {
expect(
result.error.issues.some(
(issue) => issue.path.join(".") === "accounts.main.verificationToken",
),
).toBe(true);
}
});
it("accepts account webhook mode inheriting top-level verificationToken", () => {
const result = FeishuConfigSchema.safeParse({
verificationToken: "token_top",
accounts: {
main: {
connectionMode: "webhook",
appId: "cli_main",
appSecret: "secret_main",
},
},
});
expect(result.success).toBe(true);
});
});

View File

@@ -0,0 +1,226 @@
import { z } from "zod";
export { z };
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 ToolPolicySchema = z
.object({
allow: z.array(z.string()).optional(),
deny: z.array(z.string()).optional(),
})
.strict()
.optional();
const DmConfigSchema = z
.object({
enabled: z.boolean().optional(),
systemPrompt: z.string().optional(),
})
.strict()
.optional();
const MarkdownConfigSchema = z
.object({
mode: z.enum(["native", "escape", "strip"]).optional(),
tableMode: z.enum(["native", "ascii", "simple"]).optional(),
})
.strict()
.optional();
// Message render mode: auto (default) = detect markdown, raw = plain text, card = always card
const RenderModeSchema = z.enum(["auto", "raw", "card"]).optional();
// Streaming card mode: when enabled, card replies use Feishu's Card Kit streaming API
// for incremental text display with a "Thinking..." placeholder
const StreamingModeSchema = z.boolean().optional();
const BlockStreamingCoalesceSchema = z
.object({
enabled: z.boolean().optional(),
minDelayMs: z.number().int().positive().optional(),
maxDelayMs: z.number().int().positive().optional(),
})
.strict()
.optional();
const ChannelHeartbeatVisibilitySchema = z
.object({
visibility: z.enum(["visible", "hidden"]).optional(),
intervalMs: z.number().int().positive().optional(),
})
.strict()
.optional();
/**
* Dynamic agent creation configuration.
* When enabled, a new agent is created for each unique DM user.
*/
const DynamicAgentCreationSchema = z
.object({
enabled: z.boolean().optional(),
workspaceTemplate: z.string().optional(),
agentDirTemplate: z.string().optional(),
maxAgents: z.number().int().positive().optional(),
})
.strict()
.optional();
/**
* Feishu tools configuration.
* Controls which tool categories are enabled.
*
* Dependencies:
* - wiki requires doc (wiki content is edited via doc tools)
* - perm can work independently but is typically used with drive
*/
const FeishuToolsConfigSchema = z
.object({
doc: z.boolean().optional(), // Document operations (default: true)
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();
/**
* Topic session isolation mode for group chats.
* - "disabled" (default): All messages in a group share one session
* - "enabled": Messages in different topics get separate sessions
*
* When enabled, the session key becomes `chat:{chatId}:topic:{rootId}`
* for messages within a topic thread, allowing isolated conversations.
*/
const TopicSessionModeSchema = z.enum(["disabled", "enabled"]).optional();
export const FeishuGroupSchema = z
.object({
requireMention: z.boolean().optional(),
tools: ToolPolicySchema,
skills: z.array(z.string()).optional(),
enabled: z.boolean().optional(),
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
systemPrompt: z.string().optional(),
topicSessionMode: TopicSessionModeSchema,
})
.strict();
const FeishuSharedConfigShape = {
webhookHost: z.string().optional(),
webhookPort: z.number().int().positive().optional(),
capabilities: z.array(z.string()).optional(),
markdown: MarkdownConfigSchema,
configWrites: z.boolean().optional(),
dmPolicy: DmPolicySchema.optional(),
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
groupPolicy: GroupPolicySchema.optional(),
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
requireMention: z.boolean().optional(),
groups: z.record(z.string(), FeishuGroupSchema.optional()).optional(),
historyLimit: z.number().int().min(0).optional(),
dmHistoryLimit: z.number().int().min(0).optional(),
dms: z.record(z.string(), DmConfigSchema).optional(),
textChunkLimit: z.number().int().positive().optional(),
chunkMode: z.enum(["length", "newline"]).optional(),
blockStreamingCoalesce: BlockStreamingCoalesceSchema,
mediaMaxMb: z.number().positive().optional(),
heartbeat: ChannelHeartbeatVisibilitySchema,
renderMode: RenderModeSchema,
streaming: StreamingModeSchema,
tools: FeishuToolsConfigSchema,
};
/**
* Per-account configuration.
* All fields are optional - missing fields inherit from top-level config.
*/
export const FeishuAccountConfigSchema = z
.object({
enabled: z.boolean().optional(),
name: z.string().optional(), // Display name for this account
appId: z.string().optional(),
appSecret: z.string().optional(),
encryptKey: z.string().optional(),
verificationToken: z.string().optional(),
domain: FeishuDomainSchema.optional(),
connectionMode: FeishuConnectionModeSchema.optional(),
webhookPath: z.string().optional(),
...FeishuSharedConfigShape,
})
.strict();
export const FeishuConfigSchema = z
.object({
enabled: z.boolean().optional(),
// Top-level credentials (backward compatible for single-account mode)
appId: z.string().optional(),
appSecret: z.string().optional(),
encryptKey: z.string().optional(),
verificationToken: z.string().optional(),
domain: FeishuDomainSchema.optional().default("feishu"),
connectionMode: FeishuConnectionModeSchema.optional().default("websocket"),
webhookPath: z.string().optional().default("/feishu/events"),
...FeishuSharedConfigShape,
dmPolicy: DmPolicySchema.optional().default("pairing"),
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
requireMention: z.boolean().optional().default(true),
topicSessionMode: TopicSessionModeSchema,
// Dynamic agent creation for DM users
dynamicAgentCreation: DynamicAgentCreationSchema,
// Multi-account configuration
accounts: z.record(z.string(), FeishuAccountConfigSchema.optional()).optional(),
})
.strict()
.superRefine((value, ctx) => {
const defaultConnectionMode = value.connectionMode ?? "websocket";
const defaultVerificationToken = value.verificationToken?.trim();
if (defaultConnectionMode === "webhook" && !defaultVerificationToken) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["verificationToken"],
message:
'channels.feishu.connectionMode="webhook" requires channels.feishu.verificationToken',
});
}
for (const [accountId, account] of Object.entries(value.accounts ?? {})) {
if (!account) {
continue;
}
const accountConnectionMode = account.connectionMode ?? defaultConnectionMode;
if (accountConnectionMode !== "webhook") {
continue;
}
const accountVerificationToken =
account.verificationToken?.trim() || defaultVerificationToken;
if (!accountVerificationToken) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["accounts", accountId, "verificationToken"],
message:
`channels.feishu.accounts.${accountId}.connectionMode="webhook" requires ` +
"a verificationToken (account-level or top-level)",
});
}
}
if (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,54 @@
import os from "node:os";
import path from "node:path";
import { createDedupeCache, createPersistentDedupe } from "openclaw/plugin-sdk";
// Persistent TTL: 24 hours — survives restarts & WebSocket reconnects.
const DEDUP_TTL_MS = 24 * 60 * 60 * 1000;
const MEMORY_MAX_SIZE = 1_000;
const FILE_MAX_ENTRIES = 10_000;
const memoryDedupe = createDedupeCache({ ttlMs: DEDUP_TTL_MS, maxSize: MEMORY_MAX_SIZE });
function resolveStateDirFromEnv(env: NodeJS.ProcessEnv = process.env): string {
const stateOverride = env.OPENCLAW_STATE_DIR?.trim() || env.CLAWDBOT_STATE_DIR?.trim();
if (stateOverride) {
return stateOverride;
}
if (env.VITEST || env.NODE_ENV === "test") {
return path.join(os.tmpdir(), ["openclaw-vitest", String(process.pid)].join("-"));
}
return path.join(os.homedir(), ".openclaw");
}
function resolveNamespaceFilePath(namespace: string): string {
const safe = namespace.replace(/[^a-zA-Z0-9_-]/g, "_");
return path.join(resolveStateDirFromEnv(), "feishu", "dedup", `${safe}.json`);
}
const persistentDedupe = createPersistentDedupe({
ttlMs: DEDUP_TTL_MS,
memoryMaxSize: MEMORY_MAX_SIZE,
fileMaxEntries: FILE_MAX_ENTRIES,
resolveFilePath: resolveNamespaceFilePath,
});
/**
* Synchronous dedup — memory only.
* Kept for backward compatibility; prefer {@link tryRecordMessagePersistent}.
*/
export function tryRecordMessage(messageId: string): boolean {
return !memoryDedupe.check(messageId);
}
export async function tryRecordMessagePersistent(
messageId: string,
namespace = "global",
log?: (...args: unknown[]) => void,
): Promise<boolean> {
return persistentDedupe.checkAndRecord(messageId, {
namespace,
onDiskError: (error) => {
log?.(`feishu-dedup: disk error, falling back to memory: ${String(error)}`);
},
});
}

View File

@@ -0,0 +1,177 @@
import type { ClawdbotConfig } from "openclaw/plugin-sdk";
import { resolveFeishuAccount } from "./accounts.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;
accountId?: string;
}): Promise<FeishuDirectoryPeer[]> {
const account = resolveFeishuAccount({ cfg: params.cfg, accountId: params.accountId });
const feishuCfg = account.config;
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;
accountId?: string;
}): Promise<FeishuDirectoryGroup[]> {
const account = resolveFeishuAccount({ cfg: params.cfg, accountId: params.accountId });
const feishuCfg = account.config;
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;
accountId?: string;
}): Promise<FeishuDirectoryPeer[]> {
const account = resolveFeishuAccount({ cfg: params.cfg, accountId: params.accountId });
if (!account.configured) {
return listFeishuDirectoryPeers(params);
}
try {
const client = createFeishuClient(account);
const peers: FeishuDirectoryPeer[] = [];
const limit = params.limit ?? 50;
const response = await client.contact.user.list({
params: {
page_size: Math.min(limit, 50),
},
});
if (response.code === 0 && 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;
accountId?: string;
}): Promise<FeishuDirectoryGroup[]> {
const account = resolveFeishuAccount({ cfg: params.cfg, accountId: params.accountId });
if (!account.configured) {
return listFeishuDirectoryGroups(params);
}
try {
const client = createFeishuClient(account);
const groups: FeishuDirectoryGroup[] = [];
const limit = params.limit ?? 50;
const response = await client.im.chat.list({
params: {
page_size: Math.min(limit, 100),
},
});
if (response.code === 0 && 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,76 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { describe, expect, test, vi } from "vitest";
import { registerFeishuDocTools } from "./docx.js";
import { createToolFactoryHarness } from "./tool-factory-test-harness.js";
const createFeishuClientMock = vi.fn((creds: { appId?: string } | undefined) => ({
__appId: creds?.appId,
}));
vi.mock("./client.js", () => {
return {
createFeishuClient: (creds: { appId?: string } | undefined) => createFeishuClientMock(creds),
};
});
// Patch SDK import so tool execution can run without network concerns.
vi.mock("@larksuiteoapi/node-sdk", () => {
return {
default: {},
};
});
describe("feishu_doc account selection", () => {
test("uses agentAccountId context when params omit accountId", async () => {
const cfg = {
channels: {
feishu: {
enabled: true,
accounts: {
a: { appId: "app-a", appSecret: "sec-a", tools: { doc: true } },
b: { appId: "app-b", appSecret: "sec-b", tools: { doc: true } },
},
},
},
} as OpenClawPluginApi["config"];
const { api, resolveTool } = createToolFactoryHarness(cfg);
registerFeishuDocTools(api);
const docToolA = resolveTool("feishu_doc", { agentAccountId: "a" });
const docToolB = resolveTool("feishu_doc", { agentAccountId: "b" });
await docToolA.execute("call-a", { action: "list_blocks", doc_token: "d" });
await docToolB.execute("call-b", { action: "list_blocks", doc_token: "d" });
expect(createFeishuClientMock).toHaveBeenCalledTimes(2);
expect(createFeishuClientMock.mock.calls[0]?.[0]?.appId).toBe("app-a");
expect(createFeishuClientMock.mock.calls[1]?.[0]?.appId).toBe("app-b");
});
test("explicit accountId param overrides agentAccountId context", async () => {
const cfg = {
channels: {
feishu: {
enabled: true,
accounts: {
a: { appId: "app-a", appSecret: "sec-a", tools: { doc: true } },
b: { appId: "app-b", appSecret: "sec-b", tools: { doc: true } },
},
},
},
} as OpenClawPluginApi["config"];
const { api, resolveTool } = createToolFactoryHarness(cfg);
registerFeishuDocTools(api);
const docTool = resolveTool("feishu_doc", { agentAccountId: "b" });
await docTool.execute("call-override", {
action: "list_blocks",
doc_token: "d",
accountId: "a",
});
expect(createFeishuClientMock.mock.calls.at(-1)?.[0]?.appId).toBe("app-a");
});
});

View File

@@ -0,0 +1,124 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const createFeishuClientMock = vi.hoisted(() => vi.fn());
const fetchRemoteMediaMock = vi.hoisted(() => vi.fn());
vi.mock("./client.js", () => ({
createFeishuClient: createFeishuClientMock,
}));
vi.mock("./runtime.js", () => ({
getFeishuRuntime: () => ({
channel: {
media: {
fetchRemoteMedia: fetchRemoteMediaMock,
},
},
}),
}));
import { registerFeishuDocTools } from "./docx.js";
describe("feishu_doc image fetch hardening", () => {
const convertMock = vi.hoisted(() => vi.fn());
const blockListMock = vi.hoisted(() => vi.fn());
const blockChildrenCreateMock = vi.hoisted(() => vi.fn());
const driveUploadAllMock = vi.hoisted(() => vi.fn());
const blockPatchMock = vi.hoisted(() => vi.fn());
const scopeListMock = vi.hoisted(() => vi.fn());
beforeEach(() => {
vi.clearAllMocks();
createFeishuClientMock.mockReturnValue({
docx: {
document: {
convert: convertMock,
},
documentBlock: {
list: blockListMock,
patch: blockPatchMock,
},
documentBlockChildren: {
create: blockChildrenCreateMock,
},
},
drive: {
media: {
uploadAll: driveUploadAllMock,
},
},
application: {
scope: {
list: scopeListMock,
},
},
});
convertMock.mockResolvedValue({
code: 0,
data: {
blocks: [{ block_type: 27 }],
first_level_block_ids: [],
},
});
blockListMock.mockResolvedValue({
code: 0,
data: {
items: [],
},
});
blockChildrenCreateMock.mockResolvedValue({
code: 0,
data: {
children: [{ block_type: 27, block_id: "img_block_1" }],
},
});
driveUploadAllMock.mockResolvedValue({ file_token: "token_1" });
blockPatchMock.mockResolvedValue({ code: 0 });
scopeListMock.mockResolvedValue({ code: 0, data: { scopes: [] } });
});
it("skips image upload when markdown image URL is blocked", async () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
fetchRemoteMediaMock.mockRejectedValueOnce(
new Error("Blocked: resolves to private/internal IP address"),
);
const registerTool = vi.fn();
registerFeishuDocTools({
config: {
channels: {
feishu: {
appId: "app_id",
appSecret: "app_secret",
},
},
} as any,
logger: { debug: vi.fn(), info: vi.fn() } as any,
registerTool,
} as any);
const feishuDocTool = registerTool.mock.calls
.map((call) => call[0])
.map((tool) => (typeof tool === "function" ? tool({}) : tool))
.find((tool) => tool.name === "feishu_doc");
expect(feishuDocTool).toBeDefined();
const result = await feishuDocTool.execute("tool-call", {
action: "write",
doc_token: "doc_1",
content: "![x](https://x.test/image.png)",
});
expect(fetchRemoteMediaMock).toHaveBeenCalled();
expect(driveUploadAllMock).not.toHaveBeenCalled();
expect(blockPatchMock).not.toHaveBeenCalled();
expect(result.details.images_processed).toBe(0);
expect(consoleErrorSpy).toHaveBeenCalled();
consoleErrorSpy.mockRestore();
});
});

View File

@@ -0,0 +1,566 @@
import { Readable } from "stream";
import type * as Lark from "@larksuiteoapi/node-sdk";
import { Type } from "@sinclair/typebox";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { listEnabledFeishuAccounts } from "./accounts.js";
import { FeishuDocSchema, type FeishuDocParams } from "./doc-schema.js";
import { getFeishuRuntime } from "./runtime.js";
import {
createFeishuToolClient,
resolveAnyEnabledFeishuToolsConfig,
resolveFeishuToolAccount,
} from "./tool-account.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) */
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK block types
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: _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 ?? [],
};
}
function sortBlocksByFirstLevel(blocks: any[], firstLevelIds: string[]): any[] {
if (!firstLevelIds || firstLevelIds.length === 0) return blocks;
const sorted = firstLevelIds.map((id) => blocks.find((b) => b.block_id === id)).filter(Boolean);
const sortedIds = new Set(firstLevelIds);
const remaining = blocks.filter((b) => !sortedIds.has(b.block_id));
return [...sorted, ...remaining];
}
/* eslint-disable @typescript-eslint/no-explicit-any -- SDK block types */
async function insertBlocks(
client: Lark.Client,
docToken: string,
blocks: any[],
parentBlockId?: string,
): Promise<{ children: any[]; skipped: string[] }> {
/* eslint-enable @typescript-eslint/no-explicit-any */
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,
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK stream type
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, maxBytes: number): Promise<Buffer> {
const fetched = await getFeishuRuntime().channel.media.fetchRemoteMedia({ url, maxBytes });
return fetched.buffer;
}
/* eslint-disable @typescript-eslint/no-explicit-any -- SDK block types */
async function processImages(
client: Lark.Client,
docToken: string,
markdown: string,
insertedBlocks: any[],
maxBytes: number,
): Promise<number> {
/* eslint-enable @typescript-eslint/no-explicit-any */
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, maxBytes);
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, maxBytes: number) {
const deleted = await clearDocumentContent(client, docToken);
const { blocks, firstLevelBlockIds } = await convertMarkdown(client, markdown);
if (blocks.length === 0) {
return { success: true, blocks_deleted: deleted, blocks_added: 0, images_processed: 0 };
}
const sortedBlocks = sortBlocksByFirstLevel(blocks, firstLevelBlockIds);
const { children: inserted, skipped } = await insertBlocks(client, docToken, sortedBlocks);
const imagesProcessed = await processImages(client, docToken, markdown, inserted, maxBytes);
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,
maxBytes: number,
) {
const { blocks, firstLevelBlockIds } = await convertMarkdown(client, markdown);
if (blocks.length === 0) {
throw new Error("Content is empty");
}
const sortedBlocks = sortBlocksByFirstLevel(blocks, firstLevelBlockIds);
const { children: inserted, skipped } = await insertBlocks(client, docToken, sortedBlocks);
const imagesProcessed = await processImages(client, docToken, markdown, inserted, maxBytes);
return {
success: true,
blocks_added: inserted.length,
images_processed: imagesProcessed,
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK block type
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 ?? [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK block type
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) {
if (!api.config) {
api.logger.debug?.("feishu_doc: No config available, skipping doc tools");
return;
}
// Check if any account is configured
const accounts = listEnabledFeishuAccounts(api.config);
if (accounts.length === 0) {
api.logger.debug?.("feishu_doc: No Feishu accounts configured, skipping doc tools");
return;
}
// Register if enabled on any account; account routing is resolved per execution.
const toolsCfg = resolveAnyEnabledFeishuToolsConfig(accounts);
const registered: string[] = [];
type FeishuDocExecuteParams = FeishuDocParams & { accountId?: string };
const getClient = (params: { accountId?: string } | undefined, defaultAccountId?: string) =>
createFeishuToolClient({ api, executeParams: params, defaultAccountId });
const getMediaMaxBytes = (
params: { accountId?: string } | undefined,
defaultAccountId?: string,
) =>
(resolveFeishuToolAccount({ api, executeParams: params, defaultAccountId }).config
?.mediaMaxMb ?? 30) *
1024 *
1024;
// Main document tool with action-based dispatch
if (toolsCfg.doc) {
api.registerTool(
(ctx) => {
const defaultAccountId = ctx.agentAccountId;
return {
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 FeishuDocExecuteParams;
try {
const client = getClient(p, defaultAccountId);
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,
getMediaMaxBytes(p, defaultAccountId),
),
);
case "append":
return json(
await appendDoc(
client,
p.doc_token,
p.content,
getMediaMaxBytes(p, defaultAccountId),
),
);
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: {
const exhaustiveCheck: never = p;
return json({ error: `Unknown action: ${String(exhaustiveCheck)}` });
}
}
} 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(
(ctx) => ({
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(undefined, ctx.agentAccountId));
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,232 @@
import type * as Lark from "@larksuiteoapi/node-sdk";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { listEnabledFeishuAccounts } from "./accounts.js";
import { FeishuDriveSchema, type FeishuDriveParams } from "./drive-schema.js";
import { createFeishuToolClient, resolveAnyEnabledFeishuToolsConfig } from "./tool-account.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
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- accessing internal SDK property
const domain = (client as any).domain ?? "https://open.feishu.cn";
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- accessing internal SDK property
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) {
if (!api.config) {
api.logger.debug?.("feishu_drive: No config available, skipping drive tools");
return;
}
const accounts = listEnabledFeishuAccounts(api.config);
if (accounts.length === 0) {
api.logger.debug?.("feishu_drive: No Feishu accounts configured, skipping drive tools");
return;
}
const toolsCfg = resolveAnyEnabledFeishuToolsConfig(accounts);
if (!toolsCfg.drive) {
api.logger.debug?.("feishu_drive: drive tool disabled in config");
return;
}
type FeishuDriveExecuteParams = FeishuDriveParams & { accountId?: string };
api.registerTool(
(ctx) => {
const defaultAccountId = ctx.agentAccountId;
return {
name: "feishu_drive",
label: "Feishu Drive",
description:
"Feishu cloud storage operations. Actions: list, info, create_folder, move, delete",
parameters: FeishuDriveSchema,
async execute(_toolCallId, params) {
const p = params as FeishuDriveExecuteParams;
try {
const client = createFeishuToolClient({
api,
executeParams: p,
defaultAccountId,
});
switch (p.action) {
case "list":
return 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:
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- exhaustive check fallback
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,131 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
import type { DynamicAgentCreationConfig } from "./types.js";
export type MaybeCreateDynamicAgentResult = {
created: boolean;
updatedCfg: OpenClawConfig;
agentId?: string;
};
/**
* Check if a dynamic agent should be created for a DM user and create it if needed.
* This creates a unique agent instance with its own workspace for each DM user.
*/
export async function maybeCreateDynamicAgent(params: {
cfg: OpenClawConfig;
runtime: PluginRuntime;
senderOpenId: string;
dynamicCfg: DynamicAgentCreationConfig;
log: (msg: string) => void;
}): Promise<MaybeCreateDynamicAgentResult> {
const { cfg, runtime, senderOpenId, dynamicCfg, log } = params;
// Check if there's already a binding for this user
const existingBindings = cfg.bindings ?? [];
const hasBinding = existingBindings.some(
(b) =>
b.match?.channel === "feishu" &&
b.match?.peer?.kind === "direct" &&
b.match?.peer?.id === senderOpenId,
);
if (hasBinding) {
return { created: false, updatedCfg: cfg };
}
// Check maxAgents limit if configured
if (dynamicCfg.maxAgents !== undefined) {
const feishuAgentCount = (cfg.agents?.list ?? []).filter((a) =>
a.id.startsWith("feishu-"),
).length;
if (feishuAgentCount >= dynamicCfg.maxAgents) {
log(
`feishu: maxAgents limit (${dynamicCfg.maxAgents}) reached, not creating agent for ${senderOpenId}`,
);
return { created: false, updatedCfg: cfg };
}
}
// Use full OpenID as agent ID suffix (OpenID format: ou_xxx is already filesystem-safe)
const agentId = `feishu-${senderOpenId}`;
// Check if agent already exists (but binding was missing)
const existingAgent = (cfg.agents?.list ?? []).find((a) => a.id === agentId);
if (existingAgent) {
// Agent exists but binding doesn't - just add the binding
log(`feishu: agent "${agentId}" exists, adding missing binding for ${senderOpenId}`);
const updatedCfg: OpenClawConfig = {
...cfg,
bindings: [
...existingBindings,
{
agentId,
match: {
channel: "feishu",
peer: { kind: "direct", id: senderOpenId },
},
},
],
};
await runtime.config.writeConfigFile(updatedCfg);
return { created: true, updatedCfg, agentId };
}
// Resolve path templates with substitutions
const workspaceTemplate = dynamicCfg.workspaceTemplate ?? "~/.openclaw/workspace-{agentId}";
const agentDirTemplate = dynamicCfg.agentDirTemplate ?? "~/.openclaw/agents/{agentId}/agent";
const workspace = resolveUserPath(
workspaceTemplate.replace("{userId}", senderOpenId).replace("{agentId}", agentId),
);
const agentDir = resolveUserPath(
agentDirTemplate.replace("{userId}", senderOpenId).replace("{agentId}", agentId),
);
log(`feishu: creating dynamic agent "${agentId}" for user ${senderOpenId}`);
log(` workspace: ${workspace}`);
log(` agentDir: ${agentDir}`);
// Create directories
await fs.promises.mkdir(workspace, { recursive: true });
await fs.promises.mkdir(agentDir, { recursive: true });
// Update configuration with new agent and binding
const updatedCfg: OpenClawConfig = {
...cfg,
agents: {
...cfg.agents,
list: [...(cfg.agents?.list ?? []), { id: agentId, workspace, agentDir }],
},
bindings: [
...existingBindings,
{
agentId,
match: {
channel: "feishu",
peer: { kind: "direct", id: senderOpenId },
},
},
],
};
// Write updated config using PluginRuntime API
await runtime.config.writeConfigFile(updatedCfg);
return { created: true, updatedCfg, agentId };
}
/**
* Resolve a path that may start with ~ to the user's home directory.
*/
function resolveUserPath(p: string): string {
if (p.startsWith("~/")) {
return path.join(os.homedir(), p.slice(2));
}
return p;
}

View File

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

View File

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

View File

@@ -0,0 +1,279 @@
import fs from "node:fs/promises";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { resolvePreferredOpenClawTmpDir } from "../../../src/infra/tmp-openclaw-dir.js";
const createFeishuClientMock = vi.hoisted(() => vi.fn());
const resolveFeishuAccountMock = vi.hoisted(() => vi.fn());
const normalizeFeishuTargetMock = vi.hoisted(() => vi.fn());
const resolveReceiveIdTypeMock = vi.hoisted(() => vi.fn());
const loadWebMediaMock = vi.hoisted(() => vi.fn());
const fileCreateMock = vi.hoisted(() => vi.fn());
const imageGetMock = vi.hoisted(() => vi.fn());
const messageCreateMock = vi.hoisted(() => vi.fn());
const messageResourceGetMock = vi.hoisted(() => vi.fn());
const messageReplyMock = vi.hoisted(() => vi.fn());
vi.mock("./client.js", () => ({
createFeishuClient: createFeishuClientMock,
}));
vi.mock("./accounts.js", () => ({
resolveFeishuAccount: resolveFeishuAccountMock,
}));
vi.mock("./targets.js", () => ({
normalizeFeishuTarget: normalizeFeishuTargetMock,
resolveReceiveIdType: resolveReceiveIdTypeMock,
}));
vi.mock("./runtime.js", () => ({
getFeishuRuntime: () => ({
media: {
loadWebMedia: loadWebMediaMock,
},
}),
}));
import { downloadImageFeishu, downloadMessageResourceFeishu, sendMediaFeishu } from "./media.js";
function expectPathIsolatedToTmpRoot(pathValue: string, key: string): void {
expect(pathValue).not.toContain(key);
expect(pathValue).not.toContain("..");
const tmpRoot = path.resolve(resolvePreferredOpenClawTmpDir());
const resolved = path.resolve(pathValue);
const rel = path.relative(tmpRoot, resolved);
expect(rel === ".." || rel.startsWith(`..${path.sep}`)).toBe(false);
}
describe("sendMediaFeishu msg_type routing", () => {
beforeEach(() => {
vi.clearAllMocks();
resolveFeishuAccountMock.mockReturnValue({
configured: true,
accountId: "main",
config: {},
appId: "app_id",
appSecret: "app_secret",
domain: "feishu",
});
normalizeFeishuTargetMock.mockReturnValue("ou_target");
resolveReceiveIdTypeMock.mockReturnValue("open_id");
createFeishuClientMock.mockReturnValue({
im: {
file: {
create: fileCreateMock,
},
image: {
get: imageGetMock,
},
message: {
create: messageCreateMock,
reply: messageReplyMock,
},
messageResource: {
get: messageResourceGetMock,
},
},
});
fileCreateMock.mockResolvedValue({
code: 0,
data: { file_key: "file_key_1" },
});
messageCreateMock.mockResolvedValue({
code: 0,
data: { message_id: "msg_1" },
});
messageReplyMock.mockResolvedValue({
code: 0,
data: { message_id: "reply_1" },
});
loadWebMediaMock.mockResolvedValue({
buffer: Buffer.from("remote-audio"),
fileName: "remote.opus",
kind: "audio",
contentType: "audio/ogg",
});
imageGetMock.mockResolvedValue(Buffer.from("image-bytes"));
messageResourceGetMock.mockResolvedValue(Buffer.from("resource-bytes"));
});
it("uses msg_type=media for mp4", async () => {
await sendMediaFeishu({
cfg: {} as any,
to: "user:ou_target",
mediaBuffer: Buffer.from("video"),
fileName: "clip.mp4",
});
expect(fileCreateMock).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ file_type: "mp4" }),
}),
);
expect(messageCreateMock).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ msg_type: "media" }),
}),
);
});
it("uses msg_type=media for opus", async () => {
await sendMediaFeishu({
cfg: {} as any,
to: "user:ou_target",
mediaBuffer: Buffer.from("audio"),
fileName: "voice.opus",
});
expect(fileCreateMock).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ file_type: "opus" }),
}),
);
expect(messageCreateMock).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ msg_type: "media" }),
}),
);
});
it("uses msg_type=file for documents", async () => {
await sendMediaFeishu({
cfg: {} as any,
to: "user:ou_target",
mediaBuffer: Buffer.from("doc"),
fileName: "paper.pdf",
});
expect(fileCreateMock).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ file_type: "pdf" }),
}),
);
expect(messageCreateMock).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ msg_type: "file" }),
}),
);
});
it("uses msg_type=media when replying with mp4", async () => {
await sendMediaFeishu({
cfg: {} as any,
to: "user:ou_target",
mediaBuffer: Buffer.from("video"),
fileName: "reply.mp4",
replyToMessageId: "om_parent",
});
expect(messageReplyMock).toHaveBeenCalledWith(
expect.objectContaining({
path: { message_id: "om_parent" },
data: expect.objectContaining({ msg_type: "media" }),
}),
);
expect(messageCreateMock).not.toHaveBeenCalled();
});
it("fails closed when media URL fetch is blocked", async () => {
loadWebMediaMock.mockRejectedValueOnce(
new Error("Blocked: resolves to private/internal IP address"),
);
await expect(
sendMediaFeishu({
cfg: {} as any,
to: "user:ou_target",
mediaUrl: "https://x/img",
fileName: "voice.opus",
}),
).rejects.toThrow(/private\/internal/i);
expect(fileCreateMock).not.toHaveBeenCalled();
expect(messageCreateMock).not.toHaveBeenCalled();
expect(messageReplyMock).not.toHaveBeenCalled();
});
it("uses isolated temp paths for image downloads", async () => {
const imageKey = "img_v3_01abc123";
let capturedPath: string | undefined;
imageGetMock.mockResolvedValueOnce({
writeFile: async (tmpPath: string) => {
capturedPath = tmpPath;
await fs.writeFile(tmpPath, Buffer.from("image-data"));
},
});
const result = await downloadImageFeishu({
cfg: {} as any,
imageKey,
});
expect(result.buffer).toEqual(Buffer.from("image-data"));
expect(capturedPath).toBeDefined();
expectPathIsolatedToTmpRoot(capturedPath as string, imageKey);
});
it("uses isolated temp paths for message resource downloads", async () => {
const fileKey = "file_v3_01abc123";
let capturedPath: string | undefined;
messageResourceGetMock.mockResolvedValueOnce({
writeFile: async (tmpPath: string) => {
capturedPath = tmpPath;
await fs.writeFile(tmpPath, Buffer.from("resource-data"));
},
});
const result = await downloadMessageResourceFeishu({
cfg: {} as any,
messageId: "om_123",
fileKey,
type: "image",
});
expect(result.buffer).toEqual(Buffer.from("resource-data"));
expect(capturedPath).toBeDefined();
expectPathIsolatedToTmpRoot(capturedPath as string, fileKey);
});
it("rejects invalid image keys before calling feishu api", async () => {
await expect(
downloadImageFeishu({
cfg: {} as any,
imageKey: "a/../../bad",
}),
).rejects.toThrow("invalid image_key");
expect(imageGetMock).not.toHaveBeenCalled();
});
it("rejects invalid file keys before calling feishu api", async () => {
await expect(
downloadMessageResourceFeishu({
cfg: {} as any,
messageId: "om_123",
fileKey: "x/../../bad",
type: "file",
}),
).rejects.toThrow("invalid file_key");
expect(messageResourceGetMock).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,441 @@
import fs from "fs";
import path from "path";
import { Readable } from "stream";
import { withTempDownloadPath, type ClawdbotConfig } from "openclaw/plugin-sdk";
import { resolveFeishuAccount } from "./accounts.js";
import { createFeishuClient } from "./client.js";
import { normalizeFeishuExternalKey } from "./external-keys.js";
import { getFeishuRuntime } from "./runtime.js";
import { assertFeishuMessageApiSuccess, toFeishuSendResult } from "./send-result.js";
import { resolveFeishuSendTarget } from "./send-target.js";
export type DownloadImageResult = {
buffer: Buffer;
contentType?: string;
};
export type DownloadMessageResourceResult = {
buffer: Buffer;
contentType?: string;
fileName?: string;
};
async function readFeishuResponseBuffer(params: {
response: unknown;
tmpDirPrefix: string;
errorPrefix: string;
}): Promise<Buffer> {
const { response } = params;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK response type
const responseAny = response as any;
if (responseAny.code !== undefined && responseAny.code !== 0) {
throw new Error(`${params.errorPrefix}: ${responseAny.msg || `code ${responseAny.code}`}`);
}
if (Buffer.isBuffer(response)) {
return response;
}
if (response instanceof ArrayBuffer) {
return Buffer.from(response);
}
if (responseAny.data && Buffer.isBuffer(responseAny.data)) {
return responseAny.data;
}
if (responseAny.data instanceof ArrayBuffer) {
return Buffer.from(responseAny.data);
}
if (typeof responseAny.getReadableStream === "function") {
const stream = responseAny.getReadableStream();
const chunks: Buffer[] = [];
for await (const chunk of stream) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
return Buffer.concat(chunks);
}
if (typeof responseAny.writeFile === "function") {
return await withTempDownloadPath({ prefix: params.tmpDirPrefix }, async (tmpPath) => {
await responseAny.writeFile(tmpPath);
return await fs.promises.readFile(tmpPath);
});
}
if (typeof responseAny[Symbol.asyncIterator] === "function") {
const chunks: Buffer[] = [];
for await (const chunk of responseAny) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
return Buffer.concat(chunks);
}
if (typeof responseAny.read === "function") {
const chunks: Buffer[] = [];
for await (const chunk of responseAny as Readable) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
return Buffer.concat(chunks);
}
const keys = Object.keys(responseAny);
const types = keys.map((k) => `${k}: ${typeof responseAny[k]}`).join(", ");
throw new Error(`${params.errorPrefix}: unexpected response format. Keys: [${types}]`);
}
/**
* Download an image from Feishu using image_key.
* Used for downloading images sent in messages.
*/
export async function downloadImageFeishu(params: {
cfg: ClawdbotConfig;
imageKey: string;
accountId?: string;
}): Promise<DownloadImageResult> {
const { cfg, imageKey, accountId } = params;
const normalizedImageKey = normalizeFeishuExternalKey(imageKey);
if (!normalizedImageKey) {
throw new Error("Feishu image download failed: invalid image_key");
}
const account = resolveFeishuAccount({ cfg, accountId });
if (!account.configured) {
throw new Error(`Feishu account "${account.accountId}" not configured`);
}
const client = createFeishuClient(account);
const response = await client.im.image.get({
path: { image_key: normalizedImageKey },
});
const buffer = await readFeishuResponseBuffer({
response,
tmpDirPrefix: "openclaw-feishu-img-",
errorPrefix: "Feishu image download failed",
});
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";
accountId?: string;
}): Promise<DownloadMessageResourceResult> {
const { cfg, messageId, fileKey, type, accountId } = params;
const normalizedFileKey = normalizeFeishuExternalKey(fileKey);
if (!normalizedFileKey) {
throw new Error("Feishu message resource download failed: invalid file_key");
}
const account = resolveFeishuAccount({ cfg, accountId });
if (!account.configured) {
throw new Error(`Feishu account "${account.accountId}" not configured`);
}
const client = createFeishuClient(account);
const response = await client.im.messageResource.get({
path: { message_id: messageId, file_key: normalizedFileKey },
params: { type },
});
const buffer = await readFeishuResponseBuffer({
response,
tmpDirPrefix: "openclaw-feishu-resource-",
errorPrefix: "Feishu message resource download failed",
});
return { buffer };
}
export type UploadImageResult = {
imageKey: string;
};
export type UploadFileResult = {
fileKey: string;
};
export type SendMediaResult = {
messageId: string;
chatId: string;
};
/**
* Upload an image to Feishu and get an image_key for sending.
* Supports: JPEG, PNG, WEBP, GIF, TIFF, BMP, ICO
*/
export async function uploadImageFeishu(params: {
cfg: ClawdbotConfig;
image: Buffer | string; // Buffer or file path
imageType?: "message" | "avatar";
accountId?: string;
}): Promise<UploadImageResult> {
const { cfg, image, imageType = "message", accountId } = params;
const account = resolveFeishuAccount({ cfg, accountId });
if (!account.configured) {
throw new Error(`Feishu account "${account.accountId}" not configured`);
}
const client = createFeishuClient(account);
// SDK accepts Buffer directly or fs.ReadStream for file paths
// Using Readable.from(buffer) causes issues with form-data library
// See: https://github.com/larksuite/node-sdk/issues/121
const imageData = typeof image === "string" ? fs.createReadStream(image) : image;
const response = await client.im.image.create({
data: {
image_type: imageType,
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK accepts Buffer or ReadStream
image: imageData as any,
},
});
// SDK v1.30+ returns data directly without code wrapper on success
// On error, it throws or returns { code, msg }
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK response type
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
accountId?: string;
}): Promise<UploadFileResult> {
const { cfg, file, fileName, fileType, duration, accountId } = params;
const account = resolveFeishuAccount({ cfg, accountId });
if (!account.configured) {
throw new Error(`Feishu account "${account.accountId}" not configured`);
}
const client = createFeishuClient(account);
// SDK accepts Buffer directly or fs.ReadStream for file paths
// Using Readable.from(buffer) causes issues with form-data library
// See: https://github.com/larksuite/node-sdk/issues/121
const fileData = typeof file === "string" ? fs.createReadStream(file) : file;
const response = await client.im.file.create({
data: {
file_type: fileType,
file_name: fileName,
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK accepts Buffer or ReadStream
file: fileData as any,
...(duration !== undefined && { duration }),
},
});
// SDK v1.30+ returns data directly without code wrapper on success
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK response type
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;
accountId?: string;
}): Promise<SendMediaResult> {
const { cfg, to, imageKey, replyToMessageId, accountId } = params;
const { client, receiveId, receiveIdType } = resolveFeishuSendTarget({
cfg,
to,
accountId,
});
const content = JSON.stringify({ image_key: imageKey });
if (replyToMessageId) {
const response = await client.im.message.reply({
path: { message_id: replyToMessageId },
data: {
content,
msg_type: "image",
},
});
assertFeishuMessageApiSuccess(response, "Feishu image reply failed");
return toFeishuSendResult(response, receiveId);
}
const response = await client.im.message.create({
params: { receive_id_type: receiveIdType },
data: {
receive_id: receiveId,
content,
msg_type: "image",
},
});
assertFeishuMessageApiSuccess(response, "Feishu image send failed");
return toFeishuSendResult(response, receiveId);
}
/**
* Send a file message using a file_key
*/
export async function sendFileFeishu(params: {
cfg: ClawdbotConfig;
to: string;
fileKey: string;
/** Use "media" for audio/video files, "file" for documents */
msgType?: "file" | "media";
replyToMessageId?: string;
accountId?: string;
}): Promise<SendMediaResult> {
const { cfg, to, fileKey, replyToMessageId, accountId } = params;
const msgType = params.msgType ?? "file";
const { client, receiveId, receiveIdType } = resolveFeishuSendTarget({
cfg,
to,
accountId,
});
const content = JSON.stringify({ file_key: fileKey });
if (replyToMessageId) {
const response = await client.im.message.reply({
path: { message_id: replyToMessageId },
data: {
content,
msg_type: msgType,
},
});
assertFeishuMessageApiSuccess(response, "Feishu file reply failed");
return toFeishuSendResult(response, receiveId);
}
const response = await client.im.message.create({
params: { receive_id_type: receiveIdType },
data: {
receive_id: receiveId,
content,
msg_type: msgType,
},
});
assertFeishuMessageApiSuccess(response, "Feishu file send failed");
return toFeishuSendResult(response, receiveId);
}
/**
* Helper to detect file type from extension
*/
export function detectFileType(
fileName: string,
): "opus" | "mp4" | "pdf" | "doc" | "xls" | "ppt" | "stream" {
const ext = 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";
}
}
/**
* 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;
accountId?: string;
}): Promise<SendMediaResult> {
const { cfg, to, mediaUrl, mediaBuffer, fileName, replyToMessageId, accountId } = params;
const account = resolveFeishuAccount({ cfg, accountId });
if (!account.configured) {
throw new Error(`Feishu account "${account.accountId}" not configured`);
}
const mediaMaxBytes = (account.config?.mediaMaxMb ?? 30) * 1024 * 1024;
let buffer: Buffer;
let name: string;
if (mediaBuffer) {
buffer = mediaBuffer;
name = fileName ?? "file";
} else if (mediaUrl) {
const loaded = await getFeishuRuntime().media.loadWebMedia(mediaUrl, {
maxBytes: mediaMaxBytes,
optimizeImages: false,
});
buffer = loaded.buffer;
name = fileName ?? loaded.fileName ?? "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, accountId });
return sendImageFeishu({ cfg, to, imageKey, replyToMessageId, accountId });
} else {
const fileType = detectFileType(name);
const { fileKey } = await uploadFileFeishu({
cfg,
file: buffer,
fileName: name,
fileType,
accountId,
});
// Feishu requires msg_type "media" for audio/video, "file" for documents
const isMedia = fileType === "mp4" || fileType === "opus";
return sendFileFeishu({
cfg,
to,
fileKey,
msgType: isMedia ? "media" : "file",
replyToMessageId,
accountId,
});
}
}

View File

@@ -0,0 +1,133 @@
import type { FeishuMessageEvent } from "./bot.js";
/**
* Escape regex metacharacters so user-controlled mention fields are treated literally.
*/
export function escapeRegExp(input: string): string {
return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
/**
* 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(escapeRegExp(key), "g"), "");
}
return result.replace(/\s+/g, " ").trim();
}
/**
* Format @mention for text message
*/
export function formatMentionForText(target: MentionTarget): string {
return `<at user_id="${target.openId}">${target.name}</at>`;
}
/**
* Format @everyone for text message
*/
export function formatMentionAllForText(): string {
return `<at user_id="all">Everyone</at>`;
}
/**
* Format @mention for card message (lark_md)
*/
export function formatMentionForCard(target: MentionTarget): string {
return `<at id=${target.openId}></at>`;
}
/**
* Format @everyone for card message
*/
export function formatMentionAllForCard(): string {
return `<at id=all></at>`;
}
/**
* Build complete message with @mentions (text format)
*/
export function buildMentionedMessage(targets: MentionTarget[], message: string): string {
if (targets.length === 0) {
return message;
}
const mentionParts = targets.map((t) => formatMentionForText(t));
return `${mentionParts.join(" ")} ${message}`;
}
/**
* Build card content with @mentions (Markdown format)
*/
export function buildMentionedCardContent(targets: MentionTarget[], message: string): string {
if (targets.length === 0) {
return message;
}
const mentionParts = targets.map((t) => formatMentionForCard(t));
return `${mentionParts.join(" ")} ${message}`;
}

View File

@@ -0,0 +1,397 @@
import * as http from "http";
import * as Lark from "@larksuiteoapi/node-sdk";
import {
type ClawdbotConfig,
type RuntimeEnv,
type HistoryEntry,
installRequestBodyLimitGuard,
} from "openclaw/plugin-sdk";
import { resolveFeishuAccount, listEnabledFeishuAccounts } from "./accounts.js";
import { handleFeishuMessage, type FeishuMessageEvent, type FeishuBotAddedEvent } from "./bot.js";
import { createFeishuWSClient, createEventDispatcher } from "./client.js";
import { probeFeishu } from "./probe.js";
import type { ResolvedFeishuAccount } from "./types.js";
export type MonitorFeishuOpts = {
config?: ClawdbotConfig;
runtime?: RuntimeEnv;
abortSignal?: AbortSignal;
accountId?: string;
};
// Per-account WebSocket clients, HTTP servers, and bot info
const wsClients = new Map<string, Lark.WSClient>();
const httpServers = new Map<string, http.Server>();
const botOpenIds = new Map<string, string>();
const FEISHU_WEBHOOK_MAX_BODY_BYTES = 1024 * 1024;
const FEISHU_WEBHOOK_BODY_TIMEOUT_MS = 30_000;
const FEISHU_WEBHOOK_RATE_LIMIT_WINDOW_MS = 60_000;
const FEISHU_WEBHOOK_RATE_LIMIT_MAX_REQUESTS = 120;
const FEISHU_WEBHOOK_COUNTER_LOG_EVERY = 25;
const feishuWebhookRateLimits = new Map<string, { count: number; windowStartMs: number }>();
const feishuWebhookStatusCounters = new Map<string, number>();
function isJsonContentType(value: string | string[] | undefined): boolean {
const first = Array.isArray(value) ? value[0] : value;
if (!first) {
return false;
}
const mediaType = first.split(";", 1)[0]?.trim().toLowerCase();
return mediaType === "application/json" || Boolean(mediaType?.endsWith("+json"));
}
function isWebhookRateLimited(key: string, nowMs: number): boolean {
const state = feishuWebhookRateLimits.get(key);
if (!state || nowMs - state.windowStartMs >= FEISHU_WEBHOOK_RATE_LIMIT_WINDOW_MS) {
feishuWebhookRateLimits.set(key, { count: 1, windowStartMs: nowMs });
return false;
}
state.count += 1;
if (state.count > FEISHU_WEBHOOK_RATE_LIMIT_MAX_REQUESTS) {
return true;
}
return false;
}
function recordWebhookStatus(
runtime: RuntimeEnv | undefined,
accountId: string,
path: string,
statusCode: number,
): void {
if (![400, 401, 408, 413, 415, 429].includes(statusCode)) {
return;
}
const key = `${accountId}:${path}:${statusCode}`;
const next = (feishuWebhookStatusCounters.get(key) ?? 0) + 1;
feishuWebhookStatusCounters.set(key, next);
if (next === 1 || next % FEISHU_WEBHOOK_COUNTER_LOG_EVERY === 0) {
const log = runtime?.log ?? console.log;
log(`feishu[${accountId}]: webhook anomaly path=${path} status=${statusCode} count=${next}`);
}
}
async function fetchBotOpenId(account: ResolvedFeishuAccount): Promise<string | undefined> {
try {
const result = await probeFeishu(account);
return result.ok ? result.botOpenId : undefined;
} catch {
return undefined;
}
}
/**
* Register common event handlers on an EventDispatcher.
* When fireAndForget is true (webhook mode), message handling is not awaited
* to avoid blocking the HTTP response (Lark requires <3s response).
*/
function registerEventHandlers(
eventDispatcher: Lark.EventDispatcher,
context: {
cfg: ClawdbotConfig;
accountId: string;
runtime?: RuntimeEnv;
chatHistories: Map<string, HistoryEntry[]>;
fireAndForget?: boolean;
},
) {
const { cfg, accountId, runtime, chatHistories, fireAndForget } = context;
const log = runtime?.log ?? console.log;
const error = runtime?.error ?? console.error;
eventDispatcher.register({
"im.message.receive_v1": async (data) => {
try {
const event = data as unknown as FeishuMessageEvent;
const promise = handleFeishuMessage({
cfg,
event,
botOpenId: botOpenIds.get(accountId),
runtime,
chatHistories,
accountId,
});
if (fireAndForget) {
promise.catch((err) => {
error(`feishu[${accountId}]: error handling message: ${String(err)}`);
});
} else {
await promise;
}
} catch (err) {
error(`feishu[${accountId}]: error handling message: ${String(err)}`);
}
},
"im.message.message_read_v1": async () => {
// Ignore read receipts
},
"im.chat.member.bot.added_v1": async (data) => {
try {
const event = data as unknown as FeishuBotAddedEvent;
log(`feishu[${accountId}]: bot added to chat ${event.chat_id}`);
} catch (err) {
error(`feishu[${accountId}]: error handling bot added event: ${String(err)}`);
}
},
"im.chat.member.bot.deleted_v1": async (data) => {
try {
const event = data as unknown as { chat_id: string };
log(`feishu[${accountId}]: bot removed from chat ${event.chat_id}`);
} catch (err) {
error(`feishu[${accountId}]: error handling bot removed event: ${String(err)}`);
}
},
});
}
type MonitorAccountParams = {
cfg: ClawdbotConfig;
account: ResolvedFeishuAccount;
runtime?: RuntimeEnv;
abortSignal?: AbortSignal;
};
/**
* Monitor a single Feishu account.
*/
async function monitorSingleAccount(params: MonitorAccountParams): Promise<void> {
const { cfg, account, runtime, abortSignal } = params;
const { accountId } = account;
const log = runtime?.log ?? console.log;
// Fetch bot open_id
const botOpenId = await fetchBotOpenId(account);
botOpenIds.set(accountId, botOpenId ?? "");
log(`feishu[${accountId}]: bot open_id resolved: ${botOpenId ?? "unknown"}`);
const connectionMode = account.config.connectionMode ?? "websocket";
if (connectionMode === "webhook" && !account.verificationToken?.trim()) {
throw new Error(`Feishu account "${accountId}" webhook mode requires verificationToken`);
}
const eventDispatcher = createEventDispatcher(account);
const chatHistories = new Map<string, HistoryEntry[]>();
registerEventHandlers(eventDispatcher, {
cfg,
accountId,
runtime,
chatHistories,
fireAndForget: connectionMode === "webhook",
});
if (connectionMode === "webhook") {
return monitorWebhook({ params, accountId, eventDispatcher });
}
return monitorWebSocket({ params, accountId, eventDispatcher });
}
type ConnectionParams = {
params: MonitorAccountParams;
accountId: string;
eventDispatcher: Lark.EventDispatcher;
};
async function monitorWebSocket({
params,
accountId,
eventDispatcher,
}: ConnectionParams): Promise<void> {
const { account, runtime, abortSignal } = params;
const log = runtime?.log ?? console.log;
const error = runtime?.error ?? console.error;
log(`feishu[${accountId}]: starting WebSocket connection...`);
const wsClient = createFeishuWSClient(account);
wsClients.set(accountId, wsClient);
return new Promise((resolve, reject) => {
const cleanup = () => {
wsClients.delete(accountId);
botOpenIds.delete(accountId);
};
const handleAbort = () => {
log(`feishu[${accountId}]: abort signal received, stopping`);
cleanup();
resolve();
};
if (abortSignal?.aborted) {
cleanup();
resolve();
return;
}
abortSignal?.addEventListener("abort", handleAbort, { once: true });
try {
wsClient.start({ eventDispatcher });
log(`feishu[${accountId}]: WebSocket client started`);
} catch (err) {
cleanup();
abortSignal?.removeEventListener("abort", handleAbort);
reject(err);
}
});
}
async function monitorWebhook({
params,
accountId,
eventDispatcher,
}: ConnectionParams): Promise<void> {
const { account, runtime, abortSignal } = params;
const log = runtime?.log ?? console.log;
const error = runtime?.error ?? console.error;
const port = account.config.webhookPort ?? 3000;
const path = account.config.webhookPath ?? "/feishu/events";
const host = account.config.webhookHost ?? "127.0.0.1";
log(`feishu[${accountId}]: starting Webhook server on ${host}:${port}, path ${path}...`);
const server = http.createServer();
const webhookHandler = Lark.adaptDefault(path, eventDispatcher, { autoChallenge: true });
server.on("request", (req, res) => {
res.on("finish", () => {
recordWebhookStatus(runtime, accountId, path, res.statusCode);
});
const rateLimitKey = `${accountId}:${path}:${req.socket.remoteAddress ?? "unknown"}`;
if (isWebhookRateLimited(rateLimitKey, Date.now())) {
res.statusCode = 429;
res.end("Too Many Requests");
return;
}
if (req.method === "POST" && !isJsonContentType(req.headers["content-type"])) {
res.statusCode = 415;
res.end("Unsupported Media Type");
return;
}
const guard = installRequestBodyLimitGuard(req, res, {
maxBytes: FEISHU_WEBHOOK_MAX_BODY_BYTES,
timeoutMs: FEISHU_WEBHOOK_BODY_TIMEOUT_MS,
responseFormat: "text",
});
if (guard.isTripped()) {
return;
}
void Promise.resolve(webhookHandler(req, res))
.catch((err) => {
if (!guard.isTripped()) {
error(`feishu[${accountId}]: webhook handler error: ${String(err)}`);
}
})
.finally(() => {
guard.dispose();
});
});
httpServers.set(accountId, server);
return new Promise((resolve, reject) => {
const cleanup = () => {
server.close();
httpServers.delete(accountId);
botOpenIds.delete(accountId);
};
const handleAbort = () => {
log(`feishu[${accountId}]: abort signal received, stopping Webhook server`);
cleanup();
resolve();
};
if (abortSignal?.aborted) {
cleanup();
resolve();
return;
}
abortSignal?.addEventListener("abort", handleAbort, { once: true });
server.listen(port, host, () => {
log(`feishu[${accountId}]: Webhook server listening on ${host}:${port}`);
});
server.on("error", (err) => {
error(`feishu[${accountId}]: Webhook server error: ${err}`);
abortSignal?.removeEventListener("abort", handleAbort);
reject(err);
});
});
}
/**
* Main entry: start monitoring for all enabled accounts.
*/
export async function monitorFeishuProvider(opts: MonitorFeishuOpts = {}): Promise<void> {
const cfg = opts.config;
if (!cfg) {
throw new Error("Config is required for Feishu monitor");
}
const log = opts.runtime?.log ?? console.log;
// If accountId is specified, only monitor that account
if (opts.accountId) {
const account = resolveFeishuAccount({ cfg, accountId: opts.accountId });
if (!account.enabled || !account.configured) {
throw new Error(`Feishu account "${opts.accountId}" not configured or disabled`);
}
return monitorSingleAccount({
cfg,
account,
runtime: opts.runtime,
abortSignal: opts.abortSignal,
});
}
// Otherwise, start all enabled accounts
const accounts = listEnabledFeishuAccounts(cfg);
if (accounts.length === 0) {
throw new Error("No enabled Feishu accounts configured");
}
log(
`feishu: starting ${accounts.length} account(s): ${accounts.map((a) => a.accountId).join(", ")}`,
);
// Start all accounts in parallel
await Promise.all(
accounts.map((account) =>
monitorSingleAccount({
cfg,
account,
runtime: opts.runtime,
abortSignal: opts.abortSignal,
}),
),
);
}
/**
* Stop monitoring for a specific account or all accounts.
*/
export function stopFeishuMonitor(accountId?: string): void {
if (accountId) {
wsClients.delete(accountId);
const server = httpServers.get(accountId);
if (server) {
server.close();
httpServers.delete(accountId);
}
botOpenIds.delete(accountId);
} else {
wsClients.clear();
for (const server of httpServers.values()) {
server.close();
}
httpServers.clear();
botOpenIds.clear();
}
}

View File

@@ -0,0 +1,183 @@
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import type { ClawdbotConfig } from "openclaw/plugin-sdk";
import { afterEach, describe, expect, it, vi } from "vitest";
const probeFeishuMock = vi.hoisted(() => vi.fn());
vi.mock("@larksuiteoapi/node-sdk", () => ({
adaptDefault: vi.fn(
() => (_req: unknown, res: { statusCode?: number; end: (s: string) => void }) => {
res.statusCode = 200;
res.end("ok");
},
),
}));
vi.mock("./probe.js", () => ({
probeFeishu: probeFeishuMock,
}));
vi.mock("./client.js", () => ({
createFeishuWSClient: vi.fn(() => ({ start: vi.fn() })),
createEventDispatcher: vi.fn(() => ({ register: vi.fn() })),
}));
import { monitorFeishuProvider, stopFeishuMonitor } from "./monitor.js";
async function getFreePort(): Promise<number> {
const server = createServer();
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", () => resolve()));
const address = server.address() as AddressInfo | null;
if (!address) {
throw new Error("missing server address");
}
await new Promise<void>((resolve) => server.close(() => resolve()));
return address.port;
}
async function waitUntilServerReady(url: string): Promise<void> {
for (let i = 0; i < 50; i += 1) {
try {
const response = await fetch(url, { method: "GET" });
if (response.status >= 200 && response.status < 500) {
return;
}
} catch {
// retry
}
await new Promise((resolve) => setTimeout(resolve, 20));
}
throw new Error(`server did not start: ${url}`);
}
function buildConfig(params: {
accountId: string;
path: string;
port: number;
verificationToken?: string;
}): ClawdbotConfig {
return {
channels: {
feishu: {
enabled: true,
accounts: {
[params.accountId]: {
enabled: true,
appId: "cli_test",
appSecret: "secret_test",
connectionMode: "webhook",
webhookHost: "127.0.0.1",
webhookPort: params.port,
webhookPath: params.path,
verificationToken: params.verificationToken,
},
},
},
},
} as ClawdbotConfig;
}
async function withRunningWebhookMonitor(
params: {
accountId: string;
path: string;
verificationToken: string;
},
run: (url: string) => Promise<void>,
) {
const port = await getFreePort();
const cfg = buildConfig({
accountId: params.accountId,
path: params.path,
port,
verificationToken: params.verificationToken,
});
const abortController = new AbortController();
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
const monitorPromise = monitorFeishuProvider({
config: cfg,
runtime,
abortSignal: abortController.signal,
});
const url = `http://127.0.0.1:${port}${params.path}`;
await waitUntilServerReady(url);
try {
await run(url);
} finally {
abortController.abort();
await monitorPromise;
}
}
afterEach(() => {
stopFeishuMonitor();
});
describe("Feishu webhook security hardening", () => {
it("rejects webhook mode without verificationToken", async () => {
probeFeishuMock.mockResolvedValue({ ok: true, botOpenId: "bot_open_id" });
const cfg = buildConfig({
accountId: "missing-token",
path: "/hook-missing-token",
port: await getFreePort(),
});
await expect(monitorFeishuProvider({ config: cfg })).rejects.toThrow(
/requires verificationToken/i,
);
});
it("returns 415 for POST requests without json content type", async () => {
probeFeishuMock.mockResolvedValue({ ok: true, botOpenId: "bot_open_id" });
await withRunningWebhookMonitor(
{
accountId: "content-type",
path: "/hook-content-type",
verificationToken: "verify_token",
},
async (url) => {
const response = await fetch(url, {
method: "POST",
headers: { "content-type": "text/plain" },
body: "{}",
});
expect(response.status).toBe(415);
expect(await response.text()).toBe("Unsupported Media Type");
},
);
});
it("rate limits webhook burst traffic with 429", async () => {
probeFeishuMock.mockResolvedValue({ ok: true, botOpenId: "bot_open_id" });
await withRunningWebhookMonitor(
{
accountId: "rate-limit",
path: "/hook-rate-limit",
verificationToken: "verify_token",
},
async (url) => {
let saw429 = false;
for (let i = 0; i < 130; i += 1) {
const response = await fetch(url, {
method: "POST",
headers: { "content-type": "text/plain" },
body: "{}",
});
if (response.status === 429) {
saw429 = true;
expect(await response.text()).toBe("Too Many Requests");
break;
}
}
expect(saw429).toBe(true);
},
);
});
});

View File

@@ -0,0 +1,351 @@
import type {
ChannelOnboardingAdapter,
ChannelOnboardingDmPolicy,
ClawdbotConfig,
DmPolicy,
WizardPrompter,
} from "openclaw/plugin-sdk";
import { addWildcardAllowFrom, DEFAULT_ACCOUNT_ID, formatDocsLink } from "openclaw/plugin-sdk";
import { resolveFeishuCredentials } from "./accounts.js";
import { probeFeishu } from "./probe.js";
import type { FeishuConfig } from "./types.js";
const channel = "feishu" as const;
function setFeishuDmPolicy(cfg: ClawdbotConfig, dmPolicy: DmPolicy): ClawdbotConfig {
const allowFrom =
dmPolicy === "open"
? addWildcardAllowFrom(cfg.channels?.feishu?.allowFrom)?.map((entry) => String(entry))
: undefined;
return {
...cfg,
channels: {
...cfg.channels,
feishu: {
...cfg.channels?.feishu,
dmPolicy,
...(allowFrom ? { allowFrom } : {}),
},
},
};
}
function setFeishuAllowFrom(cfg: ClawdbotConfig, allowFrom: string[]): ClawdbotConfig {
return {
...cfg,
channels: {
...cfg.channels,
feishu: {
...cfg.channels?.feishu,
allowFrom,
},
},
};
}
function parseAllowFromInput(raw: string): string[] {
return raw
.split(/[\n,;]+/g)
.map((entry) => entry.trim())
.filter(Boolean);
}
async function promptFeishuAllowFrom(params: {
cfg: ClawdbotConfig;
prompter: WizardPrompter;
}): Promise<ClawdbotConfig> {
const existing = params.cfg.channels?.feishu?.allowFrom ?? [];
await params.prompter.note(
[
"Allowlist Feishu DMs by open_id or user_id.",
"You can find user open_id in Feishu admin console or via API.",
"Examples:",
"- ou_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"- on_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
].join("\n"),
"Feishu allowlist",
);
while (true) {
const entry = await params.prompter.text({
message: "Feishu allowFrom (user open_ids)",
placeholder: "ou_xxxxx, ou_yyyyy",
initialValue: existing[0] ? String(existing[0]) : undefined,
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
});
const parts = parseAllowFromInput(String(entry));
if (parts.length === 0) {
await params.prompter.note("Enter at least one user.", "Feishu allowlist");
continue;
}
const unique = [
...new Set([
...existing.map((v: string | number) => 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",
);
}
async function promptFeishuCredentials(prompter: WizardPrompter): Promise<{
appId: string;
appSecret: string;
}> {
const appId = String(
await prompter.text({
message: "Enter Feishu App ID",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
const appSecret = String(
await prompter.text({
message: "Enter Feishu App Secret",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
return { appId, appSecret };
}
function setFeishuGroupPolicy(
cfg: ClawdbotConfig,
groupPolicy: "open" | "allowlist" | "disabled",
): ClawdbotConfig {
return {
...cfg,
channels: {
...cfg.channels,
feishu: {
...cfg.channels?.feishu,
enabled: true,
groupPolicy,
},
},
};
}
function setFeishuGroupAllowFrom(cfg: ClawdbotConfig, groupAllowFrom: string[]): ClawdbotConfig {
return {
...cfg,
channels: {
...cfg.channels,
feishu: {
...cfg.channels?.feishu,
groupAllowFrom,
},
},
};
}
const dmPolicy: ChannelOnboardingDmPolicy = {
label: "Feishu",
channel,
policyKey: "channels.feishu.dmPolicy",
allowFromKey: "channels.feishu.allowFrom",
getCurrent: (cfg) => (cfg.channels?.feishu as FeishuConfig | undefined)?.dmPolicy ?? "pairing",
setPolicy: (cfg, policy) => setFeishuDmPolicy(cfg, policy),
promptAllowFrom: promptFeishuAllowFrom,
};
export const feishuOnboardingAdapter: ChannelOnboardingAdapter = {
channel,
getStatus: async ({ cfg }) => {
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
const configured = Boolean(resolveFeishuCredentials(feishuCfg));
// Try to probe if configured
let probeResult = null;
if (configured && feishuCfg) {
try {
probeResult = await probeFeishu(feishuCfg);
} catch {
// Ignore probe errors
}
}
const statusLines: string[] = [];
if (!configured) {
statusLines.push("Feishu: needs app credentials");
} else if (probeResult?.ok) {
statusLines.push(
`Feishu: connected as ${probeResult.botName ?? probeResult.botOpenId ?? "bot"}`,
);
} else {
statusLines.push("Feishu: configured (connection not verified)");
}
return {
channel,
configured,
statusLines,
selectionHint: configured ? "configured" : "needs app creds",
quickstartScore: configured ? 2 : 0,
};
},
configure: async ({ cfg, prompter }) => {
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
const resolved = resolveFeishuCredentials(feishuCfg);
const hasConfigCreds = Boolean(feishuCfg?.appId?.trim() && feishuCfg?.appSecret?.trim());
const canUseEnv = Boolean(
!hasConfigCreds && process.env.FEISHU_APP_ID?.trim() && process.env.FEISHU_APP_SECRET?.trim(),
);
let next = cfg;
let appId: string | null = null;
let appSecret: string | null = null;
if (!resolved) {
await noteFeishuCredentialHelp(prompter);
}
if (canUseEnv) {
const keepEnv = await prompter.confirm({
message: "FEISHU_APP_ID + FEISHU_APP_SECRET detected. Use env vars?",
initialValue: true,
});
if (keepEnv) {
next = {
...next,
channels: {
...next.channels,
feishu: { ...next.channels?.feishu, enabled: true },
},
};
} else {
const entered = await promptFeishuCredentials(prompter);
appId = entered.appId;
appSecret = entered.appSecret;
}
} else if (hasConfigCreds) {
const keep = await prompter.confirm({
message: "Feishu credentials already configured. Keep them?",
initialValue: true,
});
if (!keep) {
const entered = await promptFeishuCredentials(prompter);
appId = entered.appId;
appSecret = entered.appSecret;
}
} else {
const entered = await promptFeishuCredentials(prompter);
appId = entered.appId;
appSecret = entered.appSecret;
}
if (appId && appSecret) {
next = {
...next,
channels: {
...next.channels,
feishu: {
...next.channels?.feishu,
enabled: true,
appId,
appSecret,
},
},
};
// Test connection
const testCfg = next.channels?.feishu as FeishuConfig;
try {
const probe = await probeFeishu(testCfg);
if (probe.ok) {
await prompter.note(
`Connected as ${probe.botName ?? probe.botOpenId ?? "bot"}`,
"Feishu connection test",
);
} else {
await prompter.note(
`Connection failed: ${probe.error ?? "unknown error"}`,
"Feishu connection test",
);
}
} catch (err) {
await prompter.note(`Connection test failed: ${String(err)}`, "Feishu connection test");
}
}
// Domain selection
const currentDomain = (next.channels?.feishu as FeishuConfig | undefined)?.domain ?? "feishu";
const domain = await prompter.select({
message: "Which Feishu domain?",
options: [
{ value: "feishu", label: "Feishu (feishu.cn) - China" },
{ value: "lark", label: "Lark (larksuite.com) - International" },
],
initialValue: currentDomain,
});
if (domain) {
next = {
...next,
channels: {
...next.channels,
feishu: {
...next.channels?.feishu,
domain: domain as "feishu" | "lark",
},
},
};
}
// Group policy
const groupPolicy = await prompter.select({
message: "Group chat policy",
options: [
{ value: "allowlist", label: "Allowlist - only respond in specific groups" },
{ value: "open", label: "Open - respond in all groups (requires mention)" },
{ value: "disabled", label: "Disabled - don't respond in groups" },
],
initialValue: (next.channels?.feishu as FeishuConfig | undefined)?.groupPolicy ?? "allowlist",
});
if (groupPolicy) {
next = setFeishuGroupPolicy(next, groupPolicy as "open" | "allowlist" | "disabled");
}
// Group allowlist if needed
if (groupPolicy === "allowlist") {
const existing = (next.channels?.feishu as FeishuConfig | undefined)?.groupAllowFrom ?? [];
const entry = await prompter.text({
message: "Group chat allowlist (chat_ids)",
placeholder: "oc_xxxxx, oc_yyyyy",
initialValue: existing.length > 0 ? existing.map(String).join(", ") : undefined,
});
if (entry) {
const parts = parseAllowFromInput(String(entry));
if (parts.length > 0) {
next = setFeishuGroupAllowFrom(next, parts);
}
}
}
return { cfg: next, accountId: DEFAULT_ACCOUNT_ID };
},
dmPolicy,
disable: (cfg) => ({
...cfg,
channels: {
...cfg.channels,
feishu: { ...cfg.channels?.feishu, enabled: false },
},
}),
};

View File

@@ -0,0 +1,55 @@
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, accountId }) => {
const result = await sendMessageFeishu({ cfg, to, text, accountId: accountId ?? undefined });
return { channel: "feishu", ...result };
},
sendMedia: async ({ cfg, to, text, mediaUrl, accountId }) => {
// Send text first if provided
if (text?.trim()) {
await sendMessageFeishu({ cfg, to, text, accountId: accountId ?? undefined });
}
// Upload and send media if URL provided
if (mediaUrl) {
try {
const result = await sendMediaFeishu({
cfg,
to,
mediaUrl,
accountId: accountId ?? undefined,
});
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,
accountId: accountId ?? undefined,
});
return { channel: "feishu", ...result };
}
}
// No media URL, just return text result
const result = await sendMessageFeishu({
cfg,
to,
text: text ?? "",
accountId: accountId ?? undefined,
});
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,180 @@
import type * as Lark from "@larksuiteoapi/node-sdk";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { listEnabledFeishuAccounts } from "./accounts.js";
import { FeishuPermSchema, type FeishuPermParams } from "./perm-schema.js";
import { createFeishuToolClient, resolveAnyEnabledFeishuToolsConfig } from "./tool-account.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) {
if (!api.config) {
api.logger.debug?.("feishu_perm: No config available, skipping perm tools");
return;
}
const accounts = listEnabledFeishuAccounts(api.config);
if (accounts.length === 0) {
api.logger.debug?.("feishu_perm: No Feishu accounts configured, skipping perm tools");
return;
}
const toolsCfg = resolveAnyEnabledFeishuToolsConfig(accounts);
if (!toolsCfg.perm) {
api.logger.debug?.("feishu_perm: perm tool disabled in config (default: false)");
return;
}
type FeishuPermExecuteParams = FeishuPermParams & { accountId?: string };
api.registerTool(
(ctx) => {
const defaultAccountId = ctx.agentAccountId;
return {
name: "feishu_perm",
label: "Feishu Perm",
description: "Feishu permission management. Actions: list, add, remove",
parameters: FeishuPermSchema,
async execute(_toolCallId, params) {
const p = params as FeishuPermExecuteParams;
try {
const client = createFeishuToolClient({
api,
executeParams: p,
defaultAccountId,
});
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:
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- exhaustive check fallback
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,59 @@
import { describe, expect, it } from "vitest";
import { isFeishuGroupAllowed, resolveFeishuAllowlistMatch } from "./policy.js";
describe("feishu policy", () => {
describe("resolveFeishuAllowlistMatch", () => {
it("allows wildcard", () => {
expect(
resolveFeishuAllowlistMatch({
allowFrom: ["*"],
senderId: "ou-attacker",
}),
).toEqual({ allowed: true, matchKey: "*", matchSource: "wildcard" });
});
it("matches normalized ID entries", () => {
expect(
resolveFeishuAllowlistMatch({
allowFrom: ["feishu:user:OU_ALLOWED"],
senderId: "ou_allowed",
}),
).toEqual({ allowed: true, matchKey: "ou_allowed", matchSource: "id" });
});
it("supports user_id as an additional immutable sender candidate", () => {
expect(
resolveFeishuAllowlistMatch({
allowFrom: ["on_user_123"],
senderId: "ou_other",
senderIds: ["on_user_123"],
}),
).toEqual({ allowed: true, matchKey: "on_user_123", matchSource: "id" });
});
it("does not authorize based on display-name collision", () => {
const victimOpenId = "ou_4f4ec5aa111122223333444455556666";
expect(
resolveFeishuAllowlistMatch({
allowFrom: [victimOpenId],
senderId: "ou_attacker_real_open_id",
senderIds: ["on_attacker_user_id"],
senderName: victimOpenId,
}),
).toEqual({ allowed: false });
});
});
describe("isFeishuGroupAllowed", () => {
it("matches group IDs with chat: prefix", () => {
expect(
isFeishuGroupAllowed({
groupPolicy: "allowlist",
allowFrom: ["chat:oc_group_123"],
senderId: "oc_group_123",
}),
).toBe(true);
});
});
});

View File

@@ -0,0 +1,120 @@
import type {
AllowlistMatch,
ChannelGroupContext,
GroupToolPolicyConfig,
} from "openclaw/plugin-sdk";
import { normalizeFeishuTarget } from "./targets.js";
import type { FeishuConfig, FeishuGroupConfig } from "./types.js";
export type FeishuAllowlistMatch = AllowlistMatch<"wildcard" | "id">;
function normalizeFeishuAllowEntry(raw: string): string {
const trimmed = raw.trim();
if (!trimmed) {
return "";
}
if (trimmed === "*") {
return "*";
}
const withoutProviderPrefix = trimmed.replace(/^feishu:/i, "");
const normalized = normalizeFeishuTarget(withoutProviderPrefix) ?? withoutProviderPrefix;
return normalized.trim().toLowerCase();
}
export function resolveFeishuAllowlistMatch(params: {
allowFrom: Array<string | number>;
senderId: string;
senderIds?: Array<string | null | undefined>;
senderName?: string | null;
}): FeishuAllowlistMatch {
const allowFrom = params.allowFrom
.map((entry) => normalizeFeishuAllowEntry(String(entry)))
.filter(Boolean);
if (allowFrom.length === 0) {
return { allowed: false };
}
if (allowFrom.includes("*")) {
return { allowed: true, matchKey: "*", matchSource: "wildcard" };
}
// Feishu allowlists are ID-based; mutable display names must never grant access.
const senderCandidates = [params.senderId, ...(params.senderIds ?? [])]
.map((entry) => normalizeFeishuAllowEntry(String(entry ?? "")))
.filter(Boolean);
for (const senderId of senderCandidates) {
if (allowFrom.includes(senderId)) {
return { allowed: true, matchKey: senderId, matchSource: "id" };
}
}
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];
if (direct) {
return direct;
}
const lowered = groupId.toLowerCase();
const matchKey = Object.keys(groups).find((key) => key.toLowerCase() === lowered);
return matchKey ? groups[matchKey] : 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;
senderIds?: Array<string | null | undefined>;
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,44 @@
import { createFeishuClient, type FeishuClientCredentials } from "./client.js";
import type { FeishuProbeResult } from "./types.js";
export async function probeFeishu(creds?: FeishuClientCredentials): Promise<FeishuProbeResult> {
if (!creds?.appId || !creds?.appSecret) {
return {
ok: false,
error: "missing credentials (appId, appSecret)",
};
}
try {
const client = createFeishuClient(creds);
// Use bot/v3/info API to get bot information
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK generic request method
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,160 @@
import type { ClawdbotConfig } from "openclaw/plugin-sdk";
import { resolveFeishuAccount } from "./accounts.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;
accountId?: string;
}): Promise<{ reactionId: string }> {
const { cfg, messageId, emojiType, accountId } = params;
const account = resolveFeishuAccount({ cfg, accountId });
if (!account.configured) {
throw new Error(`Feishu account "${account.accountId}" not configured`);
}
const client = createFeishuClient(account);
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;
accountId?: string;
}): Promise<void> {
const { cfg, messageId, reactionId, accountId } = params;
const account = resolveFeishuAccount({ cfg, accountId });
if (!account.configured) {
throw new Error(`Feishu account "${account.accountId}" not configured`);
}
const client = createFeishuClient(account);
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;
accountId?: string;
}): Promise<FeishuReaction[]> {
const { cfg, messageId, emojiType, accountId } = params;
const account = resolveFeishuAccount({ cfg, accountId });
if (!account.configured) {
throw new Error(`Feishu account "${account.accountId}" not configured`);
}
const client = createFeishuClient(account);
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,116 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const resolveFeishuAccountMock = vi.hoisted(() => vi.fn());
const getFeishuRuntimeMock = vi.hoisted(() => vi.fn());
const sendMessageFeishuMock = vi.hoisted(() => vi.fn());
const sendMarkdownCardFeishuMock = vi.hoisted(() => vi.fn());
const createFeishuClientMock = vi.hoisted(() => vi.fn());
const resolveReceiveIdTypeMock = vi.hoisted(() => vi.fn());
const createReplyDispatcherWithTypingMock = vi.hoisted(() => vi.fn());
const streamingInstances = vi.hoisted(() => [] as any[]);
vi.mock("./accounts.js", () => ({ resolveFeishuAccount: resolveFeishuAccountMock }));
vi.mock("./runtime.js", () => ({ getFeishuRuntime: getFeishuRuntimeMock }));
vi.mock("./send.js", () => ({
sendMessageFeishu: sendMessageFeishuMock,
sendMarkdownCardFeishu: sendMarkdownCardFeishuMock,
}));
vi.mock("./client.js", () => ({ createFeishuClient: createFeishuClientMock }));
vi.mock("./targets.js", () => ({ resolveReceiveIdType: resolveReceiveIdTypeMock }));
vi.mock("./streaming-card.js", () => ({
FeishuStreamingSession: class {
active = false;
start = vi.fn(async () => {
this.active = true;
});
update = vi.fn(async () => {});
close = vi.fn(async () => {
this.active = false;
});
isActive = vi.fn(() => this.active);
constructor() {
streamingInstances.push(this);
}
},
}));
import { createFeishuReplyDispatcher } from "./reply-dispatcher.js";
describe("createFeishuReplyDispatcher streaming behavior", () => {
beforeEach(() => {
vi.clearAllMocks();
streamingInstances.length = 0;
resolveFeishuAccountMock.mockReturnValue({
accountId: "main",
appId: "app_id",
appSecret: "app_secret",
domain: "feishu",
config: {
renderMode: "auto",
streaming: true,
},
});
resolveReceiveIdTypeMock.mockReturnValue("chat_id");
createFeishuClientMock.mockReturnValue({});
createReplyDispatcherWithTypingMock.mockImplementation((opts) => ({
dispatcher: {},
replyOptions: {},
markDispatchIdle: vi.fn(),
_opts: opts,
}));
getFeishuRuntimeMock.mockReturnValue({
channel: {
text: {
resolveTextChunkLimit: vi.fn(() => 4000),
resolveChunkMode: vi.fn(() => "line"),
resolveMarkdownTableMode: vi.fn(() => "preserve"),
convertMarkdownTables: vi.fn((text) => text),
chunkTextWithMode: vi.fn((text) => [text]),
},
reply: {
createReplyDispatcherWithTyping: createReplyDispatcherWithTypingMock,
resolveHumanDelayConfig: vi.fn(() => undefined),
},
},
});
});
it("keeps auto mode plain text on non-streaming send path", async () => {
createFeishuReplyDispatcher({
cfg: {} as never,
agentId: "agent",
runtime: {} as never,
chatId: "oc_chat",
});
const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
await options.deliver({ text: "plain text" }, { kind: "final" });
expect(streamingInstances).toHaveLength(0);
expect(sendMessageFeishuMock).toHaveBeenCalledTimes(1);
expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
});
it("uses streaming session for auto mode markdown payloads", async () => {
createFeishuReplyDispatcher({
cfg: {} as never,
agentId: "agent",
runtime: { log: vi.fn(), error: vi.fn() } as never,
chatId: "oc_chat",
});
const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
await options.deliver({ text: "```ts\nconst x = 1\n```" }, { kind: "final" });
expect(streamingInstances).toHaveLength(1);
expect(streamingInstances[0].start).toHaveBeenCalledTimes(1);
expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,239 @@
import {
createReplyPrefixContext,
createTypingCallbacks,
logTypingFailure,
type ClawdbotConfig,
type ReplyPayload,
type RuntimeEnv,
} from "openclaw/plugin-sdk";
import { resolveFeishuAccount } from "./accounts.js";
import { createFeishuClient } from "./client.js";
import type { MentionTarget } from "./mention.js";
import { buildMentionedCardContent } from "./mention.js";
import { getFeishuRuntime } from "./runtime.js";
import { sendMarkdownCardFeishu, sendMessageFeishu } from "./send.js";
import { FeishuStreamingSession } from "./streaming-card.js";
import { resolveReceiveIdType } from "./targets.js";
import { addTypingIndicator, removeTypingIndicator, type TypingIndicatorState } from "./typing.js";
/** Detect if text contains markdown elements that benefit from card rendering */
function shouldUseCard(text: string): boolean {
return /```[\s\S]*?```/.test(text) || /\|.+\|[\r\n]+\|[-:| ]+\|/.test(text);
}
export type CreateFeishuReplyDispatcherParams = {
cfg: ClawdbotConfig;
agentId: string;
runtime: RuntimeEnv;
chatId: string;
replyToMessageId?: string;
mentionTargets?: MentionTarget[];
accountId?: string;
};
export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherParams) {
const core = getFeishuRuntime();
const { cfg, agentId, chatId, replyToMessageId, mentionTargets, accountId } = params;
const account = resolveFeishuAccount({ cfg, accountId });
const prefixContext = createReplyPrefixContext({ cfg, agentId });
let typingState: TypingIndicatorState | null = null;
const typingCallbacks = createTypingCallbacks({
start: async () => {
if (!replyToMessageId) {
return;
}
typingState = await addTypingIndicator({ cfg, messageId: replyToMessageId, accountId });
},
stop: async () => {
if (!typingState) {
return;
}
await removeTypingIndicator({ cfg, state: typingState, accountId });
typingState = null;
},
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, "feishu", accountId, {
fallbackLimit: 4000,
});
const chunkMode = core.channel.text.resolveChunkMode(cfg, "feishu");
const tableMode = core.channel.text.resolveMarkdownTableMode({ cfg, channel: "feishu" });
const renderMode = account.config?.renderMode ?? "auto";
const streamingEnabled = account.config?.streaming !== false && renderMode !== "raw";
let streaming: FeishuStreamingSession | null = null;
let streamText = "";
let lastPartial = "";
let partialUpdateQueue: Promise<void> = Promise.resolve();
let streamingStartPromise: Promise<void> | null = null;
const startStreaming = () => {
if (!streamingEnabled || streamingStartPromise || streaming) {
return;
}
streamingStartPromise = (async () => {
const creds =
account.appId && account.appSecret
? { appId: account.appId, appSecret: account.appSecret, domain: account.domain }
: null;
if (!creds) {
return;
}
streaming = new FeishuStreamingSession(createFeishuClient(account), creds, (message) =>
params.runtime.log?.(`feishu[${account.accountId}] ${message}`),
);
try {
await streaming.start(chatId, resolveReceiveIdType(chatId));
} catch (error) {
params.runtime.error?.(`feishu: streaming start failed: ${String(error)}`);
streaming = null;
}
})();
};
const closeStreaming = async () => {
if (streamingStartPromise) {
await streamingStartPromise;
}
await partialUpdateQueue;
if (streaming?.isActive()) {
let text = streamText;
if (mentionTargets?.length) {
text = buildMentionedCardContent(mentionTargets, text);
}
await streaming.close(text);
}
streaming = null;
streamingStartPromise = null;
streamText = "";
lastPartial = "";
};
const { dispatcher, replyOptions, markDispatchIdle } =
core.channel.reply.createReplyDispatcherWithTyping({
responsePrefix: prefixContext.responsePrefix,
responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, agentId),
onReplyStart: () => {
if (streamingEnabled && renderMode === "card") {
startStreaming();
}
void typingCallbacks.onReplyStart?.();
},
deliver: async (payload: ReplyPayload, info) => {
const text = payload.text ?? "";
if (!text.trim()) {
return;
}
const useCard = renderMode === "card" || (renderMode === "auto" && shouldUseCard(text));
if ((info?.kind === "block" || info?.kind === "final") && streamingEnabled && useCard) {
startStreaming();
if (streamingStartPromise) {
await streamingStartPromise;
}
}
if (streaming?.isActive()) {
if (info?.kind === "final") {
streamText = text;
await closeStreaming();
}
return;
}
let first = true;
if (useCard) {
for (const chunk of core.channel.text.chunkTextWithMode(
text,
textChunkLimit,
chunkMode,
)) {
await sendMarkdownCardFeishu({
cfg,
to: chatId,
text: chunk,
replyToMessageId,
mentions: first ? mentionTargets : undefined,
accountId,
});
first = false;
}
} else {
const converted = core.channel.text.convertMarkdownTables(text, tableMode);
for (const chunk of core.channel.text.chunkTextWithMode(
converted,
textChunkLimit,
chunkMode,
)) {
await sendMessageFeishu({
cfg,
to: chatId,
text: chunk,
replyToMessageId,
mentions: first ? mentionTargets : undefined,
accountId,
});
first = false;
}
}
},
onError: async (error, info) => {
params.runtime.error?.(
`feishu[${account.accountId}] ${info.kind} reply failed: ${String(error)}`,
);
await closeStreaming();
typingCallbacks.onIdle?.();
},
onIdle: async () => {
await closeStreaming();
typingCallbacks.onIdle?.();
},
onCleanup: () => {
typingCallbacks.onCleanup?.();
},
});
return {
dispatcher,
replyOptions: {
...replyOptions,
onModelSelected: prefixContext.onModelSelected,
onPartialReply: streamingEnabled
? (payload: ReplyPayload) => {
if (!payload.text || payload.text === lastPartial) {
return;
}
lastPartial = payload.text;
streamText = payload.text;
partialUpdateQueue = partialUpdateQueue.then(async () => {
if (streamingStartPromise) {
await streamingStartPromise;
}
if (streaming?.isActive()) {
await streaming.update(streamText);
}
});
}
: undefined,
},
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,29 @@
export type FeishuMessageApiResponse = {
code?: number;
msg?: string;
data?: {
message_id?: string;
};
};
export function assertFeishuMessageApiSuccess(
response: FeishuMessageApiResponse,
errorPrefix: string,
) {
if (response.code !== 0) {
throw new Error(`${errorPrefix}: ${response.msg || `code ${response.code}`}`);
}
}
export function toFeishuSendResult(
response: FeishuMessageApiResponse,
chatId: string,
): {
messageId: string;
chatId: string;
} {
return {
messageId: response.data?.message_id ?? "unknown",
chatId,
};
}

View File

@@ -0,0 +1,25 @@
import type { ClawdbotConfig } from "openclaw/plugin-sdk";
import { resolveFeishuAccount } from "./accounts.js";
import { createFeishuClient } from "./client.js";
import { resolveReceiveIdType, normalizeFeishuTarget } from "./targets.js";
export function resolveFeishuSendTarget(params: {
cfg: ClawdbotConfig;
to: string;
accountId?: string;
}) {
const account = resolveFeishuAccount({ cfg: params.cfg, accountId: params.accountId });
if (!account.configured) {
throw new Error(`Feishu account "${account.accountId}" not configured`);
}
const client = createFeishuClient(account);
const receiveId = normalizeFeishuTarget(params.to);
if (!receiveId) {
throw new Error(`Invalid Feishu target: ${params.to}`);
}
return {
client,
receiveId,
receiveIdType: resolveReceiveIdType(receiveId),
};
}

View File

@@ -0,0 +1,313 @@
import type { ClawdbotConfig } from "openclaw/plugin-sdk";
import { resolveFeishuAccount } from "./accounts.js";
import { createFeishuClient } from "./client.js";
import type { MentionTarget } from "./mention.js";
import { buildMentionedMessage, buildMentionedCardContent } from "./mention.js";
import { getFeishuRuntime } from "./runtime.js";
import { assertFeishuMessageApiSuccess, toFeishuSendResult } from "./send-result.js";
import { resolveFeishuSendTarget } from "./send-target.js";
import type { FeishuSendResult } from "./types.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;
accountId?: string;
}): Promise<FeishuMessageInfo | null> {
const { cfg, messageId, accountId } = params;
const account = resolveFeishuAccount({ cfg, accountId });
if (!account.configured) {
throw new Error(`Feishu account "${account.accountId}" not configured`);
}
const client = createFeishuClient(account);
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[];
/** Account ID (optional, uses default if not specified) */
accountId?: string;
};
function buildFeishuPostMessagePayload(params: { 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, accountId } = params;
const { client, receiveId, receiveIdType } = resolveFeishuSendTarget({ cfg, to, accountId });
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({ messageText });
if (replyToMessageId) {
const response = await client.im.message.reply({
path: { message_id: replyToMessageId },
data: {
content,
msg_type: msgType,
},
});
assertFeishuMessageApiSuccess(response, "Feishu reply failed");
return toFeishuSendResult(response, receiveId);
}
const response = await client.im.message.create({
params: { receive_id_type: receiveIdType },
data: {
receive_id: receiveId,
content,
msg_type: msgType,
},
});
assertFeishuMessageApiSuccess(response, "Feishu send failed");
return toFeishuSendResult(response, receiveId);
}
export type SendFeishuCardParams = {
cfg: ClawdbotConfig;
to: string;
card: Record<string, unknown>;
replyToMessageId?: string;
accountId?: string;
};
export async function sendCardFeishu(params: SendFeishuCardParams): Promise<FeishuSendResult> {
const { cfg, to, card, replyToMessageId, accountId } = params;
const { client, receiveId, receiveIdType } = resolveFeishuSendTarget({ cfg, to, accountId });
const content = JSON.stringify(card);
if (replyToMessageId) {
const response = await client.im.message.reply({
path: { message_id: replyToMessageId },
data: {
content,
msg_type: "interactive",
},
});
assertFeishuMessageApiSuccess(response, "Feishu card reply failed");
return toFeishuSendResult(response, receiveId);
}
const response = await client.im.message.create({
params: { receive_id_type: receiveIdType },
data: {
receive_id: receiveId,
content,
msg_type: "interactive",
},
});
assertFeishuMessageApiSuccess(response, "Feishu card send failed");
return toFeishuSendResult(response, receiveId);
}
export async function updateCardFeishu(params: {
cfg: ClawdbotConfig;
messageId: string;
card: Record<string, unknown>;
accountId?: string;
}): Promise<void> {
const { cfg, messageId, card, accountId } = params;
const account = resolveFeishuAccount({ cfg, accountId });
if (!account.configured) {
throw new Error(`Feishu account "${account.accountId}" not configured`);
}
const client = createFeishuClient(account);
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.)
* Uses schema 2.0 format for proper markdown rendering.
*/
export function buildMarkdownCard(text: string): Record<string, unknown> {
return {
schema: "2.0",
config: {
wide_screen_mode: true,
},
body: {
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[];
accountId?: string;
}): Promise<FeishuSendResult> {
const { cfg, to, text, replyToMessageId, mentions, accountId } = 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, accountId });
}
/**
* 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;
accountId?: string;
}): Promise<void> {
const { cfg, messageId, text, accountId } = params;
const account = resolveFeishuAccount({ cfg, accountId });
if (!account.configured) {
throw new Error(`Feishu account "${account.accountId}" not configured`);
}
const client = createFeishuClient(account);
const tableMode = getFeishuRuntime().channel.text.resolveMarkdownTableMode({
cfg,
channel: "feishu",
});
const messageText = getFeishuRuntime().channel.text.convertMarkdownTables(text ?? "", tableMode);
const { content, msgType } = buildFeishuPostMessagePayload({ 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,218 @@
/**
* Feishu Streaming Card - Card Kit streaming API for real-time text output
*/
import type { Client } from "@larksuiteoapi/node-sdk";
import type { FeishuDomain } from "./types.js";
type Credentials = { appId: string; appSecret: string; domain?: FeishuDomain };
type CardState = { cardId: string; messageId: string; sequence: number; currentText: string };
// Token cache (keyed by domain + appId)
const tokenCache = new Map<string, { token: string; expiresAt: number }>();
function resolveApiBase(domain?: FeishuDomain): string {
if (domain === "lark") {
return "https://open.larksuite.com/open-apis";
}
if (domain && domain !== "feishu" && domain.startsWith("http")) {
return `${domain.replace(/\/+$/, "")}/open-apis`;
}
return "https://open.feishu.cn/open-apis";
}
async function getToken(creds: Credentials): Promise<string> {
const key = `${creds.domain ?? "feishu"}|${creds.appId}`;
const cached = tokenCache.get(key);
if (cached && cached.expiresAt > Date.now() + 60000) {
return cached.token;
}
const res = await fetch(`${resolveApiBase(creds.domain)}/auth/v3/tenant_access_token/internal`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ app_id: creds.appId, app_secret: creds.appSecret }),
});
const data = (await res.json()) as {
code: number;
msg: string;
tenant_access_token?: string;
expire?: number;
};
if (data.code !== 0 || !data.tenant_access_token) {
throw new Error(`Token error: ${data.msg}`);
}
tokenCache.set(key, {
token: data.tenant_access_token,
expiresAt: Date.now() + (data.expire ?? 7200) * 1000,
});
return data.tenant_access_token;
}
function truncateSummary(text: string, max = 50): string {
if (!text) {
return "";
}
const clean = text.replace(/\n/g, " ").trim();
return clean.length <= max ? clean : clean.slice(0, max - 3) + "...";
}
/** Streaming card session manager */
export class FeishuStreamingSession {
private client: Client;
private creds: Credentials;
private state: CardState | null = null;
private queue: Promise<void> = Promise.resolve();
private closed = false;
private log?: (msg: string) => void;
private lastUpdateTime = 0;
private pendingText: string | null = null;
private updateThrottleMs = 100; // Throttle updates to max 10/sec
constructor(client: Client, creds: Credentials, log?: (msg: string) => void) {
this.client = client;
this.creds = creds;
this.log = log;
}
async start(
receiveId: string,
receiveIdType: "open_id" | "user_id" | "union_id" | "email" | "chat_id" = "chat_id",
): Promise<void> {
if (this.state) {
return;
}
const apiBase = resolveApiBase(this.creds.domain);
const cardJson = {
schema: "2.0",
config: {
streaming_mode: true,
summary: { content: "[Generating...]" },
streaming_config: { print_frequency_ms: { default: 50 }, print_step: { default: 2 } },
},
body: {
elements: [{ tag: "markdown", content: "⏳ Thinking...", element_id: "content" }],
},
};
// Create card entity
const createRes = await fetch(`${apiBase}/cardkit/v1/cards`, {
method: "POST",
headers: {
Authorization: `Bearer ${await getToken(this.creds)}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ type: "card_json", data: JSON.stringify(cardJson) }),
});
const createData = (await createRes.json()) as {
code: number;
msg: string;
data?: { card_id: string };
};
if (createData.code !== 0 || !createData.data?.card_id) {
throw new Error(`Create card failed: ${createData.msg}`);
}
const cardId = createData.data.card_id;
// Send card message
const sendRes = await this.client.im.message.create({
params: { receive_id_type: receiveIdType },
data: {
receive_id: receiveId,
msg_type: "interactive",
content: JSON.stringify({ type: "card", data: { card_id: cardId } }),
},
});
if (sendRes.code !== 0 || !sendRes.data?.message_id) {
throw new Error(`Send card failed: ${sendRes.msg}`);
}
this.state = { cardId, messageId: sendRes.data.message_id, sequence: 1, currentText: "" };
this.log?.(`Started streaming: cardId=${cardId}, messageId=${sendRes.data.message_id}`);
}
private async updateCardContent(text: string, onError?: (error: unknown) => void): Promise<void> {
if (!this.state) {
return;
}
const apiBase = resolveApiBase(this.creds.domain);
this.state.sequence += 1;
await fetch(`${apiBase}/cardkit/v1/cards/${this.state.cardId}/elements/content/content`, {
method: "PUT",
headers: {
Authorization: `Bearer ${await getToken(this.creds)}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
content: text,
sequence: this.state.sequence,
uuid: `s_${this.state.cardId}_${this.state.sequence}`,
}),
}).catch((error) => onError?.(error));
}
async update(text: string): Promise<void> {
if (!this.state || this.closed) {
return;
}
// Throttle: skip if updated recently, but remember pending text
const now = Date.now();
if (now - this.lastUpdateTime < this.updateThrottleMs) {
this.pendingText = text;
return;
}
this.pendingText = null;
this.lastUpdateTime = now;
this.queue = this.queue.then(async () => {
if (!this.state || this.closed) {
return;
}
this.state.currentText = text;
await this.updateCardContent(text, (e) => this.log?.(`Update failed: ${String(e)}`));
});
await this.queue;
}
async close(finalText?: string): Promise<void> {
if (!this.state || this.closed) {
return;
}
this.closed = true;
await this.queue;
// Use finalText, or pending throttled text, or current text
const text = finalText ?? this.pendingText ?? this.state.currentText;
const apiBase = resolveApiBase(this.creds.domain);
// Only send final update if content differs from what's already displayed
if (text && text !== this.state.currentText) {
await this.updateCardContent(text);
this.state.currentText = text;
}
// Close streaming mode
this.state.sequence += 1;
await fetch(`${apiBase}/cardkit/v1/cards/${this.state.cardId}/settings`, {
method: "PATCH",
headers: {
Authorization: `Bearer ${await getToken(this.creds)}`,
"Content-Type": "application/json; charset=utf-8",
},
body: JSON.stringify({
settings: JSON.stringify({
config: { streaming_mode: false, summary: { content: truncateSummary(text) } },
}),
sequence: this.state.sequence,
uuid: `c_${this.state.cardId}_${this.state.sequence}`,
}),
}).catch((e) => this.log?.(`Close failed: ${String(e)}`));
this.log?.(`Closed streaming: cardId=${this.state.cardId}`);
}
isActive(): boolean {
return this.state !== null && !this.closed;
}
}

View File

@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { resolveReceiveIdType } from "./targets.js";
describe("resolveReceiveIdType", () => {
it("resolves chat IDs by oc_ prefix", () => {
expect(resolveReceiveIdType("oc_123")).toBe("chat_id");
});
it("resolves open IDs by ou_ prefix", () => {
expect(resolveReceiveIdType("ou_123")).toBe("open_id");
});
it("defaults unprefixed IDs to user_id", () => {
expect(resolveReceiveIdType("u_123")).toBe("user_id");
});
});

View File

@@ -0,0 +1,78 @@
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 "user_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,111 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { registerFeishuBitableTools } from "./bitable.js";
import { registerFeishuDriveTools } from "./drive.js";
import { registerFeishuPermTools } from "./perm.js";
import { createToolFactoryHarness } from "./tool-factory-test-harness.js";
import { registerFeishuWikiTools } from "./wiki.js";
const createFeishuClientMock = vi.fn((account: { appId?: string } | undefined) => ({
__appId: account?.appId,
}));
vi.mock("./client.js", () => ({
createFeishuClient: (account: { appId?: string } | undefined) => createFeishuClientMock(account),
}));
function createConfig(params: {
toolsA?: {
wiki?: boolean;
drive?: boolean;
perm?: boolean;
};
toolsB?: {
wiki?: boolean;
drive?: boolean;
perm?: boolean;
};
}): OpenClawPluginApi["config"] {
return {
channels: {
feishu: {
enabled: true,
accounts: {
a: {
appId: "app-a",
appSecret: "sec-a",
tools: params.toolsA,
},
b: {
appId: "app-b",
appSecret: "sec-b",
tools: params.toolsB,
},
},
},
},
} as OpenClawPluginApi["config"];
}
describe("feishu tool account routing", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("wiki tool registers when first account disables it and routes to agentAccountId", async () => {
const { api, resolveTool } = createToolFactoryHarness(
createConfig({
toolsA: { wiki: false },
toolsB: { wiki: true },
}),
);
registerFeishuWikiTools(api);
const tool = resolveTool("feishu_wiki", { agentAccountId: "b" });
await tool.execute("call", { action: "search" });
expect(createFeishuClientMock.mock.calls.at(-1)?.[0]?.appId).toBe("app-b");
});
test("drive tool registers when first account disables it and routes to agentAccountId", async () => {
const { api, resolveTool } = createToolFactoryHarness(
createConfig({
toolsA: { drive: false },
toolsB: { drive: true },
}),
);
registerFeishuDriveTools(api);
const tool = resolveTool("feishu_drive", { agentAccountId: "b" });
await tool.execute("call", { action: "unknown_action" });
expect(createFeishuClientMock.mock.calls.at(-1)?.[0]?.appId).toBe("app-b");
});
test("perm tool registers when only second account enables it and routes to agentAccountId", async () => {
const { api, resolveTool } = createToolFactoryHarness(
createConfig({
toolsA: { perm: false },
toolsB: { perm: true },
}),
);
registerFeishuPermTools(api);
const tool = resolveTool("feishu_perm", { agentAccountId: "b" });
await tool.execute("call", { action: "unknown_action" });
expect(createFeishuClientMock.mock.calls.at(-1)?.[0]?.appId).toBe("app-b");
});
test("bitable tool routes to agentAccountId and allows explicit accountId override", async () => {
const { api, resolveTool } = createToolFactoryHarness(createConfig({}));
registerFeishuBitableTools(api);
const tool = resolveTool("feishu_bitable_get_meta", { agentAccountId: "b" });
await tool.execute("call-ctx", { url: "invalid-url" });
await tool.execute("call-override", { url: "invalid-url", accountId: "a" });
expect(createFeishuClientMock.mock.calls[0]?.[0]?.appId).toBe("app-b");
expect(createFeishuClientMock.mock.calls[1]?.[0]?.appId).toBe("app-a");
});
});

View File

@@ -0,0 +1,58 @@
import type * as Lark from "@larksuiteoapi/node-sdk";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { resolveFeishuAccount } from "./accounts.js";
import { createFeishuClient } from "./client.js";
import { resolveToolsConfig } from "./tools-config.js";
import type { FeishuToolsConfig, ResolvedFeishuAccount } from "./types.js";
type AccountAwareParams = { accountId?: string };
function normalizeOptionalAccountId(value: string | undefined): string | undefined {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
}
export function resolveFeishuToolAccount(params: {
api: Pick<OpenClawPluginApi, "config">;
executeParams?: AccountAwareParams;
defaultAccountId?: string;
}): ResolvedFeishuAccount {
if (!params.api.config) {
throw new Error("Feishu config unavailable");
}
return resolveFeishuAccount({
cfg: params.api.config,
accountId:
normalizeOptionalAccountId(params.executeParams?.accountId) ??
normalizeOptionalAccountId(params.defaultAccountId),
});
}
export function createFeishuToolClient(params: {
api: Pick<OpenClawPluginApi, "config">;
executeParams?: AccountAwareParams;
defaultAccountId?: string;
}): Lark.Client {
return createFeishuClient(resolveFeishuToolAccount(params));
}
export function resolveAnyEnabledFeishuToolsConfig(
accounts: ResolvedFeishuAccount[],
): Required<FeishuToolsConfig> {
const merged: Required<FeishuToolsConfig> = {
doc: false,
wiki: false,
drive: false,
perm: false,
scopes: false,
};
for (const account of accounts) {
const cfg = resolveToolsConfig(account.config.tools);
merged.doc = merged.doc || cfg.doc;
merged.wiki = merged.wiki || cfg.wiki;
merged.drive = merged.drive || cfg.drive;
merged.perm = merged.perm || cfg.perm;
merged.scopes = merged.scopes || cfg.scopes;
}
return merged;
}

View File

@@ -0,0 +1,76 @@
import type { AnyAgentTool, OpenClawPluginApi } from "openclaw/plugin-sdk";
type ToolContextLike = {
agentAccountId?: string;
};
type ToolFactoryLike = (ctx: ToolContextLike) => AnyAgentTool | AnyAgentTool[] | null | undefined;
export type ToolLike = {
name: string;
execute: (toolCallId: string, params: unknown) => Promise<unknown> | unknown;
};
type RegisteredTool = {
tool: AnyAgentTool | ToolFactoryLike;
opts?: { name?: string };
};
function toToolList(value: AnyAgentTool | AnyAgentTool[] | null | undefined): AnyAgentTool[] {
if (!value) return [];
return Array.isArray(value) ? value : [value];
}
function asToolLike(tool: AnyAgentTool, fallbackName?: string): ToolLike {
const candidate = tool as Partial<ToolLike>;
const name = candidate.name ?? fallbackName;
const execute = candidate.execute;
if (!name || typeof execute !== "function") {
throw new Error(`Resolved tool is missing required fields (name=${String(name)})`);
}
return {
name,
execute: (toolCallId, params) => execute(toolCallId, params),
};
}
export function createToolFactoryHarness(cfg: OpenClawPluginApi["config"]) {
const registered: RegisteredTool[] = [];
const api: Pick<OpenClawPluginApi, "config" | "logger" | "registerTool"> = {
config: cfg,
logger: {
info: () => {},
warn: () => {},
error: () => {},
debug: () => {},
},
registerTool: (tool, opts) => {
registered.push({ tool, opts });
},
};
const resolveTool = (name: string, ctx: ToolContextLike = {}): ToolLike => {
for (const entry of registered) {
if (entry.opts?.name === name && typeof entry.tool !== "function") {
return asToolLike(entry.tool, name);
}
if (typeof entry.tool === "function") {
const builtTools = toToolList(entry.tool(ctx));
const hit = builtTools.find((tool) => (tool as { name?: string }).name === name);
if (hit) {
return asToolLike(hit, name);
}
} else if ((entry.tool as { name?: string }).name === name) {
return asToolLike(entry.tool, name);
}
}
throw new Error(`Tool not registered: ${name}`);
};
return {
api: api as OpenClawPluginApi,
resolveTool,
};
}

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,81 @@
import type { BaseProbeResult } from "openclaw/plugin-sdk";
import type {
FeishuConfigSchema,
FeishuGroupSchema,
FeishuAccountConfigSchema,
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 FeishuAccountConfig = z.infer<typeof FeishuAccountConfigSchema>;
export type FeishuDomain = "feishu" | "lark" | (string & {});
export type FeishuConnectionMode = "websocket" | "webhook";
export type ResolvedFeishuAccount = {
accountId: string;
enabled: boolean;
configured: boolean;
name?: string;
appId?: string;
appSecret?: string;
encryptKey?: string;
verificationToken?: string;
domain: FeishuDomain;
/** Merged config (top-level defaults + account-specific overrides) */
config: FeishuConfig;
};
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 = BaseProbeResult<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;
};
export type DynamicAgentCreationConfig = {
enabled?: boolean;
workspaceTemplate?: string;
agentDirTemplate?: string;
maxAgents?: number;
};

View File

@@ -0,0 +1,80 @@
import type { ClawdbotConfig } from "openclaw/plugin-sdk";
import { resolveFeishuAccount } from "./accounts.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;
accountId?: string;
}): Promise<TypingIndicatorState> {
const { cfg, messageId, accountId } = params;
const account = resolveFeishuAccount({ cfg, accountId });
if (!account.configured) {
return { messageId, reactionId: null };
}
const client = createFeishuClient(account);
try {
const response = await client.im.messageReaction.create({
path: { message_id: messageId },
data: {
reaction_type: { emoji_type: TYPING_EMOJI },
},
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK response type
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;
accountId?: string;
}): Promise<void> {
const { cfg, state, accountId } = params;
if (!state.reactionId) {
return;
}
const account = resolveFeishuAccount({ cfg, accountId });
if (!account.configured) {
return;
}
const client = createFeishuClient(account);
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,237 @@
import type * as Lark from "@larksuiteoapi/node-sdk";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { listEnabledFeishuAccounts } from "./accounts.js";
import { createFeishuToolClient, resolveAnyEnabledFeishuToolsConfig } from "./tool-account.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) {
if (!api.config) {
api.logger.debug?.("feishu_wiki: No config available, skipping wiki tools");
return;
}
const accounts = listEnabledFeishuAccounts(api.config);
if (accounts.length === 0) {
api.logger.debug?.("feishu_wiki: No Feishu accounts configured, skipping wiki tools");
return;
}
const toolsCfg = resolveAnyEnabledFeishuToolsConfig(accounts);
if (!toolsCfg.wiki) {
api.logger.debug?.("feishu_wiki: wiki tool disabled in config");
return;
}
type FeishuWikiExecuteParams = FeishuWikiParams & { accountId?: string };
api.registerTool(
(ctx) => {
const defaultAccountId = ctx.agentAccountId;
return {
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 FeishuWikiExecuteParams;
try {
const client = createFeishuToolClient({
api,
executeParams: p,
defaultAccountId,
});
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:
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- exhaustive check fallback
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`);
}