360 lines
11 KiB
TypeScript
360 lines
11 KiB
TypeScript
import type { ChannelMeta, ChannelPlugin, ClawdbotConfig } from "openclaw/plugin-sdk";
|
|
import { DEFAULT_ACCOUNT_ID, PAIRING_APPROVED_MESSAGE } from "openclaw/plugin-sdk";
|
|
import type { ResolvedFeishuAccount, FeishuConfig } from "./types.js";
|
|
import {
|
|
resolveFeishuAccount,
|
|
resolveFeishuCredentials,
|
|
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";
|
|
|
|
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" },
|
|
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"] },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
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 = (
|
|
cfg.channels as Record<string, { groupPolicy?: string }> | undefined
|
|
)?.defaults?.groupPolicy;
|
|
const groupPolicy = feishuCfg?.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
|
|
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: {
|
|
accountId: DEFAULT_ACCOUNT_ID,
|
|
running: false,
|
|
lastStartAt: null,
|
|
lastStopAt: null,
|
|
lastError: null,
|
|
port: null,
|
|
},
|
|
buildChannelSummary: ({ snapshot }) => ({
|
|
configured: snapshot.configured ?? false,
|
|
running: snapshot.running ?? false,
|
|
lastStartAt: snapshot.lastStartAt ?? null,
|
|
lastStopAt: snapshot.lastStopAt ?? null,
|
|
lastError: snapshot.lastError ?? null,
|
|
port: snapshot.port ?? null,
|
|
probe: snapshot.probe,
|
|
lastProbeAt: snapshot.lastProbeAt ?? null,
|
|
}),
|
|
probeAccount: async ({ account }) => {
|
|
return 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,
|
|
});
|
|
},
|
|
},
|
|
};
|