Merge cea071f247 into da71eaebd2
This commit is contained in:
commit
2db5795a57
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, "");
|
||||
}
|
||||
|
||||
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/**/*"]
|
||||
}
|
||||
32
pnpm-lock.yaml
generated
32
pnpm-lock.yaml
generated
@ -320,6 +320,25 @@ importers:
|
||||
|
||||
extensions/imessage: {}
|
||||
|
||||
extensions/lark:
|
||||
dependencies:
|
||||
express:
|
||||
specifier: ^5.2.1
|
||||
version: 5.2.1
|
||||
devDependencies:
|
||||
'@types/express':
|
||||
specifier: ^5.0.0
|
||||
version: 5.0.6
|
||||
'@types/node':
|
||||
specifier: ^22.0.0
|
||||
version: 22.19.7
|
||||
clawdbot:
|
||||
specifier: workspace:*
|
||||
version: link:../..
|
||||
typescript:
|
||||
specifier: ^5.7.3
|
||||
version: 5.9.3
|
||||
|
||||
extensions/line:
|
||||
devDependencies:
|
||||
openclaw:
|
||||
@ -2746,6 +2765,9 @@ packages:
|
||||
'@types/node@20.19.30':
|
||||
resolution: {integrity: sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==}
|
||||
|
||||
'@types/node@22.19.7':
|
||||
resolution: {integrity: sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==}
|
||||
|
||||
'@types/node@24.10.9':
|
||||
resolution: {integrity: sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==}
|
||||
|
||||
@ -8473,7 +8495,7 @@ snapshots:
|
||||
|
||||
'@types/express-serve-static-core@5.1.1':
|
||||
dependencies:
|
||||
'@types/node': 25.0.10
|
||||
'@types/node': 22.19.7
|
||||
'@types/qs': 6.14.0
|
||||
'@types/range-parser': 1.2.7
|
||||
'@types/send': 1.2.1
|
||||
@ -8521,6 +8543,10 @@ snapshots:
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
|
||||
'@types/node@22.19.7':
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
|
||||
'@types/node@24.10.9':
|
||||
dependencies:
|
||||
undici-types: 7.16.0
|
||||
@ -8557,7 +8583,7 @@ snapshots:
|
||||
|
||||
'@types/send@1.2.1':
|
||||
dependencies:
|
||||
'@types/node': 25.0.10
|
||||
'@types/node': 22.19.7
|
||||
|
||||
'@types/serve-static@1.15.10':
|
||||
dependencies:
|
||||
@ -8568,7 +8594,7 @@ snapshots:
|
||||
'@types/serve-static@2.2.0':
|
||||
dependencies:
|
||||
'@types/http-errors': 2.0.5
|
||||
'@types/node': 25.0.10
|
||||
'@types/node': 22.19.7
|
||||
|
||||
'@types/tough-cookie@4.0.5': {}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user