diff --git a/.github/labeler.yml b/.github/labeler.yml index 5c19fa418..5d98105aa 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -9,6 +9,11 @@ - "src/discord/**" - "extensions/discord/**" - "docs/channels/discord.md" +"channel: feishu": + - changed-files: + - any-glob-to-any-file: + - "extensions/feishu/**" + - "docs/channels/feishu.md" "channel: googlechat": - changed-files: - any-glob-to-any-file: diff --git a/docs/channels/feishu.md b/docs/channels/feishu.md new file mode 100644 index 000000000..cf191b4e9 --- /dev/null +++ b/docs/channels/feishu.md @@ -0,0 +1,59 @@ +--- +summary: "Feishu (Lark) bot support status, capabilities, and configuration" +read_when: + - Working on Feishu channel features +--- +# Feishu (Lark) + +Status: beta; inbound via WebSocket using `@larksuiteoapi/node-sdk`. + +## Quick setup +1) Create a Feishu/Lark app and enable the IM message receive event. +2) Subscribe to `im.message.receive_v1` in your app settings. +3) Configure `channels.feishu.appId` and `channels.feishu.appSecret`. +4) Start the gateway. + +Minimal config: +```json5 +{ + channels: { + feishu: { + enabled: true, + appId: "YOUR_APP_ID", + appSecret: "YOUR_APP_SECRET" + } + } +} +``` + +Multi-account example: +```json5 +{ + channels: { + feishu: { + accounts: { + work: { appId: "APP_ID", appSecret: "APP_SECRET" }, + personal: { appId: "APP_ID", appSecret: "APP_SECRET" } + } + } + } +} +``` + +## How it works +- Messages are received over the Feishu WebSocket event stream. +- Replies are sent back to the same `chat_id`. +- DMs use pairing by default (`channels.feishu.dm.policy`). +- Group chats can be restricted with `channels.feishu.groupPolicy` and `channels.feishu.groups`. + +## Target formats +- `chat:` for group chats. +- `user:` for direct messages. + +## Configuration reference (Feishu) +- `channels.feishu.appId`: Feishu App ID. +- `channels.feishu.appSecret`: Feishu App Secret. +- `channels.feishu.dm.policy`: DM policy (`pairing`, `allowlist`, `open`, `disabled`). +- `channels.feishu.dm.allowFrom`: allowlist for DMs when policy is `allowlist` or `open`. +- `channels.feishu.groupPolicy`: `open`, `allowlist`, or `disabled`. +- `channels.feishu.groups`: per-chat overrides keyed by `chat_id` (supports `requireMention`, `tools`, `users`). diff --git a/docs/channels/index.md b/docs/channels/index.md index 10651e6d1..14801f66a 100644 --- a/docs/channels/index.md +++ b/docs/channels/index.md @@ -14,6 +14,7 @@ Text is supported everywhere; media and reactions vary by channel. - [WhatsApp](/channels/whatsapp) — Most popular; uses Baileys and requires QR pairing. - [Telegram](/channels/telegram) — Bot API via grammY; supports groups. - [Discord](/channels/discord) — Discord Bot API + Gateway; supports servers, channels, and DMs. +- [Feishu](/channels/feishu) — Lark/Feishu app via WebSocket events. - [Slack](/channels/slack) — Bolt SDK; workspace apps. - [Google Chat](/channels/googlechat) — Google Chat API app via HTTP webhook. - [Mattermost](/channels/mattermost) — Bot API + WebSocket; channels, groups, DMs (plugin, installed separately). diff --git a/extensions/feishu/index.ts b/extensions/feishu/index.ts new file mode 100644 index 000000000..fb318d465 --- /dev/null +++ b/extensions/feishu/index.ts @@ -0,0 +1,18 @@ +import type { MoltbotPluginApi } from "clawdbot/plugin-sdk"; +import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk"; + +import { feishuPlugin } from "./src/channel.js"; +import { setFeishuRuntime } from "./src/runtime.js"; + +const plugin = { + id: "feishu", + name: "Feishu", + description: "Feishu channel plugin", + configSchema: emptyPluginConfigSchema(), + register(api: MoltbotPluginApi) { + setFeishuRuntime(api.runtime); + api.registerChannel({ plugin: feishuPlugin }); + }, +}; + +export default plugin; diff --git a/extensions/feishu/moltbot.plugin.json b/extensions/feishu/moltbot.plugin.json new file mode 100644 index 000000000..b640fa22b --- /dev/null +++ b/extensions/feishu/moltbot.plugin.json @@ -0,0 +1,11 @@ +{ + "id": "feishu", + "name": "Feishu", + "description": "Feishu channel plugin", + "channels": ["feishu"], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/feishu/package.json b/extensions/feishu/package.json new file mode 100644 index 000000000..f020d151a --- /dev/null +++ b/extensions/feishu/package.json @@ -0,0 +1,17 @@ +{ + "name": "@moltbot/feishu", + "version": "2026.1.27-beta.1", + "type": "module", + "description": "Moltbot Feishu channel plugin", + "moltbot": { + "extensions": [ + "./index.ts" + ] + }, + "dependencies": { + "@larksuiteoapi/node-sdk": "1.56.1" + }, + "devDependencies": { + "moltbot": "workspace:*" + } +} diff --git a/extensions/feishu/src/accounts.ts b/extensions/feishu/src/accounts.ts new file mode 100644 index 000000000..0c35cf6d4 --- /dev/null +++ b/extensions/feishu/src/accounts.ts @@ -0,0 +1,73 @@ +import type { FeishuAccountConfig, MoltbotConfig } from "clawdbot/plugin-sdk"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "clawdbot/plugin-sdk"; + +export type ResolvedFeishuAccount = { + accountId: string; + enabled: boolean; + name?: string; + appId?: string; + appSecret?: string; + config: FeishuAccountConfig; +}; + +function listConfiguredAccountIds(cfg: MoltbotConfig): string[] { + const accounts = cfg.channels?.feishu?.accounts; + if (!accounts || typeof accounts !== "object") return []; + return Object.keys(accounts).filter(Boolean); +} + +export function listFeishuAccountIds(cfg: MoltbotConfig): string[] { + const ids = listConfiguredAccountIds(cfg); + if (ids.length === 0) return [DEFAULT_ACCOUNT_ID]; + return ids.sort((a, b) => a.localeCompare(b)); +} + +export function resolveDefaultFeishuAccountId(cfg: MoltbotConfig): string { + const ids = listFeishuAccountIds(cfg); + if (ids.includes(DEFAULT_ACCOUNT_ID)) return DEFAULT_ACCOUNT_ID; + return ids[0] ?? DEFAULT_ACCOUNT_ID; +} + +function resolveAccountConfig( + cfg: MoltbotConfig, + accountId: string, +): FeishuAccountConfig | undefined { + const accounts = cfg.channels?.feishu?.accounts; + if (!accounts || typeof accounts !== "object") return undefined; + return accounts[accountId] as FeishuAccountConfig | undefined; +} + +function mergeFeishuAccountConfig(cfg: MoltbotConfig, accountId: string): FeishuAccountConfig { + const { accounts: _ignored, ...base } = (cfg.channels?.feishu ?? {}) as FeishuAccountConfig & { + accounts?: unknown; + }; + const account = resolveAccountConfig(cfg, accountId) ?? {}; + return { ...base, ...account }; +} + +export function resolveFeishuAccount(params: { + cfg: MoltbotConfig; + accountId?: string | null; +}): ResolvedFeishuAccount { + const accountId = normalizeAccountId(params.accountId); + const baseEnabled = params.cfg.channels?.feishu?.enabled !== false; + const merged = mergeFeishuAccountConfig(params.cfg, accountId); + const accountEnabled = merged.enabled !== false; + const enabled = baseEnabled && accountEnabled; + const appId = merged.appId?.trim() || undefined; + const appSecret = merged.appSecret?.trim() || undefined; + return { + accountId, + enabled, + name: merged.name?.trim() || undefined, + appId, + appSecret, + config: merged, + }; +} + +export function listEnabledFeishuAccounts(cfg: MoltbotConfig): ResolvedFeishuAccount[] { + return listFeishuAccountIds(cfg) + .map((accountId) => resolveFeishuAccount({ cfg, accountId })) + .filter((account) => account.enabled); +} diff --git a/extensions/feishu/src/channel.ts b/extensions/feishu/src/channel.ts new file mode 100644 index 000000000..b4c828dab --- /dev/null +++ b/extensions/feishu/src/channel.ts @@ -0,0 +1,318 @@ +import type { ChannelPlugin, MoltbotConfig } from "clawdbot/plugin-sdk"; +import { + applyAccountNameToChannelSection, + buildChannelConfigSchema, + DEFAULT_ACCOUNT_ID, + deleteAccountFromConfigSection, + formatPairingApproveHint, + getChatChannelMeta, + migrateBaseNameToDefaultAccount, + missingTargetError, + normalizeAccountId, + PAIRING_APPROVED_MESSAGE, + setAccountEnabledInConfigSection, +} from "clawdbot/plugin-sdk"; +import { FeishuConfigSchema } from "clawdbot/plugin-sdk"; + +import { + listFeishuAccountIds, + resolveDefaultFeishuAccountId, + resolveFeishuAccount, + type ResolvedFeishuAccount, +} from "./accounts.js"; +import { startFeishuMonitor } from "./monitor.js"; +import { resolveFeishuGroupToolPolicy } from "./policy.js"; +import { getFeishuRuntime } from "./runtime.js"; +import { sendFeishuMessage } from "./send.js"; +import { + looksLikeFeishuTargetId, + normalizeFeishuMessagingTarget, +} from "./targets.js"; + +const meta = getChatChannelMeta("feishu"); + +function formatAllowFromEntry(entry: string): string { + return entry + .trim() + .replace(/^(feishu|lark|user|open_id|user_id|union_id|email):/i, "") + .toLowerCase(); +} + +export const feishuPlugin: ChannelPlugin = { + id: "feishu", + meta: { + ...meta, + }, + pairing: { + idLabel: "feishuUserId", + normalizeAllowEntry: (entry) => formatAllowFromEntry(entry), + notifyApproval: async ({ cfg, id }) => { + const account = resolveFeishuAccount({ + cfg: cfg as MoltbotConfig, + accountId: DEFAULT_ACCOUNT_ID, + }); + if (!account.appId || !account.appSecret) return; + await sendFeishuMessage({ + account, + to: `user:${id}`, + text: PAIRING_APPROVED_MESSAGE, + }); + }, + }, + capabilities: { + chatTypes: ["direct", "channel"], + reactions: false, + threads: false, + media: false, + nativeCommands: false, + }, + reload: { configPrefixes: ["channels.feishu"] }, + configSchema: buildChannelConfigSchema(FeishuConfigSchema), + config: { + listAccountIds: (cfg) => listFeishuAccountIds(cfg as MoltbotConfig), + resolveAccount: (cfg, accountId) => + resolveFeishuAccount({ cfg: cfg as MoltbotConfig, accountId }), + defaultAccountId: (cfg) => resolveDefaultFeishuAccountId(cfg as MoltbotConfig), + setAccountEnabled: ({ cfg, accountId, enabled }) => + setAccountEnabledInConfigSection({ + cfg: cfg as MoltbotConfig, + sectionKey: "feishu", + accountId, + enabled, + allowTopLevel: true, + }), + deleteAccount: ({ cfg, accountId }) => + deleteAccountFromConfigSection({ + cfg: cfg as MoltbotConfig, + sectionKey: "feishu", + accountId, + clearBaseFields: ["appId", "appSecret", "name"], + }), + isConfigured: (account) => Boolean(account.appId?.trim() && account.appSecret?.trim()), + describeAccount: (account) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: Boolean(account.appId?.trim() && account.appSecret?.trim()), + }), + resolveAllowFrom: ({ cfg, accountId }) => + (resolveFeishuAccount({ cfg: cfg as MoltbotConfig, accountId }).config.dm?.allowFrom ?? []).map( + (entry) => String(entry), + ), + formatAllowFrom: ({ allowFrom }) => + allowFrom + .map((entry) => String(entry).trim()) + .filter(Boolean) + .map(formatAllowFromEntry), + }, + security: { + resolveDmPolicy: ({ cfg, accountId, account }) => { + const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID; + const useAccountPath = Boolean( + (cfg as MoltbotConfig).channels?.feishu?.accounts?.[resolvedAccountId], + ); + const allowFromPath = useAccountPath + ? `channels.feishu.accounts.${resolvedAccountId}.dm.` + : "channels.feishu.dm."; + return { + policy: account.config.dm?.policy ?? "pairing", + allowFrom: account.config.dm?.allowFrom ?? [], + allowFromPath, + approveHint: formatPairingApproveHint("feishu"), + normalizeEntry: (raw) => formatAllowFromEntry(raw), + }; + }, + }, + groups: { + resolveToolPolicy: resolveFeishuGroupToolPolicy, + }, + messaging: { + normalizeTarget: normalizeFeishuMessagingTarget, + targetResolver: { + looksLikeId: looksLikeFeishuTargetId, + hint: "", + }, + }, + outbound: { + deliveryMode: "direct", + chunker: (text, limit) => { + const runtime = getFeishuRuntime(); + return runtime.channel.text.chunkText(text, limit); + }, + chunkerMode: "text", + textChunkLimit: 4000, + resolveTarget: ({ to, allowFrom, mode }) => { + const trimmed = to?.trim() ?? ""; + const allowListRaw = (allowFrom ?? []).map((entry) => String(entry).trim()).filter(Boolean); + const allowList = allowListRaw + .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", + " 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", + " or channels.feishu.dm.allowFrom[0]", + ), + }; + }, + sendText: async ({ cfg, to, text, accountId }) => { + const account = resolveFeishuAccount({ cfg: cfg as MoltbotConfig, accountId }); + const result = await sendFeishuMessage({ account, to, text }); + return { channel: "feishu", messageId: result.messageId }; + }, + sendMedia: async ({ mediaUrl }) => { + if (!mediaUrl) { + throw new Error("Feishu mediaUrl is required."); + } + throw new Error("Feishu media is not supported yet."); + }, + }, + status: { + defaultRuntime: { + accountId: DEFAULT_ACCOUNT_ID, + running: false, + lastStartAt: null, + lastStopAt: null, + lastError: null, + }, + buildChannelSummary: ({ snapshot }) => ({ + configured: snapshot.configured ?? false, + running: snapshot.running ?? false, + lastStartAt: snapshot.lastStartAt ?? null, + lastStopAt: snapshot.lastStopAt ?? null, + lastError: snapshot.lastError ?? null, + lastInboundAt: snapshot.lastInboundAt ?? null, + lastOutboundAt: snapshot.lastOutboundAt ?? null, + }), + buildAccountSnapshot: ({ account, runtime }) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: Boolean(account.appId?.trim() && account.appSecret?.trim()), + running: runtime?.running ?? false, + lastStartAt: runtime?.lastStartAt ?? null, + lastStopAt: runtime?.lastStopAt ?? null, + lastError: runtime?.lastError ?? null, + lastInboundAt: runtime?.lastInboundAt ?? null, + lastOutboundAt: runtime?.lastOutboundAt ?? null, + dmPolicy: account.config.dm?.policy ?? "pairing", + }), + }, + gateway: { + startAccount: async (ctx) => { + const account = ctx.account; + if (!account.appId?.trim() || !account.appSecret?.trim()) { + throw new Error( + `Feishu appId/appSecret missing for account "${account.accountId}" (set channels.feishu.appId/appSecret or channels.feishu.accounts.${account.accountId}.appId/appSecret).`, + ); + } + ctx.log?.info(`[${account.accountId}] starting Feishu websocket`); + ctx.setStatus({ + accountId: account.accountId, + running: true, + lastStartAt: Date.now(), + }); + const stop = await startFeishuMonitor({ + account, + config: ctx.cfg as MoltbotConfig, + runtime: ctx.runtime, + abortSignal: ctx.abortSignal, + statusSink: (patch) => ctx.setStatus({ accountId: account.accountId, ...patch }), + }); + return () => { + stop(); + ctx.setStatus({ + accountId: account.accountId, + running: false, + lastStopAt: Date.now(), + }); + }; + }, + }, + setup: { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToChannelSection({ + cfg: cfg as MoltbotConfig, + channelKey: "feishu", + accountId, + name, + }), + validateInput: ({ input }) => { + if (!input.appId || !input.appSecret) { + return "Feishu requires appId and appSecret."; + } + return null; + }, + applyAccountConfig: ({ cfg, accountId, input }) => { + const namedConfig = applyAccountNameToChannelSection({ + cfg: cfg as MoltbotConfig, + channelKey: "feishu", + accountId, + name: input.name, + }); + const next = + accountId !== DEFAULT_ACCOUNT_ID + ? migrateBaseNameToDefaultAccount({ + cfg: namedConfig, + channelKey: "feishu", + }) + : namedConfig; + if (accountId === DEFAULT_ACCOUNT_ID) { + return { + ...next, + channels: { + ...next.channels, + feishu: { + ...next.channels?.feishu, + enabled: true, + appId: input.appId, + appSecret: input.appSecret, + }, + }, + } as MoltbotConfig; + } + return { + ...next, + channels: { + ...next.channels, + feishu: { + ...next.channels?.feishu, + enabled: true, + accounts: { + ...next.channels?.feishu?.accounts, + [accountId]: { + ...next.channels?.feishu?.accounts?.[accountId], + enabled: true, + appId: input.appId, + appSecret: input.appSecret, + }, + }, + }, + }, + } as MoltbotConfig; + }, + }, +}; diff --git a/extensions/feishu/src/monitor.ts b/extensions/feishu/src/monitor.ts new file mode 100644 index 000000000..5b7c9b375 --- /dev/null +++ b/extensions/feishu/src/monitor.ts @@ -0,0 +1,498 @@ +import * as Lark from "@larksuiteoapi/node-sdk"; + +import type { MoltbotConfig } from "clawdbot/plugin-sdk"; +import { + resolveMentionGatingWithBypass, + type ReplyPayload, +} from "clawdbot/plugin-sdk"; + +import { getFeishuRuntime } from "./runtime.js"; +import type { ResolvedFeishuAccount } from "./accounts.js"; +import { sendFeishuMessage } from "./send.js"; + +export type FeishuRuntimeEnv = { + log?: (message: string) => void; + error?: (message: string) => void; +}; + +export type FeishuMonitorOptions = { + account: ResolvedFeishuAccount; + config: MoltbotConfig; + runtime: FeishuRuntimeEnv; + abortSignal: AbortSignal; + statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void; +}; + +type FeishuSender = { + sender_type?: string; + sender_id?: { + user_id?: string; + open_id?: string; + union_id?: string; + }; +}; + +type FeishuMessage = { + chat_id?: string; + chat_type?: string; + message_id?: string; + message_type?: string; + content?: string; + create_time?: string; + root_id?: string; + parent_id?: string; +}; + +type FeishuMessageEvent = { + message?: FeishuMessage; + sender?: FeishuSender; +}; + +type FeishuCoreRuntime = ReturnType; + +type FeishuWsClient = Lark.WSClient & { + stop?: () => void | Promise; +}; + +function logVerbose(core: FeishuCoreRuntime, runtime: FeishuRuntimeEnv, message: string): void { + if (core.logging.shouldLogVerbose()) { + runtime.log?.(`[feishu] ${message}`); + } +} + +function requireFeishuCredentials(account: ResolvedFeishuAccount): { + appId: string; + appSecret: string; +} { + const appId = account.appId?.trim(); + const appSecret = account.appSecret?.trim(); + if (!appId || !appSecret) { + throw new Error( + `Feishu credentials missing for account "${account.accountId}" (set channels.feishu.appId/appSecret or channels.feishu.accounts.${account.accountId}.appId/appSecret).`, + ); + } + return { appId, appSecret }; +} + +function resolveSenderInfo( + sender?: FeishuSender, +): { id: string; idType: "open_id" | "user_id" | "union_id" } | null { + if (!sender) return null; + const ids = sender.sender_id; + if (ids?.open_id?.trim()) { + return { id: ids.open_id.trim(), idType: "open_id" }; + } + if (ids?.user_id?.trim()) { + return { id: ids.user_id.trim(), idType: "user_id" }; + } + if (ids?.union_id?.trim()) { + return { id: ids.union_id.trim(), idType: "union_id" }; + } + return null; +} + +function parseMessageText(params: { + content: string; + runtime: FeishuRuntimeEnv; + accountId: string; +}): string | null { + const trimmed = params.content.trim(); + if (!trimmed) return null; + try { + const parsed = JSON.parse(trimmed) as unknown; + if (!parsed || typeof parsed !== "object") return null; + const text = (parsed as { text?: unknown }).text; + return typeof text === "string" ? text : null; + } catch (err) { + params.runtime.error?.( + `[${params.accountId}] Feishu message content parse failed: ${String(err)}`, + ); + return null; + } +} + +function resolveMentionState(text: string): { hasAnyMention: boolean; wasMentioned: boolean } { + const hasAnyMention = / 0; + return { entry, wildcard, allowlistConfigured }; +} + +function resolveGroupPolicy(cfg: MoltbotConfig, account: ResolvedFeishuAccount): "open" | "allowlist" | "disabled" { + const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; + return account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; +} + +function resolveChatType(chatType?: string): "direct" | "channel" { + if (!chatType) return "channel"; + return chatType === "p2p" ? "direct" : "channel"; +} + +async function stopFeishuWsClient(wsClient: FeishuWsClient, runtime: FeishuRuntimeEnv): Promise { + const stop = wsClient.stop; + if (typeof stop !== "function") return; + try { + await Promise.resolve(stop.call(wsClient)); + } catch (err) { + runtime.error?.(`feishu websocket stop failed: ${String(err)}`); + } +} + +async function deliverFeishuReply(params: { + payload: ReplyPayload; + account: ResolvedFeishuAccount; + chatId: string; + runtime: FeishuRuntimeEnv; + core: FeishuCoreRuntime; + config: MoltbotConfig; + statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void; +}): Promise { + const { payload, account, chatId, runtime, core, config, statusSink } = params; + const text = payload.text ?? ""; + if (!text.trim()) return; + const textLimit = core.channel.text.resolveTextChunkLimit(config, "feishu", account.accountId, { + fallbackLimit: 4000, + }); + const chunkMode = core.channel.text.resolveChunkMode(config, "feishu", account.accountId); + const chunks = core.channel.text.chunkTextWithMode(text, textLimit, chunkMode); + for (const chunk of chunks) { + await sendFeishuMessage({ account, to: `chat:${chatId}`, text: chunk }); + statusSink?.({ lastOutboundAt: Date.now() }); + } + logVerbose(core, runtime, `reply sent to chat=${chatId}`); +} + +async function handleFeishuMessage(params: { + event: FeishuMessageEvent; + account: ResolvedFeishuAccount; + config: MoltbotConfig; + runtime: FeishuRuntimeEnv; + core: FeishuCoreRuntime; + statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void; +}): Promise { + const { event, account, config, runtime, core, statusSink } = params; + const message = event.message; + if (!message) return; + const chatId = message.chat_id?.trim(); + if (!chatId) return; + const senderInfo = resolveSenderInfo(event.sender); + if (!senderInfo) return; + const senderId = senderInfo.id; + const senderType = event.sender?.sender_type; + if (senderType && senderType !== "user" && account.config.allowBots !== true) { + logVerbose(core, runtime, `drop bot message senderType=${senderType}`); + return; + } + const messageType = message.message_type?.trim() || ""; + if (messageType && messageType !== "text") { + logVerbose(core, runtime, `drop unsupported message type=${messageType}`); + return; + } + if (typeof message.content !== "string") return; + const rawBody = parseMessageText({ + content: message.content, + runtime, + accountId: account.accountId, + }); + if (rawBody === null) return; + const chatType = resolveChatType(message.chat_type); + const isGroup = chatType !== "direct"; + + statusSink?.({ lastInboundAt: Date.now() }); + + const groupPolicy = resolveGroupPolicy(config, account); + const groupInfo = resolveGroupEntry(account, chatId); + const groupEntry = groupInfo.entry; + if (isGroup) { + if (groupPolicy === "disabled") return; + const groupAllowed = Boolean(groupEntry) || Boolean(groupInfo.wildcard); + if (groupPolicy === "allowlist") { + if (!groupInfo.allowlistConfigured) { + logVerbose(core, runtime, `drop group message (allowlist empty, chat=${chatId})`); + return; + } + if (!groupAllowed) { + logVerbose(core, runtime, `drop group message (not allowlisted, chat=${chatId})`); + return; + } + } + if (groupEntry?.enabled === false || groupEntry?.allow === false) { + logVerbose(core, runtime, `drop group message (disabled, chat=${chatId})`); + return; + } + if (groupEntry?.users && groupEntry.users.length > 0) { + const ok = isSenderAllowed(senderId, groupEntry.users.map((value) => String(value))); + if (!ok) { + logVerbose(core, runtime, `drop group message (sender not allowed, ${senderId})`); + return; + } + } + } + + const dmPolicy = account.config.dm?.policy ?? "pairing"; + const configAllowFrom = (account.config.dm?.allowFrom ?? []).map((value) => String(value)); + const shouldComputeAuth = core.channel.commands.shouldComputeCommandAuthorized(rawBody, config); + const storeAllowFrom = !isGroup && (dmPolicy !== "open" || shouldComputeAuth) + ? await core.channel.pairing.readAllowFromStore("feishu").catch((err) => { + runtime.error?.(`feishu: failed reading allowFrom store: ${String(err)}`); + return []; + }) + : []; + const effectiveAllowFrom = [...configAllowFrom, ...storeAllowFrom]; + const commandAllowFrom = isGroup + ? (groupEntry?.users ?? []).map((value) => String(value)) + : effectiveAllowFrom; + const useAccessGroups = config.commands?.useAccessGroups !== false; + const senderAllowedForCommands = isSenderAllowed(senderId, commandAllowFrom); + const commandAuthorized = shouldComputeAuth + ? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({ + useAccessGroups, + authorizers: [ + { + configured: commandAllowFrom.length > 0, + allowed: senderAllowedForCommands, + }, + ], + }) + : undefined; + + let effectiveWasMentioned: boolean | undefined; + if (isGroup) { + const requireMention = groupEntry?.requireMention ?? account.config.requireMention ?? true; + const mentionState = resolveMentionState(rawBody); + const allowTextCommands = core.channel.commands.shouldHandleTextCommands({ + cfg: config, + surface: "feishu", + }); + const mentionGate = resolveMentionGatingWithBypass({ + isGroup: true, + requireMention, + canDetectMention: true, + wasMentioned: mentionState.wasMentioned, + implicitMention: false, + hasAnyMention: mentionState.hasAnyMention, + allowTextCommands, + hasControlCommand: core.channel.text.hasControlCommand(rawBody, config), + commandAuthorized: commandAuthorized === true, + }); + effectiveWasMentioned = mentionGate.effectiveWasMentioned; + if (mentionGate.shouldSkip) { + logVerbose(core, runtime, `drop group message (mention required, chat=${chatId})`); + return; + } + } + + if (!isGroup) { + if (dmPolicy === "disabled" || account.config.dm?.enabled === false) { + logVerbose(core, runtime, `drop DM (dmPolicy=disabled, sender=${senderId})`); + return; + } + + if (dmPolicy !== "open") { + const allowed = senderAllowedForCommands; + if (!allowed) { + if (dmPolicy === "pairing") { + const { code, created } = await core.channel.pairing.upsertPairingRequest({ + channel: "feishu", + id: senderId, + }); + if (created) { + try { + await sendFeishuMessage({ + account, + to: `${senderInfo.idType}:${senderId}`, + text: core.channel.pairing.buildPairingReply({ + channel: "feishu", + idLine: `Your Feishu user id: ${senderId}`, + code, + }), + }); + statusSink?.({ lastOutboundAt: Date.now() }); + } catch (err) { + runtime.error?.(`feishu pairing reply failed: ${String(err)}`); + } + } + } else { + logVerbose(core, runtime, `drop DM (unauthorized, sender=${senderId})`); + } + return; + } + } + } + + if ( + isGroup && + core.channel.commands.isControlCommandMessage(rawBody, config) && + commandAuthorized !== true + ) { + logVerbose(core, runtime, `feishu: drop control command from ${senderId}`); + return; + } + + const route = core.channel.routing.resolveAgentRoute({ + cfg: config, + channel: "feishu", + accountId: account.accountId, + peer: { + kind: isGroup ? "group" : "dm", + id: chatId, + }, + }); + + const fromLabel = isGroup ? `chat:${chatId}` : `user:${senderId}`; + const storePath = core.channel.session.resolveStorePath(config.session?.store, { + agentId: route.agentId, + }); + const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config); + const previousTimestamp = core.channel.session.readSessionUpdatedAt({ + storePath, + sessionKey: route.sessionKey, + }); + const timestampMs = Number(message.create_time); + const timestamp = Number.isFinite(timestampMs) ? timestampMs : undefined; + const body = core.channel.reply.formatAgentEnvelope({ + channel: "Feishu", + from: fromLabel, + timestamp, + previousTimestamp, + envelope: envelopeOptions, + body: rawBody, + }); + + const groupSystemPrompt = groupEntry?.systemPrompt?.trim() || undefined; + + const ctxPayload = core.channel.reply.finalizeInboundContext({ + Body: body, + RawBody: rawBody, + CommandBody: rawBody, + From: `feishu:${senderId}`, + To: `feishu:${chatId}`, + SessionKey: route.sessionKey, + AccountId: route.accountId, + ChatType: isGroup ? "channel" : "direct", + ConversationLabel: fromLabel, + SenderId: senderId, + WasMentioned: isGroup ? effectiveWasMentioned : undefined, + CommandAuthorized: commandAuthorized, + Provider: "feishu", + Surface: "feishu", + MessageSid: message.message_id, + MessageSidFull: message.message_id, + ReplyToId: message.parent_id ?? message.root_id, + ReplyToIdFull: message.parent_id ?? message.root_id, + GroupSpace: isGroup ? chatId : undefined, + GroupSystemPrompt: isGroup ? groupSystemPrompt : undefined, + OriginatingChannel: "feishu", + OriginatingTo: `feishu:${chatId}`, + }); + + void core.channel.session + .recordSessionMetaFromInbound({ + storePath, + sessionKey: ctxPayload.SessionKey ?? route.sessionKey, + ctx: ctxPayload, + }) + .catch((err) => { + runtime.error?.(`feishu: failed updating session meta: ${String(err)}`); + }); + + await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({ + ctx: ctxPayload, + cfg: config, + dispatcherOptions: { + deliver: async (payload) => { + await deliverFeishuReply({ + payload, + account, + chatId, + runtime, + core, + config, + statusSink, + }); + }, + onError: (err, info) => { + runtime.error?.(`[${account.accountId}] Feishu ${info.kind} reply failed: ${String(err)}`); + }, + }, + }); +} + +export async function startFeishuMonitor(opts: FeishuMonitorOptions): Promise<() => void> { + const core = getFeishuRuntime(); + const { appId, appSecret } = requireFeishuCredentials(opts.account); + const loggerLevel = core.logging.shouldLogVerbose() + ? Lark.LoggerLevel.debug + : Lark.LoggerLevel.error; + const wsClient: FeishuWsClient = new Lark.WSClient({ + appId, + appSecret, + loggerLevel, + }); + + const dispatcher = new Lark.EventDispatcher({}).register({ + "im.message.receive_v1": async (data: unknown) => { + const event = data as FeishuMessageEvent; + try { + await handleFeishuMessage({ + event, + account: opts.account, + config: opts.config, + runtime: opts.runtime, + core, + statusSink: opts.statusSink, + }); + } catch (err) { + opts.runtime.error?.(`feishu inbound handler failed: ${String(err)}`); + } + }, + }); + + await Promise.resolve( + wsClient.start({ + eventDispatcher: dispatcher, + }), + ); + + const abortHandler = () => { + void stopFeishuWsClient(wsClient, opts.runtime); + }; + if (opts.abortSignal.aborted) { + abortHandler(); + } else { + opts.abortSignal.addEventListener("abort", abortHandler, { once: true }); + } + + logVerbose(core, opts.runtime, `Feishu WS connected for account=${opts.account.accountId}`); + + return () => { + opts.abortSignal.removeEventListener("abort", abortHandler); + void stopFeishuWsClient(wsClient, opts.runtime); + logVerbose(core, opts.runtime, `Feishu WS stopped for account=${opts.account.accountId}`); + }; +} diff --git a/extensions/feishu/src/policy.ts b/extensions/feishu/src/policy.ts new file mode 100644 index 000000000..063c91048 --- /dev/null +++ b/extensions/feishu/src/policy.ts @@ -0,0 +1,62 @@ +import type { GroupToolPolicyConfig, MoltbotConfig } from "clawdbot/plugin-sdk"; +import { normalizeAccountId, resolveToolsBySender } from "clawdbot/plugin-sdk"; + +type FeishuGroupConfig = { + tools?: GroupToolPolicyConfig; + toolsBySender?: Record; +}; + +type FeishuGroupsConfig = Record | undefined; + +function resolveGroupsConfig( + cfg: MoltbotConfig, + accountId?: string | null, +): FeishuGroupsConfig { + const normalizedAccountId = normalizeAccountId(accountId); + const channelConfig = cfg.channels?.feishu as + | { + accounts?: Record; + groups?: FeishuGroupsConfig; + } + | undefined; + if (!channelConfig) return undefined; + const accountGroups = + channelConfig.accounts?.[normalizedAccountId]?.groups ?? + channelConfig.accounts?.[ + Object.keys(channelConfig.accounts ?? {}).find( + (key) => key.toLowerCase() === normalizedAccountId.toLowerCase(), + ) ?? "" + ]?.groups; + return accountGroups ?? channelConfig.groups; +} + +export function resolveFeishuGroupToolPolicy(params: { + cfg: MoltbotConfig; + accountId?: string | null; + groupId?: string | null; + senderId?: string | null; + senderName?: string | null; + senderUsername?: string | null; +}): GroupToolPolicyConfig | undefined { + const groups = resolveGroupsConfig(params.cfg, params.accountId); + if (!groups) return undefined; + const groupId = params.groupId?.trim(); + const groupConfig = groupId ? groups[groupId] : undefined; + const defaultConfig = groups["*"]; + const groupSenderPolicy = resolveToolsBySender({ + toolsBySender: groupConfig?.toolsBySender, + senderId: params.senderId, + senderName: params.senderName, + senderUsername: params.senderUsername, + }); + if (groupSenderPolicy) return groupSenderPolicy; + if (groupConfig?.tools) return groupConfig.tools; + const defaultSenderPolicy = resolveToolsBySender({ + toolsBySender: defaultConfig?.toolsBySender, + senderId: params.senderId, + senderName: params.senderName, + senderUsername: params.senderUsername, + }); + if (defaultSenderPolicy) return defaultSenderPolicy; + return defaultConfig?.tools; +} diff --git a/extensions/feishu/src/runtime.ts b/extensions/feishu/src/runtime.ts new file mode 100644 index 000000000..9fbb18629 --- /dev/null +++ b/extensions/feishu/src/runtime.ts @@ -0,0 +1,14 @@ +import type { PluginRuntime } from "clawdbot/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; +} diff --git a/extensions/feishu/src/send.ts b/extensions/feishu/src/send.ts new file mode 100644 index 000000000..d40fc97b5 --- /dev/null +++ b/extensions/feishu/src/send.ts @@ -0,0 +1,83 @@ +import * as Lark from "@larksuiteoapi/node-sdk"; + +import type { MoltbotConfig } from "clawdbot/plugin-sdk"; + +import { resolveFeishuAccount, type ResolvedFeishuAccount } from "./accounts.js"; +import { parseFeishuTarget, type FeishuReceiveIdType } from "./targets.js"; + +export type SendFeishuMessageParams = { + account: ResolvedFeishuAccount; + to: string; + text: string; +}; + +export type SendFeishuMessageResult = { + messageId: string; +}; + +function requireFeishuCredentials(account: ResolvedFeishuAccount): { + appId: string; + appSecret: string; +} { + const appId = account.appId?.trim(); + const appSecret = account.appSecret?.trim(); + if (!appId || !appSecret) { + throw new Error( + `Feishu credentials missing for account "${account.accountId}" (set channels.feishu.appId/appSecret or channels.feishu.accounts.${account.accountId}.appId/appSecret).`, + ); + } + return { appId, appSecret }; +} + +function buildTextContent(text: string): string { + return JSON.stringify({ text }); +} + +function readMessageId(value: unknown): string { + if (!value || typeof value !== "object") return ""; + const data = (value as { data?: unknown }).data; + if (!data || typeof data !== "object") return ""; + const record = data as { message_id?: unknown; messageId?: unknown }; + if (typeof record.message_id === "string") return record.message_id; + if (typeof record.messageId === "string") return record.messageId; + return ""; +} + +function resolveTarget(input: string): { receiveIdType: FeishuReceiveIdType; receiveId: string } { + const parsed = parseFeishuTarget(input); + if (!parsed) { + throw new Error( + "Feishu target is required (use chat:, user:, or open_id/user_id/union_id/email prefixes).", + ); + } + return parsed; +} + +export async function sendFeishuMessage( + params: SendFeishuMessageParams, +): Promise { + const { appId, appSecret } = requireFeishuCredentials(params.account); + const target = resolveTarget(params.to); + const client = new Lark.Client({ appId, appSecret }); + const response = await client.im.v1.message.create({ + params: { + receive_id_type: target.receiveIdType, + }, + data: { + receive_id: target.receiveId, + content: buildTextContent(params.text ?? ""), + msg_type: "text", + }, + }); + return { messageId: readMessageId(response) }; +} + +export async function sendFeishuMessageFromConfig(params: { + cfg: MoltbotConfig; + accountId?: string | null; + to: string; + text: string; +}): Promise { + const account = resolveFeishuAccount({ cfg: params.cfg, accountId: params.accountId }); + return await sendFeishuMessage({ account, to: params.to, text: params.text }); +} diff --git a/extensions/feishu/src/targets.ts b/extensions/feishu/src/targets.ts new file mode 100644 index 000000000..1d5d4f1bd --- /dev/null +++ b/extensions/feishu/src/targets.ts @@ -0,0 +1,61 @@ +export type FeishuReceiveIdType = "chat_id" | "open_id" | "user_id" | "union_id" | "email"; + +export type FeishuTarget = { + receiveIdType: FeishuReceiveIdType; + receiveId: string; +}; + +function normalizeInput(raw: string): string { + return raw.trim().replace(/^feishu:/i, ""); +} + +export function parseFeishuTarget(raw: string): FeishuTarget | null { + const trimmed = normalizeInput(raw); + if (!trimmed) return null; + const lowered = trimmed.toLowerCase(); + if (lowered.startsWith("chat:")) { + const id = trimmed.slice("chat:".length).trim(); + return id ? { receiveIdType: "chat_id", receiveId: id } : null; + } + if (lowered.startsWith("user:")) { + const id = trimmed.slice("user:".length).trim(); + return id ? { receiveIdType: "open_id", receiveId: id } : null; + } + if (lowered.startsWith("open_id:")) { + const id = trimmed.slice("open_id:".length).trim(); + return id ? { receiveIdType: "open_id", receiveId: id } : null; + } + if (lowered.startsWith("user_id:")) { + const id = trimmed.slice("user_id:".length).trim(); + return id ? { receiveIdType: "user_id", receiveId: id } : null; + } + if (lowered.startsWith("union_id:")) { + const id = trimmed.slice("union_id:".length).trim(); + return id ? { receiveIdType: "union_id", receiveId: id } : null; + } + if (lowered.startsWith("email:")) { + const id = trimmed.slice("email:".length).trim(); + return id ? { receiveIdType: "email", receiveId: id } : null; + } + if (/^ou_/i.test(trimmed)) return { receiveIdType: "open_id", receiveId: trimmed }; + if (/^oc_/i.test(trimmed)) return { receiveIdType: "chat_id", receiveId: trimmed }; + return { receiveIdType: "chat_id", receiveId: trimmed }; +} + +export function looksLikeFeishuTargetId(raw: string): boolean { + const trimmed = normalizeInput(raw); + if (!trimmed) return false; + if (/^(chat|user|open_id|user_id|union_id|email):/i.test(trimmed)) return true; + return /^oc_/.test(trimmed) || /^ou_/.test(trimmed); +} + +export function normalizeFeishuMessagingTarget(raw: string): string | undefined { + const parsed = parseFeishuTarget(raw); + if (!parsed) return undefined; + if (parsed.receiveIdType === "chat_id") return `chat:${parsed.receiveId}`; + if (parsed.receiveIdType === "open_id") return `user:${parsed.receiveId}`; + if (parsed.receiveIdType === "user_id") return `user_id:${parsed.receiveId}`; + if (parsed.receiveIdType === "union_id") return `union_id:${parsed.receiveId}`; + if (parsed.receiveIdType === "email") return `email:${parsed.receiveId}`; + return undefined; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9c0f99928..21c413a55 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -172,6 +172,13 @@ importers: zod: specifier: ^4.3.6 version: 4.3.6 + optionalDependencies: + '@napi-rs/canvas': + specifier: ^0.1.88 + version: 0.1.88 + node-llama-cpp: + specifier: 3.15.0 + version: 3.15.0(typescript@5.9.3) devDependencies: '@grammyjs/types': specifier: ^3.23.0 @@ -254,13 +261,6 @@ importers: wireit: specifier: ^0.14.12 version: 0.14.12 - optionalDependencies: - '@napi-rs/canvas': - specifier: ^0.1.88 - version: 0.1.88 - node-llama-cpp: - specifier: 3.15.0 - version: 3.15.0(typescript@5.9.3) extensions/bluebubbles: {} @@ -304,6 +304,16 @@ importers: extensions/discord: {} + extensions/feishu: + dependencies: + '@larksuiteoapi/node-sdk': + specifier: 1.56.1 + version: 1.56.1 + devDependencies: + moltbot: + specifier: workspace:* + version: link:../.. + extensions/google-antigravity-auth: {} extensions/google-gemini-cli-auth: {} @@ -383,12 +393,12 @@ importers: '@microsoft/agents-hosting-extensions-teams': specifier: ^1.2.2 version: 1.2.2 - moltbot: - specifier: workspace:* - version: link:../.. express: specifier: ^5.2.1 version: 5.2.1 + moltbot: + specifier: workspace:* + version: link:../.. proper-lockfile: specifier: ^4.1.2 version: 4.1.2 @@ -1126,89 +1136,105 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -1288,24 +1314,28 @@ packages: engines: {node: '>= 18'} cpu: [arm64] os: [linux] + libc: [glibc] '@lancedb/lancedb-linux-arm64-musl@0.23.0': resolution: {integrity: sha512-c2UCtGoYjA3oDdw5y3RLK7J2th3rSjYBng+1I03vU9g092y8KATAJO/lV2AtyxSC+esSuyY1dMEaj8ADcXjZAA==} engines: {node: '>= 18'} cpu: [arm64] os: [linux] + libc: [musl] '@lancedb/lancedb-linux-x64-gnu@0.23.0': resolution: {integrity: sha512-OPL7tK3JCTx43ZxvbVs+CljfCer0KrojANQbcJ2V4VAp6XBhKx1sBAlIVGuCrd93pA8UOUP3iHsM7aglPo6rCg==} engines: {node: '>= 18'} cpu: [x64] os: [linux] + libc: [glibc] '@lancedb/lancedb-linux-x64-musl@0.23.0': resolution: {integrity: sha512-1ZEoQDwOrKvwPyAG+95/r1NYqX8Ca5bRek8Vr62CzWCEmHd/pFeEGWZ5STrkh+Bt3GLdi2JOivFtRbmuBAJypQ==} engines: {node: '>= 18'} cpu: [x64] os: [linux] + libc: [musl] '@lancedb/lancedb-win32-arm64-msvc@0.23.0': resolution: {integrity: sha512-OuD1mkrgXvijRlXdbx3LvfuorO04FD5qHegnTOWGXh1sIwwrvvhcJAvXUGBNLY4n/lsWvA+xTjtMwRjUitvPKg==} @@ -1322,11 +1352,13 @@ packages: '@lancedb/lancedb@0.23.0': resolution: {integrity: sha512-aYrIoEG24AC+wILCL57Ius/Y4yU+xFHDPKLvmjzzN4byAjzeIGF0TC86S5RBt4Ji+dxS7yIWV5Q/gE5/fybIFQ==} engines: {node: '>= 18'} - cpu: [x64, arm64] os: [darwin, linux, win32] peerDependencies: apache-arrow: '>=15.0.0 <=18.1.0' + '@larksuiteoapi/node-sdk@1.56.1': + resolution: {integrity: sha512-/ixtyJnWOmcupKgDXz+6G6qTLMi3cNrR+LGOuq2PMwcJ6hhXTUJNyAF+ADY7ah9OoeDniGU/UJwMb2gqKdxwcA==} + '@line/bot-sdk@10.6.0': resolution: {integrity: sha512-4hSpglL/G/cW2JCcohaYz/BS0uOSJNV9IEYdMm0EiPEvDLayoI2hGq2D86uYPQFD2gvgkyhmzdShpWLG3P5r3w==} engines: {node: '>=20'} @@ -1398,24 +1430,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@mariozechner/clipboard-linux-riscv64-gnu@0.3.0': resolution: {integrity: sha512-4BC08CIaOXSSAGRZLEjqJmQfioED8ohAzwt0k2amZPEbH96YKoBNorq5EdwPf5VT+odS0DeyCwhwtxokRLZIvQ==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] + libc: [glibc] '@mariozechner/clipboard-linux-x64-gnu@0.3.0': resolution: {integrity: sha512-GpNY5Y9nOzr0Vt0Qi5U88qwe6piiIHk44kSMexl8ns90LluN5UTNYmyfi7Xq3/lmPZCpnB2xvBTYbsXCxnopIA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@mariozechner/clipboard-linux-x64-musl@0.3.0': resolution: {integrity: sha512-+PnR48/x9GMY5Kh8BLjzHMx6trOegMtxAuqTM9X/bhV3QuW6sLLd7nojDHSGj/ZueK6i0tcQxvOrgNLozVtNDA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@mariozechner/clipboard-win32-arm64-msvc@0.3.0': resolution: {integrity: sha512-+dy2vZ1Ph4EYj0cotB+bVUVk/uKl2bh9LOp/zlnFqoCCYDN6sm+L0VyIOPPo3hjoEVdGpHe1MUxp3qG/OLwXgg==} @@ -1516,30 +1552,35 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@napi-rs/canvas-linux-arm64-musl@0.1.88': resolution: {integrity: sha512-kYyNrUsHLkoGHBc77u4Unh067GrfiCUMbGHC2+OTxbeWfZkPt2o32UOQkhnSswKd9Fko/wSqqGkY956bIUzruA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@napi-rs/canvas-linux-riscv64-gnu@0.1.88': resolution: {integrity: sha512-HVuH7QgzB0yavYdNZDRyAsn/ejoXB0hn8twwFnOqUbCCdkV+REna7RXjSR7+PdfW0qMQ2YYWsLvVBT5iL/mGpw==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] + libc: [glibc] '@napi-rs/canvas-linux-x64-gnu@0.1.88': resolution: {integrity: sha512-hvcvKIcPEQrvvJtJnwD35B3qk6umFJ8dFIr8bSymfrSMem0EQsfn1ztys8ETIFndTwdNWJKWluvxztA41ivsEw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@napi-rs/canvas-linux-x64-musl@0.1.88': resolution: {integrity: sha512-eSMpGYY2xnZSQ6UxYJ6plDboxq4KeJ4zT5HaVkUnbObNN6DlbJe0Mclh3wifAmquXfrlgTZt6zhHsUgz++AK6g==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@napi-rs/canvas-win32-arm64-msvc@0.1.88': resolution: {integrity: sha512-qcIFfEgHrchyYqRrxsCeTQgpJZ/GqHiqPcU/Fvw/ARVlQeDX1VyFH+X+0gCR2tca6UJrq96vnW+5o7buCq+erA==} @@ -1585,36 +1626,42 @@ packages: engines: {node: '>=20.0.0'} cpu: [arm64, x64] os: [linux] + libc: [glibc] '@node-llama-cpp/linux-armv7l@3.15.0': resolution: {integrity: sha512-ZuZ3q6mejQnEP4o22la7zBv7jNR+IZfgItDm3KjAl04HUXTKJ43HpNwjnf9GyYYd+dEgtoX0MESvWz4RnGH8Jw==} engines: {node: '>=20.0.0'} cpu: [arm, x64] os: [linux] + libc: [glibc] '@node-llama-cpp/linux-x64-cuda-ext@3.15.0': resolution: {integrity: sha512-wQwgSl7Qm8vH56oBt7IuWWDNNsDECkVMS000C92wl3PkbzjwZFiWzehwa+kF8Lr2BBMiCJNkI5nEabhYH3RN2Q==} engines: {node: '>=20.0.0'} cpu: [x64] os: [linux] + libc: [glibc] '@node-llama-cpp/linux-x64-cuda@3.15.0': resolution: {integrity: sha512-mDjyVulCTRYilm9Emm3lDMx7dbI1vzGqk28Pj28shartjERTUu8aUNDYOmVKNMLpUKS1akw7vy0lMF8t4qswxQ==} engines: {node: '>=20.0.0'} cpu: [x64] os: [linux] + libc: [glibc] '@node-llama-cpp/linux-x64-vulkan@3.15.0': resolution: {integrity: sha512-htVIthQKq/rr8v5e7NiVtcHsstqTBAAC50kUymmDMbrzAu6d/EHacCJpNbU57b1UUa1nKN5cBqr6Jr+QqEalMA==} engines: {node: '>=20.0.0'} cpu: [x64] os: [linux] + libc: [glibc] '@node-llama-cpp/linux-x64@3.15.0': resolution: {integrity: sha512-etUuTqSyNefRObqc5+JZviNTkuef2XEtHcQLaamEIWwjI1dj7nTD2YMZPBP7H3M3E55HSIY82vqCQ1bp6ZILiA==} engines: {node: '>=20.0.0'} cpu: [x64] os: [linux] + libc: [glibc] '@node-llama-cpp/mac-arm64-metal@3.15.0': resolution: {integrity: sha512-3Vkq6bpyQZaIzoaLLP7H2Tt8ty5BS0zxUY2pX0ox2S9P4fp8Au0CCJuUJF4V+EKi+/PTn70A6R1QCkRMfMQJig==} @@ -1962,21 +2009,25 @@ packages: resolution: {integrity: sha512-GubkQeQT5d3B/Jx/IiR7NMkSmXrCZcVI0BPh1i7mpFi8HgD1hQ/LbhiBKAMsMqs5bbugdQOgBEl8bOhe8JhW1g==} cpu: [arm64] os: [linux] + libc: [glibc] '@oxfmt/linux-arm64-musl@0.26.0': resolution: {integrity: sha512-OEypUwK69bFPj+aa3/LYCnlIUPgoOLu//WNcriwpnWNmt47808Ht7RJSg+MNK8a7pSZHpXJ5/E6CRK/OTwFdaQ==} cpu: [arm64] os: [linux] + libc: [musl] '@oxfmt/linux-x64-gnu@0.26.0': resolution: {integrity: sha512-xO6iEW2bC6ZHyOTPmPWrg/nM6xgzyRPaS84rATy6F8d79wz69LdRdJ3l/PXlkqhi7XoxhvX4ExysA0Nf10ZZEQ==} cpu: [x64] os: [linux] + libc: [glibc] '@oxfmt/linux-x64-musl@0.26.0': resolution: {integrity: sha512-Z3KuZFC+MIuAyFCXBHY71kCsdRq1ulbsbzTe71v+hrEv7zVBn6yzql+/AZcgfIaKzWO9OXNuz5WWLWDmVALwow==} cpu: [x64] os: [linux] + libc: [musl] '@oxfmt/win32-arm64@0.26.0': resolution: {integrity: sha512-3zRbqwVWK1mDhRhTknlQFpRFL9GhEB5GfU6U7wawnuEwpvi39q91kJ+SRJvJnhyPCARkjZBd1V8XnweN5IFd1g==} @@ -2032,21 +2083,25 @@ packages: resolution: {integrity: sha512-Fow7H84Bs8XxuaK1yfSEWBC8HI7rfEQB9eR2A0J61un1WgCas7jNrt1HbT6+p6KmUH2bhR+r/RDu/6JFAvvj4g==} cpu: [arm64] os: [linux] + libc: [glibc] '@oxlint/linux-arm64-musl@1.41.0': resolution: {integrity: sha512-WoRRDNwgP5W3rjRh42Zdx8ferYnqpKoYCv2QQLenmdrLjRGYwAd52uywfkcS45mKEWHeY1RPwPkYCSROXiGb2w==} cpu: [arm64] os: [linux] + libc: [musl] '@oxlint/linux-x64-gnu@1.41.0': resolution: {integrity: sha512-75k3CKj3fOc/a/2aSgO81s3HsTZOFROthPJ+UI2Oatic1LhvH6eKjKfx3jDDyVpzeDS2qekPlc/y3N33iZz5Og==} cpu: [x64] os: [linux] + libc: [glibc] '@oxlint/linux-x64-musl@1.41.0': resolution: {integrity: sha512-8r82eBwGPoAPn67ZvdxTlX/Z3gVb+ZtN6nbkyFzwwHWAh8yGutX+VBcVkyrePSl6XgBP4QAaddPnHmkvJjqY0g==} cpu: [x64] os: [linux] + libc: [musl] '@oxlint/win32-arm64@1.41.0': resolution: {integrity: sha512-aK+DAcckQsNCOXKruatyYuY/ROjNiRejQB1PeJtkZwM21+8rV9ODYbvKNvt0pW+YCws7svftBSFMCpl3ke2unw==} @@ -2118,24 +2173,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@reflink/reflink-linux-arm64-musl@0.1.19': resolution: {integrity: sha512-37iO/Dp6m5DDaC2sf3zPtx/hl9FV3Xze4xoYidrxxS9bgP3S8ALroxRK6xBG/1TtfXKTvolvp+IjrUU6ujIGmA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@reflink/reflink-linux-x64-gnu@0.1.19': resolution: {integrity: sha512-jbI8jvuYCaA3MVUdu8vLoLAFqC+iNMpiSuLbxlAgg7x3K5bsS8nOpTRnkLF7vISJ+rVR8W+7ThXlXlUQ93ulkw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@reflink/reflink-linux-x64-musl@0.1.19': resolution: {integrity: sha512-e9FBWDe+lv7QKAwtKOt6A2W/fyy/aEEfr0g6j/hWzvQcrzHCsz07BNQYlNOjTfeytrtLU7k449H1PI95jA4OjQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@reflink/reflink-win32-arm64-msvc@0.1.19': resolution: {integrity: sha512-09PxnVIQcd+UOn4WAW73WU6PXL7DwGS6wPlkMhMg2zlHHG65F3vHepOw06HFCq+N42qkaNAc8AKIabWvtk6cIQ==} @@ -2188,24 +2247,28 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.0.0-rc.1': resolution: {integrity: sha512-UvApLEGholmxw/HIwmUnLq3CwdydbhaHHllvWiCTNbyGom7wTwOtz5OAQbAKZYyiEOeIXZNPkM7nA4Dtng7CLw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@rolldown/binding-linux-x64-gnu@1.0.0-rc.1': resolution: {integrity: sha512-uVctNgZHiGnJx5Fij7wHLhgw4uyZBVi6mykeWKOqE7bVy9Hcxn0fM/IuqdMwk6hXlaf9fFShDTFz2+YejP+x0A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-musl@1.0.0-rc.1': resolution: {integrity: sha512-T6Eg0xWwcxd/MzBcuv4Z37YVbUbJxy5cMNnbIt/Yr99wFwli30O4BPlY8hKeGyn6lWNtU0QioBS46lVzDN38bg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@rolldown/binding-openharmony-arm64@1.0.0-rc.1': resolution: {integrity: sha512-PuGZVS2xNJyLADeh2F04b+Cz4NwvpglbtWACgrDOa5YDTEHKwmiTDjoD5eZ9/ptXtcpeFrMqD2H4Zn33KAh1Eg==} @@ -2267,66 +2330,79 @@ packages: resolution: {integrity: sha512-YeGUhkN1oA+iSPzzhEjVPS29YbViOr8s4lSsFaZKLHswgqP911xx25fPOyE9+khmN6W4VeM0aevbDp4kkEoHiA==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.55.3': resolution: {integrity: sha512-eo0iOIOvcAlWB3Z3eh8pVM8hZ0oVkK3AjEM9nSrkSug2l15qHzF3TOwT0747omI6+CJJvl7drwZepT+re6Fy/w==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.55.3': resolution: {integrity: sha512-DJay3ep76bKUDImmn//W5SvpjRN5LmK/ntWyeJs/dcnwiiHESd3N4uteK9FDLf0S0W8E6Y0sVRXpOCoQclQqNg==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.55.3': resolution: {integrity: sha512-BKKWQkY2WgJ5MC/ayvIJTHjy0JUGb5efaHCUiG/39sSUvAYRBaO3+/EK0AZT1RF3pSj86O24GLLik9mAYu0IJg==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.55.3': resolution: {integrity: sha512-Q9nVlWtKAG7ISW80OiZGxTr6rYtyDSkauHUtvkQI6TNOJjFvpj4gcH+KaJihqYInnAzEEUetPQubRwHef4exVg==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.55.3': resolution: {integrity: sha512-2H5LmhzrpC4fFRNwknzmmTvvyJPHwESoJgyReXeFoYYuIDfBhP29TEXOkCJE/KxHi27mj7wDUClNq78ue3QEBQ==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.55.3': resolution: {integrity: sha512-9S542V0ie9LCTznPYlvaeySwBeIEa7rDBgLHKZ5S9DBgcqdJYburabm8TqiqG6mrdTzfV5uttQRHcbKff9lWtA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.55.3': resolution: {integrity: sha512-ukxw+YH3XXpcezLgbJeasgxyTbdpnNAkrIlFGDl7t+pgCxZ89/6n1a+MxlY7CegU+nDgrgdqDelPRNQ/47zs0g==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.55.3': resolution: {integrity: sha512-Iauw9UsTTvlF++FhghFJjqYxyXdggXsOqGpFBylaRopVpcbfyIIsNvkf9oGwfgIcf57z3m8+/oSYTo6HutBFNw==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.55.3': resolution: {integrity: sha512-3OqKAHSEQXKdq9mQ4eajqUgNIK27VZPW3I26EP8miIzuKzCJ3aW3oEn2pzF+4/Hj/Moc0YDsOtBgT5bZ56/vcA==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.55.3': resolution: {integrity: sha512-0CM8dSVzVIaqMcXIFej8zZrSFLnGrAE8qlNbbHfTw1EEPnFTg1U1ekI0JdzjPyzSfUsHWtodilQQG/RA55berA==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.55.3': resolution: {integrity: sha512-+fgJE12FZMIgBaKIAGd45rxf+5ftcycANJRWk8Vz0NnMTM5rADPGuRFTYar+Mqs560xuART7XsX2lSACa1iOmQ==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.55.3': resolution: {integrity: sha512-tMD7NnbAolWPzQlJQJjVFh/fNH3K/KnA7K8gv2dJWCwwnaK6DFCYST1QXYWfu5V0cDwarWC8Sf/cfMHniNq21A==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.55.3': resolution: {integrity: sha512-u5KsqxOxjEeIbn7bUK1MPM34jrnPwjeqgyin4/N6e/KzXKfpE9Mi0nCxcQjaM9lLmPcHmn/xx1yOjgTMtu1jWQ==} @@ -3071,6 +3147,9 @@ packages: aws4@1.13.2: resolution: {integrity: sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==} + axios@0.27.2: + resolution: {integrity: sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==} + axios@1.13.2: resolution: {integrity: sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==} @@ -3214,11 +3293,6 @@ packages: class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} - clawdbot@2026.1.24-3: - resolution: {integrity: sha512-zt9BzhWXduq8ZZR4rfzQDurQWAgmijTTyPZCQGrn5ew6wCEwhxxEr2/NHG7IlCwcfRsKymsY4se9KMhoNz0JtQ==} - engines: {node: '>=22.12.0'} - hasBin: true - cli-cursor@5.0.0: resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} engines: {node: '>=18'} @@ -4097,24 +4171,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.30.2: resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.30.2: resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.30.2: resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.30.2: resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} @@ -4165,6 +4243,9 @@ packages: lodash.debounce@4.0.8: resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} + lodash.identity@3.0.0: + resolution: {integrity: sha1-rXvGpOZH15yXLhuA/u968VYmeHY=} + lodash.includes@4.3.0: resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} @@ -4183,9 +4264,15 @@ packages: lodash.isstring@4.0.1: resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lodash.once@4.1.1: resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + lodash.pickby@4.6.0: + resolution: {integrity: sha1-feoh2MGNdwOifHBMFdO4SmfjOv8=} + lodash@4.17.23: resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} @@ -6882,6 +6969,20 @@ snapshots: '@lancedb/lancedb-win32-arm64-msvc': 0.23.0 '@lancedb/lancedb-win32-x64-msvc': 0.23.0 + '@larksuiteoapi/node-sdk@1.56.1': + dependencies: + axios: 0.27.2 + lodash.identity: 3.0.0 + lodash.merge: 4.6.2 + lodash.pickby: 4.6.0 + protobufjs: 7.5.4 + qs: 6.14.1 + ws: 8.19.0 + transitivePeerDependencies: + - bufferutil + - debug + - utf-8-validate + '@line/bot-sdk@10.6.0': dependencies: '@types/node': 24.10.9 @@ -8929,6 +9030,13 @@ snapshots: aws4@1.13.2: {} + axios@0.27.2: + dependencies: + follow-redirects: 1.15.11(debug@4.4.3) + form-data: 4.0.5 + transitivePeerDependencies: + - debug + axios@1.13.2(debug@4.4.3): dependencies: follow-redirects: 1.15.11(debug@4.4.3) @@ -9098,84 +9206,6 @@ snapshots: dependencies: clsx: 2.1.1 - clawdbot@2026.1.24-3(@types/express@5.0.6)(audio-decode@2.2.3)(devtools-protocol@0.0.1561482)(typescript@5.9.3): - dependencies: - '@agentclientprotocol/sdk': 0.13.1(zod@4.3.6) - '@aws-sdk/client-bedrock': 3.975.0 - '@buape/carbon': 0.14.0(hono@4.11.4) - '@clack/prompts': 0.11.0 - '@grammyjs/runner': 2.0.3(grammy@1.39.3) - '@grammyjs/transformer-throttler': 1.2.1(grammy@1.39.3) - '@homebridge/ciao': 1.3.4 - '@line/bot-sdk': 10.6.0 - '@lydell/node-pty': 1.2.0-beta.3 - '@mariozechner/pi-agent-core': 0.49.3(ws@8.19.0)(zod@4.3.6) - '@mariozechner/pi-ai': 0.49.3(ws@8.19.0)(zod@4.3.6) - '@mariozechner/pi-coding-agent': 0.49.3(ws@8.19.0)(zod@4.3.6) - '@mariozechner/pi-tui': 0.49.3 - '@mozilla/readability': 0.6.0 - '@sinclair/typebox': 0.34.47 - '@slack/bolt': 4.6.0(@types/express@5.0.6) - '@slack/web-api': 7.13.0 - '@whiskeysockets/baileys': 7.0.0-rc.9(audio-decode@2.2.3)(sharp@0.34.5) - ajv: 8.17.1 - body-parser: 2.2.2 - chalk: 5.6.2 - chokidar: 5.0.0 - chromium-bidi: 13.0.1(devtools-protocol@0.0.1561482) - cli-highlight: 2.1.11 - commander: 14.0.2 - croner: 9.1.0 - detect-libc: 2.1.2 - discord-api-types: 0.38.37 - dotenv: 17.2.3 - express: 5.2.1 - file-type: 21.3.0 - grammy: 1.39.3 - hono: 4.11.4 - jiti: 2.6.1 - json5: 2.2.3 - jszip: 3.10.1 - linkedom: 0.18.12 - long: 5.3.2 - markdown-it: 14.1.0 - node-edge-tts: 1.2.9 - osc-progress: 0.3.0 - pdfjs-dist: 5.4.530 - playwright-core: 1.58.0 - proper-lockfile: 4.1.2 - qrcode-terminal: 0.12.0 - sharp: 0.34.5 - sqlite-vec: 0.1.7-alpha.2 - tar: 7.5.4 - tslog: 4.10.2 - undici: 7.19.0 - ws: 8.19.0 - yaml: 2.8.2 - zod: 4.3.6 - optionalDependencies: - '@napi-rs/canvas': 0.1.88 - node-llama-cpp: 3.15.0(typescript@5.9.3) - transitivePeerDependencies: - - '@discordjs/opus' - - '@modelcontextprotocol/sdk' - - '@types/express' - - audio-decode - - aws-crt - - bufferutil - - canvas - - debug - - devtools-protocol - - encoding - - ffmpeg-static - - jimp - - link-preview-js - - node-opus - - opusscript - - supports-color - - typescript - - utf-8-validate - cli-cursor@5.0.0: dependencies: restore-cursor: 5.1.0 @@ -10251,6 +10281,8 @@ snapshots: lodash.debounce@4.0.8: optional: true + lodash.identity@3.0.0: {} + lodash.includes@4.3.0: {} lodash.isboolean@3.0.3: {} @@ -10263,8 +10295,12 @@ snapshots: lodash.isstring@4.0.1: {} + lodash.merge@4.6.2: {} + lodash.once@4.1.1: {} + lodash.pickby@4.6.0: {} + lodash@4.17.23: {} log-symbols@6.0.0: diff --git a/src/channels/dock.ts b/src/channels/dock.ts index 600499091..9cf8721ae 100644 --- a/src/channels/dock.ts +++ b/src/channels/dock.ts @@ -13,6 +13,8 @@ import { requireActivePluginRegistry } from "../plugins/runtime.js"; import { resolveDiscordGroupRequireMention, resolveDiscordGroupToolPolicy, + resolveFeishuGroupRequireMention, + resolveFeishuGroupToolPolicy, resolveGoogleChatGroupRequireMention, resolveGoogleChatGroupToolPolicy, resolveIMessageGroupRequireMention, @@ -76,6 +78,12 @@ const formatLower = (allowFrom: Array) => .filter(Boolean) .map((entry) => entry.toLowerCase()); +const normalizeFeishuAllowEntry = (entry: string) => + entry + .trim() + .replace(/^(feishu|lark|user|open_id|user_id|union_id|email):/i, "") + .toLowerCase(); + const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); // Channel docks: lightweight channel metadata/behavior for shared code paths. @@ -213,6 +221,40 @@ const DOCKS: Record = { }), }, }, + feishu: { + id: "feishu", + capabilities: { + chatTypes: ["direct", "channel"], + }, + outbound: { textChunkLimit: 4000 }, + config: { + resolveAllowFrom: ({ cfg, accountId }) => { + const channel = cfg.channels?.feishu as + | { + accounts?: Record } }>; + dm?: { allowFrom?: Array }; + } + | undefined; + const normalized = normalizeAccountId(accountId); + const account = + channel?.accounts?.[normalized] ?? + channel?.accounts?.[ + Object.keys(channel?.accounts ?? {}).find( + (key) => key.toLowerCase() === normalized.toLowerCase(), + ) ?? "" + ]; + return (account?.dm?.allowFrom ?? channel?.dm?.allowFrom ?? []).map((entry) => + String(entry), + ); + }, + formatAllowFrom: ({ allowFrom }) => + allowFrom.map((entry) => normalizeFeishuAllowEntry(String(entry))).filter(Boolean), + }, + groups: { + resolveRequireMention: resolveFeishuGroupRequireMention, + resolveToolPolicy: resolveFeishuGroupToolPolicy, + }, + }, googlechat: { id: "googlechat", capabilities: { diff --git a/src/channels/plugins/group-mentions.ts b/src/channels/plugins/group-mentions.ts index e91fee690..169d44351 100644 --- a/src/channels/plugins/group-mentions.ts +++ b/src/channels/plugins/group-mentions.ts @@ -172,6 +172,15 @@ export function resolveGoogleChatGroupRequireMention(params: GroupMentionParams) }); } +export function resolveFeishuGroupRequireMention(params: GroupMentionParams): boolean { + return resolveChannelGroupRequireMention({ + cfg: params.cfg, + channel: "feishu", + groupId: params.groupId, + accountId: params.accountId, + }); +} + export function resolveGoogleChatGroupToolPolicy( params: GroupMentionParams, ): GroupToolPolicyConfig | undefined { @@ -187,6 +196,21 @@ export function resolveGoogleChatGroupToolPolicy( }); } +export function resolveFeishuGroupToolPolicy( + params: GroupMentionParams, +): GroupToolPolicyConfig | undefined { + return resolveChannelGroupToolsPolicy({ + cfg: params.cfg, + channel: "feishu", + groupId: params.groupId, + accountId: params.accountId, + senderId: params.senderId, + senderName: params.senderName, + senderUsername: params.senderUsername, + senderE164: params.senderE164, + }); +} + export function resolveSlackGroupRequireMention(params: GroupMentionParams): boolean { const account = resolveSlackAccount({ cfg: params.cfg, diff --git a/src/channels/plugins/types.core.ts b/src/channels/plugins/types.core.ts index dcedc0a9c..170300ff8 100644 --- a/src/channels/plugins/types.core.ts +++ b/src/channels/plugins/types.core.ts @@ -22,6 +22,8 @@ export type ChannelSetupInput = { tokenFile?: string; botToken?: string; appToken?: string; + appId?: string; + appSecret?: string; signalNumber?: string; cliPath?: string; dbPath?: string; diff --git a/src/channels/registry.test.ts b/src/channels/registry.test.ts index 44b53ef6d..0ce7b55a7 100644 --- a/src/channels/registry.test.ts +++ b/src/channels/registry.test.ts @@ -11,6 +11,7 @@ describe("channel registry", () => { expect(normalizeChatChannelId("imsg")).toBe("imessage"); expect(normalizeChatChannelId("gchat")).toBe("googlechat"); expect(normalizeChatChannelId("google-chat")).toBe("googlechat"); + expect(normalizeChatChannelId("lark")).toBe("feishu"); expect(normalizeChatChannelId("web")).toBeNull(); }); diff --git a/src/channels/registry.ts b/src/channels/registry.ts index 6afe1996c..7b2a2a63a 100644 --- a/src/channels/registry.ts +++ b/src/channels/registry.ts @@ -8,6 +8,7 @@ export const CHAT_CHANNEL_ORDER = [ "telegram", "whatsapp", "discord", + "feishu", "googlechat", "slack", "signal", @@ -58,6 +59,16 @@ const CHAT_CHANNEL_META: Record = { blurb: "very well supported right now.", systemImage: "bubble.left.and.bubble.right", }, + feishu: { + id: "feishu", + label: "Feishu", + selectionLabel: "Feishu (Lark SDK)", + detailLabel: "Feishu Bot", + docsPath: "/channels/feishu", + docsLabel: "feishu", + blurb: "Lark/Feishu app via WebSocket.", + systemImage: "message.badge", + }, googlechat: { id: "googlechat", label: "Google Chat", @@ -104,6 +115,7 @@ export const CHAT_CHANNEL_ALIASES: Record = { imsg: "imessage", "google-chat": "googlechat", gchat: "googlechat", + lark: "feishu", }; const normalizeChannelKey = (raw?: string | null): string | undefined => { diff --git a/src/cli/channels-cli.ts b/src/cli/channels-cli.ts index 6d90ae935..0ab2adb78 100644 --- a/src/cli/channels-cli.ts +++ b/src/cli/channels-cli.ts @@ -25,6 +25,8 @@ const optionNamesAdd = [ "tokenFile", "botToken", "appToken", + "appId", + "appSecret", "signalNumber", "cliPath", "dbPath", @@ -162,6 +164,8 @@ export function registerChannelsCli(program: Command) { .option("--token-file ", "Bot token file (Telegram)") .option("--bot-token ", "Slack bot token (xoxb-...)") .option("--app-token ", "Slack app token (xapp-...)") + .option("--app-id ", "Feishu app id") + .option("--app-secret ", "Feishu app secret") .option("--signal-number ", "Signal account number (E.164)") .option("--cli-path ", "CLI path (signal-cli or imsg)") .option("--db-path ", "iMessage database path") diff --git a/src/commands/channels/add-mutators.ts b/src/commands/channels/add-mutators.ts index 1c390e5ce..688b0792b 100644 --- a/src/commands/channels/add-mutators.ts +++ b/src/commands/channels/add-mutators.ts @@ -26,6 +26,8 @@ export function applyChannelAccountConfig(params: { tokenFile?: string; botToken?: string; appToken?: string; + appId?: string; + appSecret?: string; signalNumber?: string; cliPath?: string; dbPath?: string; @@ -63,6 +65,8 @@ export function applyChannelAccountConfig(params: { tokenFile: params.tokenFile, botToken: params.botToken, appToken: params.appToken, + appId: params.appId, + appSecret: params.appSecret, signalNumber: params.signalNumber, cliPath: params.cliPath, dbPath: params.dbPath, diff --git a/src/commands/channels/add.ts b/src/commands/channels/add.ts index c619be6a6..58c6d48c3 100644 --- a/src/commands/channels/add.ts +++ b/src/commands/channels/add.ts @@ -23,6 +23,8 @@ export type ChannelsAddOptions = { tokenFile?: string; botToken?: string; appToken?: string; + appId?: string; + appSecret?: string; signalNumber?: string; cliPath?: string; dbPath?: string; @@ -191,6 +193,8 @@ export async function channelsAddCommand( tokenFile: opts.tokenFile, botToken: opts.botToken, appToken: opts.appToken, + appId: opts.appId, + appSecret: opts.appSecret, signalNumber: opts.signalNumber, cliPath: opts.cliPath, dbPath: opts.dbPath, @@ -234,6 +238,8 @@ export async function channelsAddCommand( tokenFile: opts.tokenFile, botToken: opts.botToken, appToken: opts.appToken, + appId: opts.appId, + appSecret: opts.appSecret, signalNumber: opts.signalNumber, cliPath: opts.cliPath, dbPath: opts.dbPath, diff --git a/src/config/config.feishu.test.ts b/src/config/config.feishu.test.ts new file mode 100644 index 000000000..2b0b1e4b1 --- /dev/null +++ b/src/config/config.feishu.test.ts @@ -0,0 +1,63 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { withTempHome } from "./test-helpers.js"; + +describe("config feishu", () => { + let previousHome: string | undefined; + + beforeEach(() => { + previousHome = process.env.HOME; + }); + + afterEach(() => { + process.env.HOME = previousHome; + }); + + it("loads feishu app credentials + dm and group settings", async () => { + await withTempHome(async (home) => { + const configDir = path.join(home, ".clawdbot"); + await fs.mkdir(configDir, { recursive: true }); + await fs.writeFile( + path.join(configDir, "moltbot.json"), + JSON.stringify( + { + channels: { + feishu: { + enabled: true, + appId: "app-id", + appSecret: "app-secret", + dm: { + enabled: true, + policy: "allowlist", + allowFrom: ["ou_123"], + }, + groups: { + oc_456: { + requireMention: false, + users: ["ou_123"], + }, + }, + }, + }, + }, + null, + 2, + ), + "utf-8", + ); + + vi.resetModules(); + const { loadConfig } = await import("./config.js"); + const cfg = loadConfig(); + + expect(cfg.channels?.feishu?.enabled).toBe(true); + expect(cfg.channels?.feishu?.appId).toBe("app-id"); + expect(cfg.channels?.feishu?.appSecret).toBe("app-secret"); + expect(cfg.channels?.feishu?.dm?.policy).toBe("allowlist"); + expect(cfg.channels?.feishu?.dm?.allowFrom).toEqual(["ou_123"]); + expect(cfg.channels?.feishu?.groups?.["oc_456"]?.requireMention).toBe(false); + expect(cfg.channels?.feishu?.groups?.["oc_456"]?.users).toEqual(["ou_123"]); + }); + }); +}); diff --git a/src/config/schema.ts b/src/config/schema.ts index b4ec8723b..24cac563b 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -294,6 +294,7 @@ const FIELD_LABELS: Record = { "channels.telegram": "Telegram", "channels.telegram.customCommands": "Telegram Custom Commands", "channels.discord": "Discord", + "channels.feishu": "Feishu", "channels.slack": "Slack", "channels.mattermost": "Mattermost", "channels.signal": "Signal", @@ -320,6 +321,7 @@ const FIELD_LABELS: Record = { "channels.imessage.dmPolicy": "iMessage DM Policy", "channels.bluebubbles.dmPolicy": "BlueBubbles DM Policy", "channels.discord.dm.policy": "Discord DM Policy", + "channels.feishu.dm.policy": "Feishu DM Policy", "channels.discord.retry.attempts": "Discord Retry Attempts", "channels.discord.retry.minDelayMs": "Discord Retry Min Delay (ms)", "channels.discord.retry.maxDelayMs": "Discord Retry Max Delay (ms)", @@ -330,6 +332,8 @@ const FIELD_LABELS: Record = { "channels.slack.dm.policy": "Slack DM Policy", "channels.slack.allowBots": "Slack Allow Bot Messages", "channels.discord.token": "Discord Bot Token", + "channels.feishu.appId": "Feishu App ID", + "channels.feishu.appSecret": "Feishu App Secret", "channels.slack.botToken": "Slack Bot Token", "channels.slack.appToken": "Slack App Token", "channels.slack.userToken": "Slack User Token", @@ -602,6 +606,8 @@ const FIELD_HELP: Record = { "Allow Mattermost to write config in response to channel events/commands (default: true).", "channels.discord.configWrites": "Allow Discord to write config in response to channel events/commands (default: true).", + "channels.feishu.configWrites": + "Allow Feishu to write config in response to channel events/commands (default: true).", "channels.whatsapp.configWrites": "Allow WhatsApp to write config in response to channel events/commands (default: true).", "channels.signal.configWrites": @@ -661,6 +667,8 @@ const FIELD_HELP: Record = { 'Direct message access control ("pairing" recommended). "open" requires channels.bluebubbles.allowFrom=["*"].', "channels.discord.dm.policy": 'Direct message access control ("pairing" recommended). "open" requires channels.discord.dm.allowFrom=["*"].', + "channels.feishu.dm.policy": + 'Direct message access control ("pairing" recommended). "open" requires channels.feishu.dm.allowFrom=["*"].', "channels.discord.retry.attempts": "Max retry attempts for outbound Discord API calls (default: 3).", "channels.discord.retry.minDelayMs": "Minimum retry delay in ms for Discord outbound calls.", diff --git a/src/config/types.channels.ts b/src/config/types.channels.ts index 77f4f1bc3..8a638c1ad 100644 --- a/src/config/types.channels.ts +++ b/src/config/types.channels.ts @@ -1,4 +1,5 @@ import type { DiscordConfig } from "./types.discord.js"; +import type { FeishuConfig } from "./types.feishu.js"; import type { GoogleChatConfig } from "./types.googlechat.js"; import type { IMessageConfig } from "./types.imessage.js"; import type { MSTeamsConfig } from "./types.msteams.js"; @@ -28,6 +29,7 @@ export type ChannelsConfig = { whatsapp?: WhatsAppConfig; telegram?: TelegramConfig; discord?: DiscordConfig; + feishu?: FeishuConfig; googlechat?: GoogleChatConfig; slack?: SlackConfig; signal?: SignalConfig; diff --git a/src/config/types.feishu.ts b/src/config/types.feishu.ts new file mode 100644 index 000000000..7497db231 --- /dev/null +++ b/src/config/types.feishu.ts @@ -0,0 +1,90 @@ +import type { + BlockStreamingCoalesceConfig, + DmPolicy, + GroupPolicy, + MarkdownConfig, + ReplyToMode, +} from "./types.base.js"; +import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js"; +import type { DmConfig } from "./types.messages.js"; +import type { GroupToolPolicyBySenderConfig, GroupToolPolicyConfig } from "./types.tools.js"; + +export type FeishuDmConfig = { + /** If false, ignore all incoming Feishu DMs. Default: true. */ + enabled?: boolean; + /** Direct message access policy (default: pairing). */ + policy?: DmPolicy; + /** Allowlist for DM senders (ids). */ + allowFrom?: Array; +}; + +export type FeishuGroupConfig = { + /** If false, disable the bot in this chat. (Alias for allow: false.) */ + enabled?: boolean; + /** Legacy group allow toggle; prefer enabled. */ + allow?: boolean; + /** Require mentioning the bot to trigger replies. */ + requireMention?: boolean; + /** Optional tool policy overrides for this chat. */ + tools?: GroupToolPolicyConfig; + toolsBySender?: GroupToolPolicyBySenderConfig; + /** Allowlist of users that can invoke the bot in this chat. */ + users?: Array; + /** Optional system prompt for this chat. */ + systemPrompt?: string; +}; + +export type FeishuAccountConfig = { + /** Optional display name for this account (used in CLI/UI lists). */ + name?: string; + /** Optional provider capability tags used for agent/runtime guidance. */ + capabilities?: string[]; + /** Markdown formatting overrides (tables). */ + markdown?: MarkdownConfig; + /** Allow channel-initiated config writes (default: true). */ + configWrites?: boolean; + /** If false, do not start this Feishu account. Default: true. */ + enabled?: boolean; + /** Feishu app id. */ + appId?: string; + /** Feishu app secret. */ + appSecret?: string; + /** Allow bot-authored messages to trigger replies (default: false). */ + allowBots?: boolean; + /** Default mention requirement for group chats (default: true). */ + requireMention?: boolean; + /** + * Controls how group chats are handled: + * - "open": chats bypass allowlists; mention-gating applies + * - "disabled": block all group chats + * - "allowlist": only allow chats present in channels.feishu.groups + */ + groupPolicy?: GroupPolicy; + /** Max group messages to keep as history context (0 disables). */ + historyLimit?: number; + /** Max DM turns to keep as history context. */ + dmHistoryLimit?: number; + /** Per-DM config overrides keyed by user ID. */ + dms?: Record; + /** Outbound text chunk size (chars). Default: 4000. */ + textChunkLimit?: number; + /** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */ + chunkMode?: "length" | "newline"; + /** Merge streamed block replies before sending. */ + blockStreaming?: boolean; + /** Merge streamed block replies before sending. */ + blockStreamingCoalesce?: BlockStreamingCoalesceConfig; + /** Max media size in MB. */ + mediaMaxMb?: number; + /** Control reply threading when reply tags are present (off|first|all). */ + replyToMode?: ReplyToMode; + dm?: FeishuDmConfig; + groups?: Record; + /** Heartbeat visibility settings for this channel. */ + heartbeat?: ChannelHeartbeatVisibilityConfig; +}; + +export type FeishuConfig = { + /** Optional per-account Feishu configuration (multi-account). */ + accounts?: Record; +} & FeishuAccountConfig; diff --git a/src/config/types.hooks.ts b/src/config/types.hooks.ts index 7ca74605a..59df9be81 100644 --- a/src/config/types.hooks.ts +++ b/src/config/types.hooks.ts @@ -25,6 +25,7 @@ export type HookMappingConfig = { | "whatsapp" | "telegram" | "discord" + | "feishu" | "googlechat" | "slack" | "signal" diff --git a/src/config/types.queue.ts b/src/config/types.queue.ts index 9e421ddfe..adac76813 100644 --- a/src/config/types.queue.ts +++ b/src/config/types.queue.ts @@ -12,6 +12,7 @@ export type QueueModeByProvider = { whatsapp?: QueueMode; telegram?: QueueMode; discord?: QueueMode; + feishu?: QueueMode; googlechat?: QueueMode; slack?: QueueMode; signal?: QueueMode; diff --git a/src/config/types.ts b/src/config/types.ts index 424ccaf26..701df0195 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -10,6 +10,7 @@ export * from "./types.channels.js"; export * from "./types.clawdbot.js"; export * from "./types.cron.js"; export * from "./types.discord.js"; +export * from "./types.feishu.js"; export * from "./types.googlechat.js"; export * from "./types.gateway.js"; export * from "./types.hooks.js"; diff --git a/src/config/zod-schema.core.ts b/src/config/zod-schema.core.ts index 4a8c80bcc..2e6401470 100644 --- a/src/config/zod-schema.core.ts +++ b/src/config/zod-schema.core.ts @@ -306,6 +306,7 @@ export const QueueModeBySurfaceSchema = z whatsapp: QueueModeSchema.optional(), telegram: QueueModeSchema.optional(), discord: QueueModeSchema.optional(), + feishu: QueueModeSchema.optional(), slack: QueueModeSchema.optional(), mattermost: QueueModeSchema.optional(), signal: QueueModeSchema.optional(), diff --git a/src/config/zod-schema.hooks.ts b/src/config/zod-schema.hooks.ts index 35e74f7af..bc9602117 100644 --- a/src/config/zod-schema.hooks.ts +++ b/src/config/zod-schema.hooks.ts @@ -23,6 +23,7 @@ export const HookMappingSchema = z z.literal("whatsapp"), z.literal("telegram"), z.literal("discord"), + z.literal("feishu"), z.literal("slack"), z.literal("signal"), z.literal("imessage"), diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index ed7dda22a..29e04df7b 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -282,6 +282,67 @@ export const DiscordConfigSchema = DiscordAccountSchema.extend({ accounts: z.record(z.string(), DiscordAccountSchema.optional()).optional(), }); +export const FeishuDmSchema = z + .object({ + enabled: z.boolean().optional(), + policy: DmPolicySchema.optional().default("pairing"), + allowFrom: z.array(z.union([z.string(), z.number()])).optional(), + }) + .strict() + .superRefine((value, ctx) => { + requireOpenAllowFrom({ + policy: value.policy, + allowFrom: value.allowFrom, + ctx, + path: ["allowFrom"], + message: + 'channels.feishu.dm.policy="open" requires channels.feishu.dm.allowFrom to include "*"', + }); + }); + +export const FeishuGroupSchema = z + .object({ + enabled: z.boolean().optional(), + allow: z.boolean().optional(), + requireMention: z.boolean().optional(), + tools: ToolPolicySchema, + toolsBySender: ToolPolicyBySenderSchema, + users: z.array(z.union([z.string(), z.number()])).optional(), + systemPrompt: z.string().optional(), + }) + .strict(); + +export const FeishuAccountSchema = z + .object({ + name: z.string().optional(), + capabilities: z.array(z.string()).optional(), + markdown: MarkdownConfigSchema.optional(), + configWrites: z.boolean().optional(), + enabled: z.boolean().optional(), + appId: z.string().optional(), + appSecret: z.string().optional(), + allowBots: z.boolean().optional(), + requireMention: z.boolean().optional(), + groupPolicy: GroupPolicySchema.optional().default("allowlist"), + historyLimit: z.number().int().min(0).optional(), + dmHistoryLimit: z.number().int().min(0).optional(), + dms: z.record(z.string(), DmConfigSchema.optional()).optional(), + textChunkLimit: z.number().int().positive().optional(), + chunkMode: z.enum(["length", "newline"]).optional(), + blockStreaming: z.boolean().optional(), + blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(), + mediaMaxMb: z.number().positive().optional(), + replyToMode: ReplyToModeSchema.optional(), + dm: FeishuDmSchema.optional(), + groups: z.record(z.string(), FeishuGroupSchema.optional()).optional(), + heartbeat: ChannelHeartbeatVisibilitySchema, + }) + .strict(); + +export const FeishuConfigSchema = FeishuAccountSchema.extend({ + accounts: z.record(z.string(), FeishuAccountSchema.optional()).optional(), +}); + export const GoogleChatDmSchema = z .object({ enabled: z.boolean().optional(), diff --git a/src/config/zod-schema.providers.ts b/src/config/zod-schema.providers.ts index 0a7ec9db8..8995284a1 100644 --- a/src/config/zod-schema.providers.ts +++ b/src/config/zod-schema.providers.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { BlueBubblesConfigSchema, DiscordConfigSchema, + FeishuConfigSchema, GoogleChatConfigSchema, IMessageConfigSchema, MSTeamsConfigSchema, @@ -30,6 +31,7 @@ export const ChannelsSchema = z whatsapp: WhatsAppConfigSchema.optional(), telegram: TelegramConfigSchema.optional(), discord: DiscordConfigSchema.optional(), + feishu: FeishuConfigSchema.optional(), googlechat: GoogleChatConfigSchema.optional(), slack: SlackConfigSchema.optional(), signal: SignalConfigSchema.optional(), diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 0813caefd..fdec3f9a7 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -84,6 +84,7 @@ export type { GroupToolPolicyBySenderConfig, MarkdownConfig, MarkdownTableMode, + FeishuConfig, GoogleChatAccountConfig, GoogleChatConfig, GoogleChatDmConfig, @@ -96,6 +97,7 @@ export type { } from "../config/types.js"; export { DiscordConfigSchema, + FeishuConfigSchema, GoogleChatConfigSchema, IMessageConfigSchema, MSTeamsConfigSchema,