openclaw/extensions/feishu/src/channel.ts
2026-01-26 17:11:57 +08:00

542 lines
18 KiB
TypeScript

import type {
ChannelAccountSnapshot,
ChannelDock,
ChannelPlugin,
ClawdbotConfig,
} from "clawdbot/plugin-sdk";
import {
applyAccountNameToChannelSection,
buildChannelConfigSchema,
DEFAULT_ACCOUNT_ID,
deleteAccountFromConfigSection,
formatPairingApproveHint,
migrateBaseNameToDefaultAccount,
missingTargetError,
normalizeAccountId,
PAIRING_APPROVED_MESSAGE,
setAccountEnabledInConfigSection,
} from "clawdbot/plugin-sdk";
import { getBotIdentity, sendFeishuTextMessage } from "./api.js";
import {
listFeishuAccountIds,
resolveDefaultFeishuAccountId,
resolveFeishuAccount,
type ResolvedFeishuAccount,
} from "./accounts.js";
import { FeishuConfigSchema } from "./config-schema.js";
import { feishuOnboardingAdapter } from "./onboarding.js";
import { probeFeishu } from "./probe.js";
import { resolveFeishuWebhookPath, startFeishuMonitor } from "./monitor.js";
import {
looksLikeFeishuTargetId,
normalizeFeishuMessagingTarget,
parseFeishuMessagingTarget,
} from "./targets.js";
import { getFeishuRuntime } from "./runtime.js";
const meta = {
id: "feishu",
label: "Feishu",
selectionLabel: "Feishu (Bot API)",
docsPath: "/channels/feishu",
docsLabel: "feishu",
blurb: "Feishu bot events via webhook or long connection.",
aliases: ["fs"],
order: 85,
quickstartAllowFrom: true,
} as const;
const formatAllowFromEntry = (entry: string) =>
entry
.trim()
.replace(/^feishu:/i, "")
.replace(/^fs:/i, "")
.replace(/^user:/i, "")
.replace(/^open_id:/i, "")
.replace(/^openid:/i, "")
.toLowerCase();
export const feishuDock: ChannelDock = {
id: "feishu",
capabilities: {
chatTypes: ["direct", "group"],
blockStreaming: true,
},
outbound: { textChunkLimit: 4000 },
config: {
resolveAllowFrom: ({ cfg, accountId }) =>
(
resolveFeishuAccount({ cfg: cfg as ClawdbotConfig, accountId }).config.dm?.allowFrom ?? []
).map((entry) => String(entry)),
formatAllowFrom: ({ allowFrom }) =>
allowFrom
.map((entry) => String(entry))
.filter(Boolean)
.map(formatAllowFromEntry),
},
groups: {
resolveRequireMention: () => true,
},
threading: {
resolveReplyToMode: () => "off",
},
};
function targetHint() {
return "<user:OPEN_ID|chat:CHAT_ID>";
}
export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
id: "feishu",
meta: { ...meta },
onboarding: feishuOnboardingAdapter,
pairing: {
idLabel: "feishuOpenId",
normalizeAllowEntry: (entry) => formatAllowFromEntry(entry),
notifyApproval: async ({ cfg, id }) => {
const account = resolveFeishuAccount({ cfg: cfg as ClawdbotConfig });
if (account.credentialSource === "none") return;
const openId = formatAllowFromEntry(id);
await sendFeishuTextMessage({
account,
target: { kind: "user", openId },
text: PAIRING_APPROVED_MESSAGE,
});
},
},
capabilities: {
chatTypes: ["direct", "group"],
reactions: false,
threads: false,
media: false,
polls: false,
nativeCommands: false,
blockStreaming: true,
},
streaming: {
blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
},
reload: { configPrefixes: ["channels.feishu"] },
configSchema: buildChannelConfigSchema(FeishuConfigSchema),
config: {
listAccountIds: (cfg) => listFeishuAccountIds(cfg as ClawdbotConfig),
resolveAccount: (cfg, accountId) =>
resolveFeishuAccount({ cfg: cfg as ClawdbotConfig, accountId }),
defaultAccountId: (cfg) => resolveDefaultFeishuAccountId(cfg as ClawdbotConfig),
setAccountEnabled: ({ cfg, accountId, enabled }) =>
setAccountEnabledInConfigSection({
cfg: cfg as ClawdbotConfig,
sectionKey: "feishu",
accountId,
enabled,
allowTopLevel: true,
}),
deleteAccount: ({ cfg, accountId }) =>
deleteAccountFromConfigSection({
cfg: cfg as ClawdbotConfig,
sectionKey: "feishu",
accountId,
clearBaseFields: [
"appId",
"appSecret",
"verificationToken",
"encryptKey",
"webhookPath",
"webhookUrl",
"name",
],
}),
isConfigured: (account) => account.credentialSource !== "none",
describeAccount: (account): ChannelAccountSnapshot => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: account.credentialSource !== "none",
credentialSource: account.credentialSource,
}),
resolveAllowFrom: ({ cfg, accountId }) =>
(
resolveFeishuAccount({ cfg: cfg as ClawdbotConfig, accountId }).config.dm?.allowFrom ?? []
).map((entry) => String(entry)),
formatAllowFrom: ({ allowFrom }) =>
allowFrom
.map((entry) => String(entry))
.filter(Boolean)
.map(formatAllowFromEntry),
},
security: {
resolveDmPolicy: ({ cfg, accountId, account }) => {
const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
const useAccountPath = Boolean(
(cfg as ClawdbotConfig).channels?.feishu?.accounts?.[resolvedAccountId],
);
const basePath = useAccountPath
? `channels.feishu.accounts.${resolvedAccountId}.`
: "channels.feishu.";
return {
policy: account.config.dm?.policy ?? "pairing",
allowFrom: account.config.dm?.allowFrom ?? [],
policyPath: `${basePath}dm.policy`,
allowFromPath: `${basePath}dm.`,
approveHint: formatPairingApproveHint("feishu"),
normalizeEntry: (raw) => formatAllowFromEntry(raw),
};
},
collectWarnings: ({ cfg, account }) => {
const warnings: string[] = [];
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
if (groupPolicy === "open") {
warnings.push(
`- Feishu groups: groupPolicy="open" allows any group not explicitly denied to trigger (mention-gated). Set channels.feishu.groupPolicy="allowlist" and configure channels.feishu.groups.`,
);
}
return warnings;
},
},
groups: {
resolveRequireMention: () => true,
resolveToolPolicy: () => ({ mode: "allow" }),
},
threading: {
resolveReplyToMode: () => "off",
},
messaging: {
normalizeTarget: normalizeFeishuMessagingTarget,
targetResolver: {
looksLikeId: looksLikeFeishuTargetId,
hint: targetHint(),
},
},
directory: {
self: async () => null,
listPeers: async ({ cfg, accountId, query, limit }) => {
const account = resolveFeishuAccount({ cfg: cfg as ClawdbotConfig, accountId });
const q = query?.trim().toLowerCase() || "";
const ids = new Set<string>();
for (const entry of account.config.dm?.allowFrom ?? []) {
const trimmed = String(entry).trim();
if (trimmed && trimmed !== "*") ids.add(trimmed);
}
return Array.from(ids)
.map(formatAllowFromEntry)
.filter((id) => (q ? id.includes(q) : true))
.slice(0, limit && limit > 0 ? limit : undefined)
.map((id) => ({ kind: "user", id }) as const);
},
listGroups: async ({ cfg, accountId, query, limit }) => {
const account = resolveFeishuAccount({ cfg: cfg as ClawdbotConfig, accountId });
const q = query?.trim().toLowerCase() || "";
const groups = account.config.groups ?? {};
const ids = Object.keys(groups)
.map((id) => id.trim())
.filter((id) => id && id !== "*")
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
.slice(0, limit && limit > 0 ? limit : undefined)
.map((id) => ({ kind: "group", id }) as const);
return ids;
},
},
resolver: {
resolveTargets: async ({ inputs, kind }) => {
const resolved = inputs.map((input) => {
const normalized = normalizeFeishuMessagingTarget(input);
if (!normalized) {
return { input, resolved: false, note: "empty target" };
}
if (kind === "user" && normalized.toLowerCase().startsWith("user:")) {
return { input, resolved: true, id: normalized };
}
if (kind === "group" && normalized.toLowerCase().startsWith("chat:")) {
return { input, resolved: true, id: normalized };
}
return { input, resolved: false, note: targetHint() };
});
return resolved;
},
},
setup: {
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
applyAccountName: ({ cfg, accountId, name }) =>
applyAccountNameToChannelSection({
cfg: cfg as ClawdbotConfig,
channelKey: "feishu",
accountId,
name,
}),
validateInput: ({ input }) => {
if (input.useEnv) {
return "Feishu does not support --use-env; set appId/appSecret in config.";
}
const appId = (input as { appId?: unknown }).appId;
const appSecret = (input as { appSecret?: unknown }).appSecret;
const modeRaw = (input as { mode?: unknown }).mode;
const mode =
typeof modeRaw === "string" && modeRaw.trim() ? modeRaw.trim().toLowerCase() : undefined;
if (mode && mode !== "http" && mode !== "ws") {
return 'Feishu --mode must be "http" or "ws".';
}
const verificationToken = (input as { verificationToken?: unknown }).verificationToken;
const encryptKey = (input as { encryptKey?: unknown }).encryptKey;
if (typeof appId !== "string" || !appId.trim()) {
return "Feishu requires --app-id.";
}
if (typeof appSecret !== "string" || !appSecret.trim()) {
return "Feishu requires --app-secret.";
}
if (mode !== "ws") {
if (
(typeof verificationToken !== "string" || !verificationToken.trim()) &&
(typeof encryptKey !== "string" || !encryptKey.trim())
) {
return 'Feishu requires --verification-token or --encrypt-key when --mode="http".';
}
}
return null;
},
applyAccountConfig: ({ cfg, accountId, input }) => {
const namedConfig = applyAccountNameToChannelSection({
cfg: cfg as ClawdbotConfig,
channelKey: "feishu",
accountId,
name: input.name,
});
const next =
accountId !== DEFAULT_ACCOUNT_ID
? migrateBaseNameToDefaultAccount({
cfg: namedConfig as ClawdbotConfig,
channelKey: "feishu",
})
: namedConfig;
const appId = String((input as { appId?: unknown }).appId ?? "").trim();
const appSecret = String((input as { appSecret?: unknown }).appSecret ?? "").trim();
const modeRaw = (input as { mode?: unknown }).mode;
const mode =
typeof modeRaw === "string" && modeRaw.trim() ? modeRaw.trim().toLowerCase() : undefined;
const normalizedMode = mode === "http" || mode === "ws" ? mode : undefined;
const verificationTokenRaw = (input as { verificationToken?: unknown }).verificationToken;
const encryptKeyRaw = (input as { encryptKey?: unknown }).encryptKey;
const verificationToken =
typeof verificationTokenRaw === "string" && verificationTokenRaw.trim()
? verificationTokenRaw.trim()
: undefined;
const encryptKey =
typeof encryptKeyRaw === "string" && encryptKeyRaw.trim()
? encryptKeyRaw.trim()
: undefined;
const webhookPath = input.webhookPath?.trim() || undefined;
const webhookUrl = input.webhookUrl?.trim() || undefined;
const configPatch = {
appId,
appSecret,
...(normalizedMode ? { mode: normalizedMode } : {}),
...(verificationToken ? { verificationToken } : {}),
...(encryptKey ? { encryptKey } : {}),
...(webhookPath ? { webhookPath } : {}),
...(webhookUrl ? { webhookUrl } : {}),
};
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...next,
channels: {
...next.channels,
feishu: {
...(next.channels?.feishu ?? {}),
enabled: true,
...configPatch,
},
},
} as ClawdbotConfig;
}
return {
...next,
channels: {
...next.channels,
feishu: {
...(next.channels?.feishu ?? {}),
enabled: true,
accounts: {
...(next.channels?.feishu?.accounts ?? {}),
[accountId]: {
...(next.channels?.feishu?.accounts?.[accountId] ?? {}),
enabled: true,
...configPatch,
},
},
},
},
} as ClawdbotConfig;
},
},
outbound: {
deliveryMode: "direct",
chunker: (text, limit) => getFeishuRuntime().channel.text.chunkMarkdownText(text, limit),
chunkerMode: "markdown",
textChunkLimit: 4000,
resolveTarget: ({ to, allowFrom, mode }) => {
const trimmed = to?.trim() ?? "";
const allowList = (allowFrom ?? [])
.map((entry) => String(entry).trim())
.filter(Boolean)
.filter((entry) => entry !== "*")
.map((entry) => normalizeFeishuMessagingTarget(entry))
.filter((entry): entry is string => Boolean(entry));
if (trimmed) {
const normalized = normalizeFeishuMessagingTarget(trimmed);
if (!normalized) {
if ((mode === "implicit" || mode === "heartbeat") && allowList.length > 0) {
return { ok: true, to: allowList[0] };
}
return {
ok: false,
error: missingTargetError(
"Feishu",
`${targetHint()} or channels.feishu.dm.allowFrom[0]`,
),
};
}
return { ok: true, to: normalized };
}
if (allowList.length > 0) {
return { ok: true, to: allowList[0] };
}
return {
ok: false,
error: missingTargetError("Feishu", `${targetHint()} or channels.feishu.dm.allowFrom[0]`),
};
},
sendText: async ({ cfg, to, text, accountId, replyToId }) => {
const account = resolveFeishuAccount({ cfg: cfg as ClawdbotConfig, accountId });
if (account.credentialSource === "none") {
throw new Error(
"Feishu not configured: missing appId/appSecret and webhook validation secret",
);
}
const target = parseFeishuMessagingTarget(to);
if (!target) {
throw new Error(`Invalid Feishu target: ${to}. Expected ${targetHint()}.`);
}
const bot = await getBotIdentity(account).catch(() => null);
const replyToMessageId = replyToId?.trim() || undefined;
const result = await sendFeishuTextMessage({
account,
target,
text,
replyToMessageId,
});
return {
channel: "feishu",
messageId: result.messageId,
chatId: target.kind === "chat" ? target.chatId : undefined,
meta: {
target: target.kind === "chat" ? `chat:${target.chatId}` : `user:${target.openId}`,
botOpenId: bot?.openId,
},
};
},
sendMedia: async ({ cfg, to, text, mediaUrl, accountId, replyToId }) => {
const caption = text?.trim() ? `${text.trim()}\n${mediaUrl}` : mediaUrl;
const account = resolveFeishuAccount({ cfg: cfg as ClawdbotConfig, accountId });
if (account.credentialSource === "none") {
throw new Error(
"Feishu not configured: missing appId/appSecret and webhook validation secret",
);
}
const target = parseFeishuMessagingTarget(to);
if (!target) {
throw new Error(`Invalid Feishu target: ${to}. Expected ${targetHint()}.`);
}
const result = await sendFeishuTextMessage({
account,
target,
text: caption,
replyToMessageId: replyToId?.trim() || undefined,
});
return {
channel: "feishu",
messageId: result.messageId,
chatId: target.kind === "chat" ? target.chatId : undefined,
meta: {
warning: "media send is not supported yet; delivered as text URL",
},
};
},
},
status: {
defaultRuntime: {
accountId: DEFAULT_ACCOUNT_ID,
running: false,
lastStartAt: null,
lastStopAt: null,
lastError: null,
mode: null,
webhookPath: null,
lastInboundAt: null,
lastOutboundAt: null,
},
buildChannelSummary: ({ snapshot }) => ({
configured: snapshot.configured ?? false,
running: snapshot.running ?? false,
mode: snapshot.mode ?? null,
webhookPath: snapshot.webhookPath ?? null,
lastInboundAt: snapshot.lastInboundAt ?? null,
lastOutboundAt: snapshot.lastOutboundAt ?? null,
lastStartAt: snapshot.lastStartAt ?? null,
lastStopAt: snapshot.lastStopAt ?? null,
lastError: snapshot.lastError ?? null,
probe: snapshot.probe,
lastProbeAt: snapshot.lastProbeAt ?? null,
}),
probeAccount: async ({ account }) => await probeFeishu(account),
buildAccountSnapshot: ({ account, runtime, probe }) => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: account.credentialSource !== "none",
credentialSource: account.credentialSource,
mode: runtime?.mode ?? account.config.mode ?? "http",
webhookPath: account.config.webhookPath,
webhookUrl: account.config.webhookUrl,
running: runtime?.running ?? false,
lastStartAt: runtime?.lastStartAt ?? null,
lastStopAt: runtime?.lastStopAt ?? null,
lastError: runtime?.lastError ?? null,
lastInboundAt: runtime?.lastInboundAt ?? null,
lastOutboundAt: runtime?.lastOutboundAt ?? null,
probe,
}),
},
gateway: {
startAccount: async (ctx) => {
const account = ctx.account;
const mode = account.config.mode ?? "http";
ctx.log?.info(
`[${account.accountId}] starting Feishu ${mode === "ws" ? "long connection" : "webhook"}`,
);
ctx.setStatus({
accountId: account.accountId,
running: true,
lastStartAt: Date.now(),
mode,
webhookPath: mode === "http" ? resolveFeishuWebhookPath({ account }) : null,
});
await startFeishuMonitor({
account,
config: ctx.cfg as ClawdbotConfig,
runtime: ctx.runtime,
abortSignal: ctx.abortSignal,
webhookPath: account.config.webhookPath,
webhookUrl: account.config.webhookUrl,
statusSink: (patch) => ctx.setStatus({ accountId: account.accountId, ...patch }),
});
},
},
};