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:
parent
6859e1e6a6
commit
e89378a0e7
35
extensions/lark/README.md
Normal file
35
extensions/lark/README.md
Normal 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
18
extensions/lark/index.ts
Normal 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;
|
||||||
46
extensions/lark/package.json
Normal file
46
extensions/lark/package.json
Normal 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": "*"
|
||||||
|
}
|
||||||
|
}
|
||||||
270
extensions/lark/src/channel.ts
Normal file
270
extensions/lark/src/channel.ts
Normal 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" };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
4
extensions/lark/src/index.ts
Normal file
4
extensions/lark/src/index.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export { monitorLarkProvider } from "./monitor.js";
|
||||||
|
export { larkOutbound } from "./send.js";
|
||||||
|
export { type LarkConfig, LarkConfigSchema } from "./types.js";
|
||||||
|
|
||||||
246
extensions/lark/src/monitor.ts
Normal file
246
extensions/lark/src/monitor.ts
Normal 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;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
35
extensions/lark/src/reply-dispatcher.ts
Normal file
35
extensions/lark/src/reply-dispatcher.ts
Normal 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() };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
14
extensions/lark/src/runtime.ts
Normal file
14
extensions/lark/src/runtime.ts
Normal 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;
|
||||||
|
}
|
||||||
70
extensions/lark/src/send.ts
Normal file
70
extensions/lark/src/send.ts
Normal 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(),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
53
extensions/lark/src/token.ts
Normal file
53
extensions/lark/src/token.ts
Normal 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;
|
||||||
|
}
|
||||||
46
extensions/lark/src/types.ts
Normal file
46
extensions/lark/src/types.ts
Normal 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;
|
||||||
|
};
|
||||||
9
extensions/lark/tsconfig.json
Normal file
9
extensions/lark/tsconfig.json
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": ".",
|
||||||
|
"composite": true
|
||||||
|
},
|
||||||
|
"include": ["index.ts", "src/**/*"]
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user