Include full contents of all nested repositories
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
144
openclaw/extensions/feishu/src/accounts.ts
Normal file
144
openclaw/extensions/feishu/src/accounts.ts
Normal 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);
|
||||
}
|
||||
713
openclaw/extensions/feishu/src/bitable.ts
Normal file
713
openclaw/extensions/feishu/src/bitable.ts
Normal 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");
|
||||
}
|
||||
130
openclaw/extensions/feishu/src/bot.checkBotMentioned.test.ts
Normal file
130
openclaw/extensions/feishu/src/bot.checkBotMentioned.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
38
openclaw/extensions/feishu/src/bot.stripBotMention.test.ts
Normal file
38
openclaw/extensions/feishu/src/bot.stripBotMention.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
543
openclaw/extensions/feishu/src/bot.test.ts
Normal file
543
openclaw/extensions/feishu/src/bot.test.ts
Normal 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"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
979
openclaw/extensions/feishu/src/bot.ts
Normal file
979
openclaw/extensions/feishu/src/bot.ts
Normal 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)}`);
|
||||
}
|
||||
}
|
||||
48
openclaw/extensions/feishu/src/channel.test.ts
Normal file
48
openclaw/extensions/feishu/src/channel.test.ts
Normal 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" });
|
||||
});
|
||||
});
|
||||
359
openclaw/extensions/feishu/src/channel.ts
Normal file
359
openclaw/extensions/feishu/src/channel.ts
Normal 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,
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
118
openclaw/extensions/feishu/src/client.ts
Normal file
118
openclaw/extensions/feishu/src/client.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
88
openclaw/extensions/feishu/src/config-schema.test.ts
Normal file
88
openclaw/extensions/feishu/src/config-schema.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
226
openclaw/extensions/feishu/src/config-schema.ts
Normal file
226
openclaw/extensions/feishu/src/config-schema.ts
Normal 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 "*"',
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
54
openclaw/extensions/feishu/src/dedup.ts
Normal file
54
openclaw/extensions/feishu/src/dedup.ts
Normal 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)}`);
|
||||
},
|
||||
});
|
||||
}
|
||||
177
openclaw/extensions/feishu/src/directory.ts
Normal file
177
openclaw/extensions/feishu/src/directory.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
47
openclaw/extensions/feishu/src/doc-schema.ts
Normal file
47
openclaw/extensions/feishu/src/doc-schema.ts
Normal 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>;
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
124
openclaw/extensions/feishu/src/docx.test.ts
Normal file
124
openclaw/extensions/feishu/src/docx.test.ts
Normal 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: "",
|
||||
});
|
||||
|
||||
expect(fetchRemoteMediaMock).toHaveBeenCalled();
|
||||
expect(driveUploadAllMock).not.toHaveBeenCalled();
|
||||
expect(blockPatchMock).not.toHaveBeenCalled();
|
||||
expect(result.details.images_processed).toBe(0);
|
||||
expect(consoleErrorSpy).toHaveBeenCalled();
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
566
openclaw/extensions/feishu/src/docx.ts
Normal file
566
openclaw/extensions/feishu/src/docx.ts
Normal 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(", ")}`);
|
||||
}
|
||||
}
|
||||
46
openclaw/extensions/feishu/src/drive-schema.ts
Normal file
46
openclaw/extensions/feishu/src/drive-schema.ts
Normal 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>;
|
||||
232
openclaw/extensions/feishu/src/drive.ts
Normal file
232
openclaw/extensions/feishu/src/drive.ts
Normal 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`);
|
||||
}
|
||||
131
openclaw/extensions/feishu/src/dynamic-agent.ts
Normal file
131
openclaw/extensions/feishu/src/dynamic-agent.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { OpenClawConfig, PluginRuntime } from "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;
|
||||
}
|
||||
20
openclaw/extensions/feishu/src/external-keys.test.ts
Normal file
20
openclaw/extensions/feishu/src/external-keys.test.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeFeishuExternalKey } from "./external-keys.js";
|
||||
|
||||
describe("normalizeFeishuExternalKey", () => {
|
||||
it("accepts a normal feishu key and trims surrounding spaces", () => {
|
||||
expect(normalizeFeishuExternalKey(" img_v3_01abcDEF123 ")).toBe("img_v3_01abcDEF123");
|
||||
});
|
||||
|
||||
it("rejects traversal and path separator patterns", () => {
|
||||
expect(normalizeFeishuExternalKey("../etc/passwd")).toBeUndefined();
|
||||
expect(normalizeFeishuExternalKey("a/../../b")).toBeUndefined();
|
||||
expect(normalizeFeishuExternalKey("a\\..\\b")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects empty, non-string, and control-char values", () => {
|
||||
expect(normalizeFeishuExternalKey(" ")).toBeUndefined();
|
||||
expect(normalizeFeishuExternalKey(123)).toBeUndefined();
|
||||
expect(normalizeFeishuExternalKey("abc\u0000def")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
19
openclaw/extensions/feishu/src/external-keys.ts
Normal file
19
openclaw/extensions/feishu/src/external-keys.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
const CONTROL_CHARS_RE = /[\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;
|
||||
}
|
||||
279
openclaw/extensions/feishu/src/media.test.ts
Normal file
279
openclaw/extensions/feishu/src/media.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
441
openclaw/extensions/feishu/src/media.ts
Normal file
441
openclaw/extensions/feishu/src/media.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
133
openclaw/extensions/feishu/src/mention.ts
Normal file
133
openclaw/extensions/feishu/src/mention.ts
Normal 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}`;
|
||||
}
|
||||
397
openclaw/extensions/feishu/src/monitor.ts
Normal file
397
openclaw/extensions/feishu/src/monitor.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
183
openclaw/extensions/feishu/src/monitor.webhook-security.test.ts
Normal file
183
openclaw/extensions/feishu/src/monitor.webhook-security.test.ts
Normal 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);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
351
openclaw/extensions/feishu/src/onboarding.ts
Normal file
351
openclaw/extensions/feishu/src/onboarding.ts
Normal 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 },
|
||||
},
|
||||
}),
|
||||
};
|
||||
55
openclaw/extensions/feishu/src/outbound.ts
Normal file
55
openclaw/extensions/feishu/src/outbound.ts
Normal 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 };
|
||||
},
|
||||
};
|
||||
52
openclaw/extensions/feishu/src/perm-schema.ts
Normal file
52
openclaw/extensions/feishu/src/perm-schema.ts
Normal 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>;
|
||||
180
openclaw/extensions/feishu/src/perm.ts
Normal file
180
openclaw/extensions/feishu/src/perm.ts
Normal 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`);
|
||||
}
|
||||
59
openclaw/extensions/feishu/src/policy.test.ts
Normal file
59
openclaw/extensions/feishu/src/policy.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
120
openclaw/extensions/feishu/src/policy.ts
Normal file
120
openclaw/extensions/feishu/src/policy.ts
Normal 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 };
|
||||
}
|
||||
44
openclaw/extensions/feishu/src/probe.ts
Normal file
44
openclaw/extensions/feishu/src/probe.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
}
|
||||
160
openclaw/extensions/feishu/src/reactions.ts
Normal file
160
openclaw/extensions/feishu/src/reactions.ts
Normal 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];
|
||||
116
openclaw/extensions/feishu/src/reply-dispatcher.test.ts
Normal file
116
openclaw/extensions/feishu/src/reply-dispatcher.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
239
openclaw/extensions/feishu/src/reply-dispatcher.ts
Normal file
239
openclaw/extensions/feishu/src/reply-dispatcher.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
14
openclaw/extensions/feishu/src/runtime.ts
Normal file
14
openclaw/extensions/feishu/src/runtime.ts
Normal 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;
|
||||
}
|
||||
29
openclaw/extensions/feishu/src/send-result.ts
Normal file
29
openclaw/extensions/feishu/src/send-result.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
25
openclaw/extensions/feishu/src/send-target.ts
Normal file
25
openclaw/extensions/feishu/src/send-target.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
313
openclaw/extensions/feishu/src/send.ts
Normal file
313
openclaw/extensions/feishu/src/send.ts
Normal 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}`}`);
|
||||
}
|
||||
}
|
||||
218
openclaw/extensions/feishu/src/streaming-card.ts
Normal file
218
openclaw/extensions/feishu/src/streaming-card.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
16
openclaw/extensions/feishu/src/targets.test.ts
Normal file
16
openclaw/extensions/feishu/src/targets.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
78
openclaw/extensions/feishu/src/targets.ts
Normal file
78
openclaw/extensions/feishu/src/targets.ts
Normal 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;
|
||||
}
|
||||
111
openclaw/extensions/feishu/src/tool-account-routing.test.ts
Normal file
111
openclaw/extensions/feishu/src/tool-account-routing.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
58
openclaw/extensions/feishu/src/tool-account.ts
Normal file
58
openclaw/extensions/feishu/src/tool-account.ts
Normal 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;
|
||||
}
|
||||
76
openclaw/extensions/feishu/src/tool-factory-test-harness.ts
Normal file
76
openclaw/extensions/feishu/src/tool-factory-test-harness.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
21
openclaw/extensions/feishu/src/tools-config.ts
Normal file
21
openclaw/extensions/feishu/src/tools-config.ts
Normal 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 };
|
||||
}
|
||||
81
openclaw/extensions/feishu/src/types.ts
Normal file
81
openclaw/extensions/feishu/src/types.ts
Normal 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;
|
||||
};
|
||||
80
openclaw/extensions/feishu/src/typing.ts
Normal file
80
openclaw/extensions/feishu/src/typing.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
55
openclaw/extensions/feishu/src/wiki-schema.ts
Normal file
55
openclaw/extensions/feishu/src/wiki-schema.ts
Normal 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>;
|
||||
237
openclaw/extensions/feishu/src/wiki.ts
Normal file
237
openclaw/extensions/feishu/src/wiki.ts
Normal 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`);
|
||||
}
|
||||
Reference in New Issue
Block a user