From e89378a0e7a78e7c8e50591191d389861ad7ba83 Mon Sep 17 00:00:00 2001 From: devsh Date: Mon, 26 Jan 2026 17:29:10 +0800 Subject: [PATCH] feat(lark): add Feishu/Lark channel plugin with security fixes - Initialize runtime in register() to fix 'runtime not initialized' error - Move clawdbot from dependencies to devDependencies/peerDependencies - Add security adapter with DM policy (pairing, allowlist, open) - Add pairing adapter for user approval flow - Add status adapter for health checks and diagnostics - Add groups adapter for mention/tool policy resolution - Add reload config prefixes for hot reload support - Add outbound chunker for long messages - Fix verification token enforcement in webhook handler - Add allowFrom validation before processing messages - Add comprehensive input validation in webhook handler - Improve type safety and error handling throughout --- extensions/lark/README.md | 35 +++ extensions/lark/index.ts | 18 ++ extensions/lark/package.json | 46 ++++ extensions/lark/src/channel.ts | 270 ++++++++++++++++++++++++ extensions/lark/src/index.ts | 4 + extensions/lark/src/monitor.ts | 246 +++++++++++++++++++++ extensions/lark/src/reply-dispatcher.ts | 35 +++ extensions/lark/src/runtime.ts | 14 ++ extensions/lark/src/send.ts | 70 ++++++ extensions/lark/src/token.ts | 53 +++++ extensions/lark/src/types.ts | 46 ++++ extensions/lark/tsconfig.json | 9 + 12 files changed, 846 insertions(+) create mode 100644 extensions/lark/README.md create mode 100644 extensions/lark/index.ts create mode 100644 extensions/lark/package.json create mode 100644 extensions/lark/src/channel.ts create mode 100644 extensions/lark/src/index.ts create mode 100644 extensions/lark/src/monitor.ts create mode 100644 extensions/lark/src/reply-dispatcher.ts create mode 100644 extensions/lark/src/runtime.ts create mode 100644 extensions/lark/src/send.ts create mode 100644 extensions/lark/src/token.ts create mode 100644 extensions/lark/src/types.ts create mode 100644 extensions/lark/tsconfig.json diff --git a/extensions/lark/README.md b/extensions/lark/README.md new file mode 100644 index 000000000..ffb33b345 --- /dev/null +++ b/extensions/lark/README.md @@ -0,0 +1,35 @@ +# @clawdbot/lark + +Feishu / Lark channel plugin for Clawdbot. + +## Configuration + +Add the following to your `clawdbot.config.yaml`: + +```yaml +channels: + lark: + enabled: true + appId: "cli_..." + appSecret: "..." + encryptKey: "..." # Optional + verificationToken: "..." # Optional + baseUrl: "https://open.feishu.cn" # Optional, default + webhook: + port: 3000 + path: "/lark/webhook" +``` + +## Setup + +1. Create an app on [Feishu Open Platform](https://open.feishu.cn/app). +2. Get App ID and App Secret. +3. Enable "Bot" capabilities. +4. Set up "Event Subscriptions": + - Request URL: `https://your-gateway.com/lark/webhook` (must match `webhook.path` and external URL). + - Enable `im.message.receive_v1` event. +5. (Optional) Enable "Encrypt Key". + +## Development + +Run `pnpm build` to compile. diff --git a/extensions/lark/index.ts b/extensions/lark/index.ts new file mode 100644 index 000000000..740980c34 --- /dev/null +++ b/extensions/lark/index.ts @@ -0,0 +1,18 @@ +import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk"; +import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk"; + +import { larkPlugin } from "./src/channel.js"; +import { setLarkRuntime } from "./src/runtime.js"; + +const plugin = { + id: "lark", + name: "Feishu / Lark", + description: "Feishu / Lark channel plugin (Open Platform)", + configSchema: emptyPluginConfigSchema(), + register(api: ClawdbotPluginApi) { + setLarkRuntime(api.runtime); + api.registerChannel({ plugin: larkPlugin }); + }, +}; + +export default plugin; diff --git a/extensions/lark/package.json b/extensions/lark/package.json new file mode 100644 index 000000000..cfadf3e04 --- /dev/null +++ b/extensions/lark/package.json @@ -0,0 +1,46 @@ +{ + "name": "@clawdbot/lark", + "version": "1.0.0", + "type": "module", + "description": "Clawdbot Feishu/Lark channel plugin", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "scripts": { + "build": "tsc", + "prepublishOnly": "npm run build" + }, + "clawdbot": { + "extensions": [ + "./index.ts" + ], + "channel": { + "id": "lark", + "label": "Feishu / Lark", + "selectionLabel": "Feishu / Lark (Open Platform)", + "docsPath": "/channels/lark", + "docsLabel": "lark", + "blurb": "Feishu Open Platform bot integration.", + "aliases": [ + "feishu" + ], + "order": 70 + }, + "install": { + "npmSpec": "@clawdbot/lark", + "localPath": "extensions/lark", + "defaultChoice": "npm" + } + }, + "dependencies": { + "express": "^5.2.1" + }, + "devDependencies": { + "@types/express": "^5.0.0", + "@types/node": "^22.0.0", + "clawdbot": "workspace:*", + "typescript": "^5.7.3" + }, + "peerDependencies": { + "clawdbot": "*" + } +} diff --git a/extensions/lark/src/channel.ts b/extensions/lark/src/channel.ts new file mode 100644 index 000000000..cfd53b2b3 --- /dev/null +++ b/extensions/lark/src/channel.ts @@ -0,0 +1,270 @@ +import type { ChannelPlugin, ClawdbotConfig } from "clawdbot/plugin-sdk"; +import { buildChannelConfigSchema, DEFAULT_ACCOUNT_ID, formatPairingApproveHint } from "clawdbot/plugin-sdk"; +import { LarkConfigSchema, type LarkConfig } from "./types.js"; +import { larkOutbound } from "./send.js"; +import { resolveLarkCredentials, getTenantAccessToken } from "./token.js"; +import { getLarkRuntime } from "./runtime.js"; + +type ResolvedLarkAccount = { + accountId: string; + enabled: boolean; + configured: boolean; + config: LarkConfig; +}; + +const meta = { + id: "lark", + label: "Feishu / Lark", + selectionLabel: "Feishu / Lark (Open Platform)", + docsPath: "/channels/lark", + docsLabel: "lark", + blurb: "Feishu Open Platform bot integration.", + aliases: ["feishu"], + order: 70, +} as const; + +function resolveLarkAccount(cfg: ClawdbotConfig, _accountId?: string): ResolvedLarkAccount { + const larkCfg = cfg.channels?.lark as LarkConfig | undefined; + return { + accountId: DEFAULT_ACCOUNT_ID, + enabled: larkCfg?.enabled !== false, + configured: Boolean(resolveLarkCredentials(larkCfg)), + config: larkCfg ?? ({} as LarkConfig), + }; +} + +function normalizeAllowEntry(entry: string): string { + return entry.trim().replace(/^lark:/i, "").replace(/^feishu:/i, ""); +} + +export const larkPlugin: ChannelPlugin = { + id: "lark", + meta, + capabilities: { + chatTypes: ["direct", "group"], + media: false, + threads: false, + nativeCommands: false, + blockStreaming: true, + }, + reload: { configPrefixes: ["channels.lark"] }, + configSchema: buildChannelConfigSchema(LarkConfigSchema), + + config: { + listAccountIds: () => [DEFAULT_ACCOUNT_ID], + resolveAccount: (cfg) => resolveLarkAccount(cfg as ClawdbotConfig), + defaultAccountId: () => DEFAULT_ACCOUNT_ID, + setAccountEnabled: ({ cfg, enabled }) => ({ + ...cfg, + channels: { + ...cfg.channels, + lark: { + ...cfg.channels?.lark, + enabled, + }, + }, + }), + deleteAccount: ({ cfg }) => { + const next = { ...cfg } as ClawdbotConfig; + const nextChannels = { ...cfg.channels }; + delete nextChannels.lark; + if (Object.keys(nextChannels).length > 0) { + next.channels = nextChannels; + } else { + delete next.channels; + } + return next; + }, + isConfigured: (_account, cfg) => Boolean(resolveLarkCredentials((cfg as ClawdbotConfig).channels?.lark as LarkConfig)), + describeAccount: (account) => ({ + accountId: account.accountId, + enabled: account.enabled, + configured: account.configured, + }), + resolveAllowFrom: ({ cfg }) => ((cfg as ClawdbotConfig).channels?.lark as LarkConfig)?.allowFrom ?? [], + formatAllowFrom: ({ allowFrom }) => allowFrom.map((s) => normalizeAllowEntry(String(s))), + }, + + security: { + resolveDmPolicy: ({ cfg, account }) => { + const larkCfg = (cfg as ClawdbotConfig).channels?.lark as LarkConfig | undefined; + return { + policy: larkCfg?.dmPolicy ?? "pairing", + allowFrom: larkCfg?.allowFrom ?? [], + policyPath: "channels.lark.dmPolicy", + allowFromPath: "channels.lark.", + approveHint: formatPairingApproveHint("lark"), + normalizeEntry: normalizeAllowEntry, + }; + }, + collectWarnings: ({ cfg }) => { + const warnings: string[] = []; + const larkCfg = (cfg as ClawdbotConfig).channels?.lark as LarkConfig | undefined; + + if (larkCfg?.dmPolicy === "open") { + warnings.push( + `- Lark DMs are open to anyone. Set channels.lark.dmPolicy="pairing" or "allowlist" for security.` + ); + } + + const groupPolicy = larkCfg?.groupPolicy ?? "allowlist"; + if (groupPolicy === "open") { + warnings.push( + `- Lark groups: groupPolicy="open" allows any group to trigger (mention-gated). Set channels.lark.groupPolicy="allowlist" and configure channels.lark.groups.` + ); + } + + return warnings; + }, + }, + + pairing: { + idLabel: "larkUserId", + normalizeAllowEntry, + notifyApproval: async ({ cfg, id }) => { + const larkCfg = (cfg as ClawdbotConfig).channels?.lark as LarkConfig | undefined; + const creds = resolveLarkCredentials(larkCfg); + if (!creds) { + throw new Error("Lark credentials not configured"); + } + + const token = await getTenantAccessToken(creds); + const url = `${creds.baseUrl.replace(/\/$/, "")}/open-apis/im/v1/messages?receive_id_type=open_id`; + + const res = await fetch(url, { + method: "POST", + headers: { + "Authorization": `Bearer ${token}`, + "Content-Type": "application/json; charset=utf-8", + }, + body: JSON.stringify({ + receive_id: id, + msg_type: "text", + content: JSON.stringify({ text: "Your pairing request has been approved. You can now chat with this bot." }), + }), + }); + + if (!res.ok) { + throw new Error(`Failed to send approval notification: ${res.status}`); + } + }, + }, + + groups: { + resolveRequireMention: ({ cfg, groupId }) => { + const larkCfg = (cfg as ClawdbotConfig).channels?.lark as LarkConfig | undefined; + const groupConfig = larkCfg?.groups?.[groupId] ?? larkCfg?.groups?.["*"]; + return groupConfig?.requireMention ?? true; + }, + resolveToolPolicy: ({ cfg, groupId }) => { + const larkCfg = (cfg as ClawdbotConfig).channels?.lark as LarkConfig | undefined; + const groupConfig = larkCfg?.groups?.[groupId] ?? larkCfg?.groups?.["*"]; + return groupConfig?.toolPolicy ?? "full"; + }, + }, + + outbound: { + ...larkOutbound, + deliveryMode: "direct", + textChunkLimit: 4000, + chunker: (text, limit) => getLarkRuntime().channel.text.chunkMarkdownText(text, limit), + chunkerMode: "markdown", + }, + + status: { + defaultRuntime: { + accountId: DEFAULT_ACCOUNT_ID, + running: false, + lastStartAt: null, + lastStopAt: null, + lastError: null, + }, + collectStatusIssues: (accounts) => + accounts.flatMap((entry) => { + const issues = []; + const enabled = entry.enabled !== false; + const configured = entry.configured === true; + + if (enabled && !configured) { + issues.push({ + channel: "lark", + accountId: String(entry.accountId ?? DEFAULT_ACCOUNT_ID), + kind: "config", + message: "Lark credentials not configured (appId and appSecret required).", + fix: "Set channels.lark.appId and channels.lark.appSecret.", + }); + } + + return issues; + }), + buildChannelSummary: ({ snapshot }) => ({ + configured: snapshot.configured ?? false, + running: snapshot.running ?? false, + lastStartAt: snapshot.lastStartAt ?? null, + lastStopAt: snapshot.lastStopAt ?? null, + lastError: snapshot.lastError ?? null, + probe: snapshot.probe, + lastProbeAt: snapshot.lastProbeAt ?? null, + }), + probeAccount: async ({ account }) => { + const creds = resolveLarkCredentials(account.config); + if (!creds) { + return { ok: false, error: "Not configured" }; + } + + try { + const token = await getTenantAccessToken(creds); + return { ok: true, token: token.substring(0, 8) + "..." }; + } catch (err) { + return { ok: false, error: String(err) }; + } + }, + buildAccountSnapshot: ({ account, runtime, probe }) => ({ + accountId: account.accountId, + enabled: account.enabled, + configured: account.configured, + 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.dmPolicy ?? "pairing", + probe, + }), + }, + + gateway: { + startAccount: async (ctx) => { + const { monitorLarkProvider } = await import("./monitor.js"); + const larkCfg = (ctx.cfg as ClawdbotConfig).channels?.lark as LarkConfig | undefined; + const port = larkCfg?.webhook?.port ?? 3000; + + ctx.setStatus({ + accountId: ctx.accountId, + running: true, + lastStartAt: Date.now(), + port, + }); + ctx.log?.info(`[${ctx.accountId}] starting Lark provider (port ${port})`); + + return monitorLarkProvider({ + cfg: ctx.cfg as ClawdbotConfig, + runtime: ctx.runtime, + abortSignal: ctx.abortSignal, + }); + }, + }, + + onboarding: { + detectState: async (cfg) => { + const larkCfg = (cfg as ClawdbotConfig).channels?.lark as LarkConfig | undefined; + const creds = resolveLarkCredentials(larkCfg); + + if (creds) { + return { state: "configured", message: "Lark is configured" }; + } + return { state: "unconfigured", message: "Set appId and appSecret in channels.lark" }; + }, + }, +}; diff --git a/extensions/lark/src/index.ts b/extensions/lark/src/index.ts new file mode 100644 index 000000000..9124434e2 --- /dev/null +++ b/extensions/lark/src/index.ts @@ -0,0 +1,4 @@ +export { monitorLarkProvider } from "./monitor.js"; +export { larkOutbound } from "./send.js"; +export { type LarkConfig, LarkConfigSchema } from "./types.js"; + diff --git a/extensions/lark/src/monitor.ts b/extensions/lark/src/monitor.ts new file mode 100644 index 000000000..e1840d820 --- /dev/null +++ b/extensions/lark/src/monitor.ts @@ -0,0 +1,246 @@ +import type { Request, Response } from "express"; +import type { ClawdbotConfig, RuntimeEnv } from "clawdbot/plugin-sdk"; +import { resolveLarkCredentials } from "./token.js"; +import type { LarkConfig } from "./types.js"; +import * as crypto from "crypto"; +import { getLarkRuntime } from "./runtime.js"; +import { createLarkReplyDispatcher } from "./reply-dispatcher.js"; + +export type MonitorLarkOpts = { + cfg: ClawdbotConfig; + runtime?: RuntimeEnv; + abortSignal?: AbortSignal; +}; + +export type MonitorLarkResult = { + app: unknown; + shutdown: () => Promise; +}; + +function decrypt(encrypt: string, key: string): string { + const hash = crypto.createHash("sha256"); + hash.update(key); + const keyBytes = hash.digest(); + + const buf = Buffer.from(encrypt, "base64"); + const iv = buf.subarray(0, 16); + const content = buf.subarray(16); + + const decipher = crypto.createDecipheriv("aes-256-cbc", keyBytes, iv); + let decrypted = decipher.update(content); + decrypted = Buffer.concat([decrypted, decipher.final()]); + return decrypted.toString("utf8"); +} + +function normalizeAllowEntry(entry: string): string { + return entry.trim().replace(/^lark:/i, "").replace(/^feishu:/i, "").toLowerCase(); +} + +function isAllowed(senderId: string, allowFrom: string[], dmPolicy: string): boolean { + if (dmPolicy === "open") return true; + if (!allowFrom || allowFrom.length === 0) return dmPolicy !== "allowlist"; + + const normalizedSender = normalizeAllowEntry(senderId); + return allowFrom.some((entry) => { + if (entry === "*") return true; + return normalizeAllowEntry(entry) === normalizedSender; + }); +} + +export async function monitorLarkProvider(opts: MonitorLarkOpts): Promise { + const log = opts.runtime?.log ?? console.log; + const errorLog = opts.runtime?.error ?? console.error; + const cfg = opts.cfg; + const larkCfg = cfg.channels?.lark as LarkConfig | undefined; + + if (!larkCfg?.enabled) { + log("Lark provider disabled"); + return { app: null, shutdown: async () => {} }; + } + + const creds = resolveLarkCredentials(larkCfg); + if (!creds) { + errorLog("Lark credentials not configured (appId and appSecret required)"); + return { app: null, shutdown: async () => {} }; + } + + const express = await import("express"); + const app = express.default(); + app.use(express.json()); + + const port = larkCfg.webhook?.port ?? 3000; + const path = larkCfg.webhook?.path ?? "/lark/webhook"; + const dmPolicy = larkCfg.dmPolicy ?? "pairing"; + const allowFrom = larkCfg.allowFrom ?? []; + + app.post(path, async (req: Request, res: Response) => { + try { + let body = req.body; + + if (body.encrypt && creds.encryptKey) { + try { + const decrypted = decrypt(body.encrypt, creds.encryptKey); + body = JSON.parse(decrypted); + } catch (err) { + errorLog("Lark decryption failed:", err); + res.status(400).send("Decryption failed"); + return; + } + } + + if (body.type === "url_verification") { + if (creds.verificationToken && body.token !== creds.verificationToken) { + errorLog("Invalid verification token in url_verification"); + res.status(403).send("Invalid verification token"); + return; + } + res.json({ challenge: body.challenge }); + return; + } + + if (body.schema === "2.0") { + const header = body.header; + const event = body.event; + + if (!header || !event) { + errorLog("Missing header or event in schema 2.0 payload"); + res.status(400).send("Invalid payload"); + return; + } + + if (creds.verificationToken && header.token !== creds.verificationToken) { + errorLog("Invalid verification token in event callback"); + res.status(403).send("Invalid verification token"); + return; + } + + if (header.event_type === "im.message.receive_v1") { + const message = event.message; + const sender = event.sender; + + if (!message || !sender) { + errorLog("Missing message or sender in event"); + res.status(200).send("OK"); + return; + } + + let content: { text?: string }; + try { + content = JSON.parse(message.content ?? "{}"); + } catch { + errorLog("Failed to parse message content"); + res.status(200).send("OK"); + return; + } + + const text = content.text ?? ""; + const fromId = sender.sender_id?.open_id || sender.sender_id?.user_id || ""; + const chatId = message.chat_id; + const chatType = message.chat_type; + + if (!fromId) { + errorLog("Unable to identify sender"); + res.status(200).send("OK"); + return; + } + + const senderKey = fromId; + const isDirect = chatType === "p2p"; + const channelId = isDirect ? fromId : chatId; + + log(`Lark received message from ${senderKey}: ${text.substring(0, 50)}`); + + if (isDirect && !isAllowed(senderKey, allowFrom, dmPolicy)) { + log(`Sender ${senderKey} not in allowFrom list (policy: ${dmPolicy})`); + + if (dmPolicy === "pairing") { + const core = getLarkRuntime(); + const pairingCode = core.channel.pairing?.generatePairingCode?.("lark", senderKey); + + if (pairingCode) { + const dispatcher = createLarkReplyDispatcher({ cfg, channelId }); + await dispatcher.dispatch({ + body: `To chat with this bot, please ask the owner to approve your pairing code: ${pairingCode}`, + }); + } + } + + res.status(200).send("OK"); + return; + } + + const core = getLarkRuntime(); + const route = core.channel.routing.resolveAgentRoute({ + cfg, + channel: "lark", + peer: { + kind: isDirect ? "dm" : "group", + id: channelId, + }, + }); + + const ctxPayload = core.channel.reply.finalizeInboundContext({ + Body: text, + RawBody: text, + CommandBody: text, + From: `lark:${senderKey}`, + To: `lark:${creds.appId}`, + SessionKey: route.sessionKey, + AccountId: creds.appId, + ChatType: isDirect ? "direct" : "group", + SenderName: sender.sender_id?.user_id ?? "Lark User", + SenderId: senderKey, + Provider: "lark", + Surface: "lark", + Timestamp: Number(message.create_time) || Date.now(), + OriginatingChannel: "lark", + OriginatingTo: `lark:${creds.appId}`, + }); + + const dispatcher = createLarkReplyDispatcher({ cfg, channelId }); + + await core.channel.reply.dispatchReplyFromConfig({ + ctx: ctxPayload, + cfg, + dispatcher, + replyOptions: {}, + }); + } + } + + res.status(200).send("OK"); + } catch (err) { + errorLog("Lark webhook error:", err); + res.status(500).send("Internal Error"); + } + }); + + let server: ReturnType | null = null; + + const startServer = () => { + server = app.listen(port, () => { + log(`Lark provider listening on port ${port} at ${path}`); + }); + }; + + startServer(); + + if (opts.abortSignal) { + opts.abortSignal.addEventListener("abort", () => { + if (server) { + server.close(); + server = null; + } + }); + } + + return { + app, + shutdown: async () => { + if (server) { + server.close(); + server = null; + } + }, + }; +} diff --git a/extensions/lark/src/reply-dispatcher.ts b/extensions/lark/src/reply-dispatcher.ts new file mode 100644 index 000000000..326bbfc54 --- /dev/null +++ b/extensions/lark/src/reply-dispatcher.ts @@ -0,0 +1,35 @@ +import type { ReplyDispatcher, ClawdbotConfig } from "clawdbot/plugin-sdk"; +import { larkOutbound } from "./send.js"; + +type PayloadBody = string | { text?: string } | null | undefined; + +function extractText(body: PayloadBody): string { + if (typeof body === "string") return body; + if (body && typeof body === "object" && "text" in body) { + return body.text ?? ""; + } + return ""; +} + +export function createLarkReplyDispatcher(opts: { + cfg: ClawdbotConfig; + channelId: string; +}): ReplyDispatcher { + return { + dispatch: async (payload) => { + const text = extractText(payload.body as PayloadBody); + + if (!text) { + return { id: "skipped", ts: Date.now() }; + } + + const result = await larkOutbound.sendText({ + cfg: opts.cfg, + to: opts.channelId, + text, + }); + + return { id: result.id ?? "", ts: result.ts ?? Date.now() }; + }, + }; +} diff --git a/extensions/lark/src/runtime.ts b/extensions/lark/src/runtime.ts new file mode 100644 index 000000000..64fb545b5 --- /dev/null +++ b/extensions/lark/src/runtime.ts @@ -0,0 +1,14 @@ +import type { PluginRuntime } from "clawdbot/plugin-sdk"; + +let runtime: PluginRuntime | null = null; + +export function setLarkRuntime(next: PluginRuntime) { + runtime = next; +} + +export function getLarkRuntime(): PluginRuntime { + if (!runtime) { + throw new Error("Lark runtime not initialized"); + } + return runtime; +} diff --git a/extensions/lark/src/send.ts b/extensions/lark/src/send.ts new file mode 100644 index 000000000..9110562ab --- /dev/null +++ b/extensions/lark/src/send.ts @@ -0,0 +1,70 @@ +import type { ChannelOutbound, ClawdbotConfig } from "clawdbot/plugin-sdk"; +import { getTenantAccessToken, resolveLarkCredentials } from "./token.js"; +import type { LarkConfig } from "./types.js"; + +type LarkApiResponse = { + code: number; + msg: string; + data?: { + message_id?: string; + }; +}; + +function detectReceiveIdType(to: string): string { + if (to.startsWith("oc_")) return "chat_id"; + if (to.startsWith("ou_")) return "open_id"; + if (to.startsWith("on_")) return "union_id"; + if (to.includes("@")) return "email"; + return "open_id"; +} + +export const larkOutbound: ChannelOutbound = { + sendText: async ({ cfg, to, text }) => { + const larkCfg = (cfg as ClawdbotConfig).channels?.lark as LarkConfig | undefined; + const creds = resolveLarkCredentials(larkCfg); + if (!creds) { + throw new Error("Lark credentials not configured (appId and appSecret required)"); + } + + if (!to?.trim()) { + throw new Error("Lark target (to) is required"); + } + + const token = await getTenantAccessToken(creds); + const receiveIdType = detectReceiveIdType(to); + const url = `${creds.baseUrl.replace(/\/$/, "")}/open-apis/im/v1/messages?receive_id_type=${receiveIdType}`; + + const res = await fetch(url, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json; charset=utf-8", + }, + body: JSON.stringify({ + receive_id: to, + msg_type: "text", + content: JSON.stringify({ text }), + }), + }); + + if (!res.ok) { + const body = await res.text(); + throw new Error(`Lark API error: ${res.status} ${body}`); + } + + const data: unknown = await res.json(); + if (!data || typeof data !== "object") { + throw new Error("Lark API returned invalid response"); + } + + const response = data as LarkApiResponse; + if (response.code !== 0) { + throw new Error(`Lark send error (code ${response.code}): ${response.msg}`); + } + + return { + id: response.data?.message_id ?? "", + ts: Date.now(), + }; + }, +}; diff --git a/extensions/lark/src/token.ts b/extensions/lark/src/token.ts new file mode 100644 index 000000000..891149a48 --- /dev/null +++ b/extensions/lark/src/token.ts @@ -0,0 +1,53 @@ +import type { LarkConfig, LarkCredentials } from "./types.js"; + +type TokenCache = { + token: string; + expiresAt: number; +}; + +const cache = new Map(); + +export function resolveLarkCredentials(cfg?: LarkConfig): LarkCredentials | null { + if (!cfg?.appId || !cfg?.appSecret) return null; + return { + appId: cfg.appId, + appSecret: cfg.appSecret, + encryptKey: cfg.encryptKey, + verificationToken: cfg.verificationToken, + baseUrl: cfg.baseUrl ?? "https://open.feishu.cn", + }; +} + +export async function getTenantAccessToken(creds: LarkCredentials): Promise { + const cacheKey = creds.appId; + const cached = cache.get(cacheKey); + if (cached && cached.expiresAt > Date.now() + 60000) { + return cached.token; + } + + const url = `${creds.baseUrl.replace(/\/$/, "")}/open-apis/auth/v3/tenant_access_token/internal`; + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + app_id: creds.appId, + app_secret: creds.appSecret, + }), + }); + + if (!res.ok) { + throw new Error(`Failed to get tenant access token: ${res.statusText}`); + } + + const data = await res.json() as { code: number; msg: string; tenant_access_token: string; expire: number }; + if (data.code !== 0) { + throw new Error(`Feishu auth error: ${data.msg}`); + } + + cache.set(cacheKey, { + token: data.tenant_access_token, + expiresAt: Date.now() + (data.expire * 1000), + }); + + return data.tenant_access_token; +} diff --git a/extensions/lark/src/types.ts b/extensions/lark/src/types.ts new file mode 100644 index 000000000..eed6c6f13 --- /dev/null +++ b/extensions/lark/src/types.ts @@ -0,0 +1,46 @@ +import { z } from "clawdbot/plugin-sdk"; + +/** + * DM policy for Lark channel + * - "open": Accept messages from anyone + * - "allowlist": Only accept messages from users in allowFrom list + * - "pairing": Require pairing code for new users (default) + */ +export const LarkDmPolicySchema = z.enum(["open", "allowlist", "pairing"]).default("pairing"); + +/** + * Group configuration for Lark + */ +export const LarkGroupConfigSchema = z.object({ + requireMention: z.boolean().optional().describe("Require @mention to trigger in this group"), + toolPolicy: z.enum(["full", "limited", "none"]).optional().describe("Tool access policy for this group"), +}).passthrough(); + +export const LarkConfigSchema = z.object({ + enabled: z.boolean().default(true), + appId: z.string().describe("Feishu App ID"), + appSecret: z.string().describe("Feishu App Secret"), + encryptKey: z.string().optional().describe("Event subscription encrypt key"), + verificationToken: z.string().optional().describe("Event verification token"), + baseUrl: z.string().default("https://open.feishu.cn").describe("API Base URL (e.g. https://open.larksuite.com)"), + webhook: z.object({ + path: z.string().default("/lark/webhook"), + port: z.number().default(3000), + }).optional(), + dmPolicy: LarkDmPolicySchema.optional().describe("DM access policy: open, allowlist, or pairing"), + allowFrom: z.array(z.string()).optional().describe("List of allowed user IDs (open_id or user_id)"), + groups: z.record(z.string(), LarkGroupConfigSchema).optional().describe("Group-specific configurations"), + groupPolicy: z.enum(["open", "allowlist"]).optional().describe("Group access policy"), +}); + +export type LarkConfig = z.infer; +export type LarkDmPolicy = z.infer; +export type LarkGroupConfig = z.infer; + +export type LarkCredentials = { + appId: string; + appSecret: string; + encryptKey?: string; + verificationToken?: string; + baseUrl: string; +}; diff --git a/extensions/lark/tsconfig.json b/extensions/lark/tsconfig.json new file mode 100644 index 000000000..f807df4af --- /dev/null +++ b/extensions/lark/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": ".", + "composite": true + }, + "include": ["index.ts", "src/**/*"] +}