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
This commit is contained in:
devsh 2026-01-26 17:29:10 +08:00
parent 6859e1e6a6
commit e89378a0e7
12 changed files with 846 additions and 0 deletions

35
extensions/lark/README.md Normal file
View File

@ -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.

18
extensions/lark/index.ts Normal file
View File

@ -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;

View File

@ -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": "*"
}
}

View File

@ -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<ResolvedLarkAccount> = {
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" };
},
},
};

View File

@ -0,0 +1,4 @@
export { monitorLarkProvider } from "./monitor.js";
export { larkOutbound } from "./send.js";
export { type LarkConfig, LarkConfigSchema } from "./types.js";

View File

@ -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<void>;
};
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<MonitorLarkResult> {
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<typeof app.listen> | 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;
}
},
};
}

View File

@ -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() };
},
};
}

View File

@ -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;
}

View File

@ -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(),
};
},
};

View File

@ -0,0 +1,53 @@
import type { LarkConfig, LarkCredentials } from "./types.js";
type TokenCache = {
token: string;
expiresAt: number;
};
const cache = new Map<string, TokenCache>();
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<string> {
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;
}

View File

@ -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<typeof LarkConfigSchema>;
export type LarkDmPolicy = z.infer<typeof LarkDmPolicySchema>;
export type LarkGroupConfig = z.infer<typeof LarkGroupConfigSchema>;
export type LarkCredentials = {
appId: string;
appSecret: string;
encryptKey?: string;
verificationToken?: string;
baseUrl: string;
};

View File

@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": ".",
"composite": true
},
"include": ["index.ts", "src/**/*"]
}