feat(feishu): add long connection mode
This commit is contained in:
parent
229736e6ec
commit
1f7d41183d
@ -22,7 +22,7 @@
|
|||||||
"selectionLabel": "Feishu (Bot API)",
|
"selectionLabel": "Feishu (Bot API)",
|
||||||
"docsPath": "/channels/feishu",
|
"docsPath": "/channels/feishu",
|
||||||
"docsLabel": "feishu",
|
"docsLabel": "feishu",
|
||||||
"blurb": "Feishu bot with HTTP webhook events.",
|
"blurb": "Feishu bot events via webhook or long connection.",
|
||||||
"aliases": [
|
"aliases": [
|
||||||
"fs"
|
"fs"
|
||||||
],
|
],
|
||||||
|
|||||||
@ -52,9 +52,12 @@ function mergeFeishuAccountConfig(cfg: ClawdbotConfig, accountId: string): Feish
|
|||||||
function hasCredentials(cfg: FeishuAccountConfig): boolean {
|
function hasCredentials(cfg: FeishuAccountConfig): boolean {
|
||||||
const appId = cfg.appId?.trim();
|
const appId = cfg.appId?.trim();
|
||||||
const appSecret = cfg.appSecret?.trim();
|
const appSecret = cfg.appSecret?.trim();
|
||||||
|
if (!appId || !appSecret) return false;
|
||||||
|
const mode = cfg.mode ?? "http";
|
||||||
|
if (mode === "ws") return true;
|
||||||
const token = cfg.verificationToken?.trim();
|
const token = cfg.verificationToken?.trim();
|
||||||
const encryptKey = cfg.encryptKey?.trim();
|
const encryptKey = cfg.encryptKey?.trim();
|
||||||
return Boolean(appId && appSecret && (token || encryptKey));
|
return Boolean(token || encryptKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveFeishuAccount(params: {
|
export function resolveFeishuAccount(params: {
|
||||||
|
|||||||
@ -41,7 +41,7 @@ const meta = {
|
|||||||
selectionLabel: "Feishu (Bot API)",
|
selectionLabel: "Feishu (Bot API)",
|
||||||
docsPath: "/channels/feishu",
|
docsPath: "/channels/feishu",
|
||||||
docsLabel: "feishu",
|
docsLabel: "feishu",
|
||||||
blurb: "Feishu bot with HTTP webhook events.",
|
blurb: "Feishu bot events via webhook or long connection.",
|
||||||
aliases: ["fs"],
|
aliases: ["fs"],
|
||||||
order: 85,
|
order: 85,
|
||||||
quickstartAllowFrom: true,
|
quickstartAllowFrom: true,
|
||||||
@ -271,6 +271,12 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
|
|||||||
}
|
}
|
||||||
const appId = (input as { appId?: unknown }).appId;
|
const appId = (input as { appId?: unknown }).appId;
|
||||||
const appSecret = (input as { appSecret?: unknown }).appSecret;
|
const appSecret = (input as { appSecret?: unknown }).appSecret;
|
||||||
|
const modeRaw = (input as { mode?: unknown }).mode;
|
||||||
|
const mode =
|
||||||
|
typeof modeRaw === "string" && modeRaw.trim() ? modeRaw.trim().toLowerCase() : undefined;
|
||||||
|
if (mode && mode !== "http" && mode !== "ws") {
|
||||||
|
return 'Feishu --mode must be "http" or "ws".';
|
||||||
|
}
|
||||||
const verificationToken = (input as { verificationToken?: unknown }).verificationToken;
|
const verificationToken = (input as { verificationToken?: unknown }).verificationToken;
|
||||||
const encryptKey = (input as { encryptKey?: unknown }).encryptKey;
|
const encryptKey = (input as { encryptKey?: unknown }).encryptKey;
|
||||||
if (typeof appId !== "string" || !appId.trim()) {
|
if (typeof appId !== "string" || !appId.trim()) {
|
||||||
@ -279,11 +285,13 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
|
|||||||
if (typeof appSecret !== "string" || !appSecret.trim()) {
|
if (typeof appSecret !== "string" || !appSecret.trim()) {
|
||||||
return "Feishu requires --app-secret.";
|
return "Feishu requires --app-secret.";
|
||||||
}
|
}
|
||||||
if (
|
if (mode !== "ws") {
|
||||||
(typeof verificationToken !== "string" || !verificationToken.trim()) &&
|
if (
|
||||||
(typeof encryptKey !== "string" || !encryptKey.trim())
|
(typeof verificationToken !== "string" || !verificationToken.trim()) &&
|
||||||
) {
|
(typeof encryptKey !== "string" || !encryptKey.trim())
|
||||||
return "Feishu requires --verification-token or --encrypt-key.";
|
) {
|
||||||
|
return 'Feishu requires --verification-token or --encrypt-key when --mode="http".';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
@ -304,6 +312,10 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
|
|||||||
|
|
||||||
const appId = String((input as { appId?: unknown }).appId ?? "").trim();
|
const appId = String((input as { appId?: unknown }).appId ?? "").trim();
|
||||||
const appSecret = String((input as { appSecret?: unknown }).appSecret ?? "").trim();
|
const appSecret = String((input as { appSecret?: unknown }).appSecret ?? "").trim();
|
||||||
|
const modeRaw = (input as { mode?: unknown }).mode;
|
||||||
|
const mode =
|
||||||
|
typeof modeRaw === "string" && modeRaw.trim() ? modeRaw.trim().toLowerCase() : undefined;
|
||||||
|
const normalizedMode = mode === "http" || mode === "ws" ? mode : undefined;
|
||||||
const verificationTokenRaw = (input as { verificationToken?: unknown }).verificationToken;
|
const verificationTokenRaw = (input as { verificationToken?: unknown }).verificationToken;
|
||||||
const encryptKeyRaw = (input as { encryptKey?: unknown }).encryptKey;
|
const encryptKeyRaw = (input as { encryptKey?: unknown }).encryptKey;
|
||||||
const verificationToken =
|
const verificationToken =
|
||||||
@ -320,6 +332,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
|
|||||||
const configPatch = {
|
const configPatch = {
|
||||||
appId,
|
appId,
|
||||||
appSecret,
|
appSecret,
|
||||||
|
...(normalizedMode ? { mode: normalizedMode } : {}),
|
||||||
...(verificationToken ? { verificationToken } : {}),
|
...(verificationToken ? { verificationToken } : {}),
|
||||||
...(encryptKey ? { encryptKey } : {}),
|
...(encryptKey ? { encryptKey } : {}),
|
||||||
...(webhookPath ? { webhookPath } : {}),
|
...(webhookPath ? { webhookPath } : {}),
|
||||||
@ -463,6 +476,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
|
|||||||
lastStartAt: null,
|
lastStartAt: null,
|
||||||
lastStopAt: null,
|
lastStopAt: null,
|
||||||
lastError: null,
|
lastError: null,
|
||||||
|
mode: null,
|
||||||
webhookPath: null,
|
webhookPath: null,
|
||||||
lastInboundAt: null,
|
lastInboundAt: null,
|
||||||
lastOutboundAt: null,
|
lastOutboundAt: null,
|
||||||
@ -470,6 +484,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
|
|||||||
buildChannelSummary: ({ snapshot }) => ({
|
buildChannelSummary: ({ snapshot }) => ({
|
||||||
configured: snapshot.configured ?? false,
|
configured: snapshot.configured ?? false,
|
||||||
running: snapshot.running ?? false,
|
running: snapshot.running ?? false,
|
||||||
|
mode: snapshot.mode ?? null,
|
||||||
webhookPath: snapshot.webhookPath ?? null,
|
webhookPath: snapshot.webhookPath ?? null,
|
||||||
lastInboundAt: snapshot.lastInboundAt ?? null,
|
lastInboundAt: snapshot.lastInboundAt ?? null,
|
||||||
lastOutboundAt: snapshot.lastOutboundAt ?? null,
|
lastOutboundAt: snapshot.lastOutboundAt ?? null,
|
||||||
@ -486,6 +501,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
|
|||||||
enabled: account.enabled,
|
enabled: account.enabled,
|
||||||
configured: account.credentialSource !== "none",
|
configured: account.credentialSource !== "none",
|
||||||
credentialSource: account.credentialSource,
|
credentialSource: account.credentialSource,
|
||||||
|
mode: runtime?.mode ?? account.config.mode ?? "http",
|
||||||
webhookPath: account.config.webhookPath,
|
webhookPath: account.config.webhookPath,
|
||||||
webhookUrl: account.config.webhookUrl,
|
webhookUrl: account.config.webhookUrl,
|
||||||
running: runtime?.running ?? false,
|
running: runtime?.running ?? false,
|
||||||
@ -500,14 +516,18 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
|
|||||||
gateway: {
|
gateway: {
|
||||||
startAccount: async (ctx) => {
|
startAccount: async (ctx) => {
|
||||||
const account = ctx.account;
|
const account = ctx.account;
|
||||||
ctx.log?.info(`[${account.accountId}] starting Feishu webhook`);
|
const mode = account.config.mode ?? "http";
|
||||||
|
ctx.log?.info(
|
||||||
|
`[${account.accountId}] starting Feishu ${mode === "ws" ? "long connection" : "webhook"}`,
|
||||||
|
);
|
||||||
ctx.setStatus({
|
ctx.setStatus({
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
running: true,
|
running: true,
|
||||||
lastStartAt: Date.now(),
|
lastStartAt: Date.now(),
|
||||||
webhookPath: resolveFeishuWebhookPath({ account }),
|
mode,
|
||||||
|
webhookPath: mode === "http" ? resolveFeishuWebhookPath({ account }) : null,
|
||||||
});
|
});
|
||||||
const unregister = await startFeishuMonitor({
|
await startFeishuMonitor({
|
||||||
account,
|
account,
|
||||||
config: ctx.cfg as ClawdbotConfig,
|
config: ctx.cfg as ClawdbotConfig,
|
||||||
runtime: ctx.runtime,
|
runtime: ctx.runtime,
|
||||||
@ -516,14 +536,6 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
|
|||||||
webhookUrl: account.config.webhookUrl,
|
webhookUrl: account.config.webhookUrl,
|
||||||
statusSink: (patch) => ctx.setStatus({ accountId: account.accountId, ...patch }),
|
statusSink: (patch) => ctx.setStatus({ accountId: account.accountId, ...patch }),
|
||||||
});
|
});
|
||||||
return () => {
|
|
||||||
unregister?.();
|
|
||||||
ctx.setStatus({
|
|
||||||
accountId: account.accountId,
|
|
||||||
running: false,
|
|
||||||
lastStopAt: Date.now(),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
41
extensions/feishu/src/config-mode.test.ts
Normal file
41
extensions/feishu/src/config-mode.test.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
|
import { resolveFeishuAccount } from "./accounts.js";
|
||||||
|
import { FeishuConfigSchema } from "./config-schema.js";
|
||||||
|
|
||||||
|
describe("Feishu config mode", () => {
|
||||||
|
it("allows ws mode without webhook validation secrets", () => {
|
||||||
|
const parsed = FeishuConfigSchema.parse({
|
||||||
|
appId: "cli_test",
|
||||||
|
appSecret: "secret_test",
|
||||||
|
mode: "ws",
|
||||||
|
});
|
||||||
|
expect(parsed.mode).toBe("ws");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires webhook validation secrets in http mode", () => {
|
||||||
|
expect(() =>
|
||||||
|
FeishuConfigSchema.parse({
|
||||||
|
appId: "cli_test",
|
||||||
|
appSecret: "secret_test",
|
||||||
|
mode: "http",
|
||||||
|
}),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats ws mode as configured with app credentials", () => {
|
||||||
|
const cfg = {
|
||||||
|
channels: {
|
||||||
|
feishu: {
|
||||||
|
appId: "cli_test",
|
||||||
|
appSecret: "secret_test",
|
||||||
|
mode: "ws",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as ClawdbotConfig;
|
||||||
|
const resolved = resolveFeishuAccount({ cfg, accountId: "default" });
|
||||||
|
expect(resolved.credentialSource).toBe("config");
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -37,6 +37,7 @@ export const FeishuAccountSchema = z
|
|||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
appId: z.string().optional(),
|
appId: z.string().optional(),
|
||||||
appSecret: z.string().optional(),
|
appSecret: z.string().optional(),
|
||||||
|
mode: z.enum(["http", "ws"]).optional(),
|
||||||
verificationToken: z.string().optional(),
|
verificationToken: z.string().optional(),
|
||||||
encryptKey: z.string().optional(),
|
encryptKey: z.string().optional(),
|
||||||
webhookPath: z.string().optional(),
|
webhookPath: z.string().optional(),
|
||||||
@ -68,12 +69,13 @@ export const FeishuAccountSchema = z
|
|||||||
path: ["appId"],
|
path: ["appId"],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
const mode = value.mode ?? "http";
|
||||||
const verificationToken = value.verificationToken?.trim();
|
const verificationToken = value.verificationToken?.trim();
|
||||||
const encryptKey = value.encryptKey?.trim();
|
const encryptKey = value.encryptKey?.trim();
|
||||||
if ((appId || appSecret) && !verificationToken && !encryptKey) {
|
if (mode === "http" && (appId || appSecret) && !verificationToken && !encryptKey) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
message: "verificationToken or encryptKey is required for webhook validation",
|
message: 'verificationToken or encryptKey is required when mode="http"',
|
||||||
path: ["verificationToken"],
|
path: ["verificationToken"],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
448
extensions/feishu/src/inbound.ts
Normal file
448
extensions/feishu/src/inbound.ts
Normal file
@ -0,0 +1,448 @@
|
|||||||
|
import type { ClawdbotConfig, PluginRuntime } from "clawdbot/plugin-sdk";
|
||||||
|
import { resolveMentionGatingWithBypass } from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
|
import type { ResolvedFeishuAccount } from "./accounts.js";
|
||||||
|
import { getBotIdentity, sendFeishuTextMessage } from "./api.js";
|
||||||
|
import type { FeishuMessagingTarget } from "./targets.js";
|
||||||
|
|
||||||
|
export type FeishuRuntimeEnv = {
|
||||||
|
log?: (message: string) => void;
|
||||||
|
error?: (message: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FeishuStatusSink = (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function readString(value: unknown): string | undefined {
|
||||||
|
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJson(raw: string): { ok: true; value: unknown } | { ok: false; error: string } {
|
||||||
|
try {
|
||||||
|
return { ok: true, value: JSON.parse(raw) as unknown };
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseFeishuTimestampMs(value: string | undefined): number | undefined {
|
||||||
|
const raw = value?.trim();
|
||||||
|
if (!raw) return undefined;
|
||||||
|
const num = Number.parseInt(raw, 10);
|
||||||
|
if (!Number.isFinite(num)) return undefined;
|
||||||
|
if (raw.length >= 13) return num;
|
||||||
|
return num * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractMentions(value: unknown): Array<Record<string, unknown>> {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
return value.filter(isRecord);
|
||||||
|
}
|
||||||
|
|
||||||
|
function logVerbose(core: PluginRuntime, runtime: FeishuRuntimeEnv, message: string): void {
|
||||||
|
if (core.logging.shouldLogVerbose()) {
|
||||||
|
runtime.log?.(`[feishu] ${message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAllowEntry(raw: string): string {
|
||||||
|
return raw
|
||||||
|
.trim()
|
||||||
|
.replace(/^feishu:/i, "")
|
||||||
|
.replace(/^fs:/i, "")
|
||||||
|
.replace(/^user:/i, "")
|
||||||
|
.replace(/^open_id:/i, "")
|
||||||
|
.replace(/^openid:/i, "")
|
||||||
|
.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSenderAllowed(
|
||||||
|
senderOpenId: string,
|
||||||
|
senderUserId: string | undefined,
|
||||||
|
allowFrom: string[],
|
||||||
|
): boolean {
|
||||||
|
if (allowFrom.includes("*")) return true;
|
||||||
|
const openId = normalizeAllowEntry(senderOpenId);
|
||||||
|
const userId = senderUserId?.trim().toLowerCase() ?? "";
|
||||||
|
return allowFrom.some((entry) => {
|
||||||
|
const normalized = normalizeAllowEntry(String(entry));
|
||||||
|
if (!normalized) return false;
|
||||||
|
if (normalized === openId) return true;
|
||||||
|
if (userId && normalized === userId) return true;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveGroupEntry(account: ResolvedFeishuAccount, chatId: string) {
|
||||||
|
const groups = account.config.groups ?? {};
|
||||||
|
const keys = Object.keys(groups).filter(Boolean);
|
||||||
|
if (keys.length === 0) return { entry: undefined, allowlistConfigured: false };
|
||||||
|
const entry = groups[chatId] ?? groups["*"];
|
||||||
|
return { entry, allowlistConfigured: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractTextFromMessageContent(content: string): string {
|
||||||
|
const trimmed = content.trim();
|
||||||
|
if (!trimmed) return "";
|
||||||
|
const parsed = parseJson(trimmed);
|
||||||
|
if (parsed.ok && isRecord(parsed.value)) {
|
||||||
|
const text = readString(parsed.value.text);
|
||||||
|
if (text) return text;
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function wasBotMentioned(params: {
|
||||||
|
mentions: Array<Record<string, unknown>>;
|
||||||
|
bot: { openId?: string; userId?: string };
|
||||||
|
}): boolean {
|
||||||
|
const botOpenId = params.bot.openId?.trim();
|
||||||
|
const botUserId = params.bot.userId?.trim();
|
||||||
|
if (!botOpenId && !botUserId) return false;
|
||||||
|
return params.mentions.some((mention) => {
|
||||||
|
const id = isRecord(mention.id) ? mention.id : null;
|
||||||
|
const openId = readString((id ?? mention).open_id) ?? readString((id ?? mention).openId);
|
||||||
|
const userId = readString((id ?? mention).user_id) ?? readString((id ?? mention).userId);
|
||||||
|
if (botOpenId && openId && botOpenId === openId) return true;
|
||||||
|
if (botUserId && userId && botUserId === userId) return true;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deliverFeishuReply(params: {
|
||||||
|
payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string; replyToId?: string };
|
||||||
|
account: ResolvedFeishuAccount;
|
||||||
|
target: FeishuMessagingTarget;
|
||||||
|
runtime: FeishuRuntimeEnv;
|
||||||
|
core: PluginRuntime;
|
||||||
|
cfg: ClawdbotConfig;
|
||||||
|
statusSink?: FeishuStatusSink;
|
||||||
|
}) {
|
||||||
|
const { payload, account, target, runtime, core, cfg, statusSink } = params;
|
||||||
|
const mediaList = payload.mediaUrls?.length
|
||||||
|
? payload.mediaUrls
|
||||||
|
: payload.mediaUrl
|
||||||
|
? [payload.mediaUrl]
|
||||||
|
: [];
|
||||||
|
const replyToMessageId = payload.replyToId?.trim() || undefined;
|
||||||
|
|
||||||
|
let text = payload.text ?? "";
|
||||||
|
if (mediaList.length > 0) {
|
||||||
|
const suffix = mediaList.join("\n");
|
||||||
|
text = text.trim() ? `${text.trim()}\n${suffix}` : suffix;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!text.trim()) return;
|
||||||
|
|
||||||
|
const chunkLimit = account.config.textChunkLimit ?? 4000;
|
||||||
|
const chunkMode = core.channel.text.resolveChunkMode(cfg, "feishu", account.accountId);
|
||||||
|
const chunks = core.channel.text.chunkMarkdownTextWithMode(text, chunkLimit, chunkMode);
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
try {
|
||||||
|
await sendFeishuTextMessage({
|
||||||
|
account,
|
||||||
|
target,
|
||||||
|
text: chunk,
|
||||||
|
replyToMessageId,
|
||||||
|
});
|
||||||
|
statusSink?.({ lastOutboundAt: Date.now() });
|
||||||
|
} catch (err) {
|
||||||
|
runtime.error?.(`[${account.accountId}] Feishu send failed: ${String(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function processFeishuMessage(params: {
|
||||||
|
account: ResolvedFeishuAccount;
|
||||||
|
config: ClawdbotConfig;
|
||||||
|
runtime: FeishuRuntimeEnv;
|
||||||
|
core: PluginRuntime;
|
||||||
|
statusSink?: FeishuStatusSink;
|
||||||
|
messageId: string;
|
||||||
|
chatId: string;
|
||||||
|
chatType: string;
|
||||||
|
messageType: string;
|
||||||
|
content: string;
|
||||||
|
mentions: Array<Record<string, unknown>>;
|
||||||
|
senderOpenId: string;
|
||||||
|
senderUserId?: string;
|
||||||
|
senderType?: string;
|
||||||
|
createdAt?: number;
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
account,
|
||||||
|
config,
|
||||||
|
runtime,
|
||||||
|
core,
|
||||||
|
statusSink,
|
||||||
|
messageId,
|
||||||
|
chatId,
|
||||||
|
chatType,
|
||||||
|
messageType,
|
||||||
|
content,
|
||||||
|
mentions,
|
||||||
|
senderOpenId,
|
||||||
|
senderUserId,
|
||||||
|
senderType,
|
||||||
|
createdAt,
|
||||||
|
} = params;
|
||||||
|
|
||||||
|
const isGroup = chatType.toLowerCase() !== "p2p";
|
||||||
|
|
||||||
|
if (senderType && senderType.toLowerCase() !== "user") {
|
||||||
|
logVerbose(core, runtime, `skip non-user sender (type=${senderType})`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (messageType.toLowerCase() !== "text") {
|
||||||
|
logVerbose(core, runtime, `skip non-text message (type=${messageType})`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawBody = extractTextFromMessageContent(content).trim();
|
||||||
|
if (!rawBody) return;
|
||||||
|
|
||||||
|
const defaultGroupPolicy = config.channels?.defaults?.groupPolicy;
|
||||||
|
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
|
||||||
|
const groupResolved = isGroup ? resolveGroupEntry(account, chatId) : null;
|
||||||
|
const groupEntry = groupResolved?.entry;
|
||||||
|
|
||||||
|
const groupUsers = isGroup
|
||||||
|
? (groupEntry?.users ?? account.config.groupAllowFrom ?? []).map((v) => String(v))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (isGroup) {
|
||||||
|
if (groupPolicy === "disabled") {
|
||||||
|
logVerbose(core, runtime, `drop group message (groupPolicy=disabled, chat=${chatId})`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const allowlistConfigured = groupResolved?.allowlistConfigured ?? false;
|
||||||
|
const allowlisted = Boolean(groupEntry) || Boolean((account.config.groups ?? {})["*"]);
|
||||||
|
if (groupPolicy === "allowlist") {
|
||||||
|
if (!allowlistConfigured) {
|
||||||
|
logVerbose(
|
||||||
|
core,
|
||||||
|
runtime,
|
||||||
|
`drop group message (groupPolicy=allowlist, no allowlist, chat=${chatId})`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!allowlisted) {
|
||||||
|
logVerbose(core, runtime, `drop group message (not allowlisted, chat=${chatId})`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (groupEntry?.enabled === false || groupEntry?.allow === false) {
|
||||||
|
logVerbose(core, runtime, `drop group message (group disabled, chat=${chatId})`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (groupUsers.length > 0) {
|
||||||
|
const ok = isSenderAllowed(senderOpenId, senderUserId, groupUsers);
|
||||||
|
if (!ok) {
|
||||||
|
logVerbose(core, runtime, `drop group message (sender not allowed, user=${senderOpenId})`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dmPolicy = account.config.dm?.policy ?? "pairing";
|
||||||
|
const configAllowFrom = (account.config.dm?.allowFrom ?? []).map((v) => String(v));
|
||||||
|
const shouldComputeAuth = core.channel.commands.shouldComputeCommandAuthorized(rawBody, config);
|
||||||
|
const storeAllowFrom =
|
||||||
|
!isGroup && (dmPolicy !== "open" || shouldComputeAuth)
|
||||||
|
? await core.channel.pairing.readAllowFromStore("feishu").catch(() => [])
|
||||||
|
: [];
|
||||||
|
const effectiveAllowFrom = [...configAllowFrom, ...storeAllowFrom];
|
||||||
|
const commandAllowFrom = isGroup ? groupUsers : effectiveAllowFrom;
|
||||||
|
const useAccessGroups = config.commands?.useAccessGroups !== false;
|
||||||
|
const senderAllowedForCommands = isSenderAllowed(senderOpenId, senderUserId, commandAllowFrom);
|
||||||
|
const commandAuthorized = shouldComputeAuth
|
||||||
|
? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
|
||||||
|
useAccessGroups,
|
||||||
|
authorizers: [
|
||||||
|
{ configured: commandAllowFrom.length > 0, allowed: senderAllowedForCommands },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (isGroup) {
|
||||||
|
const requireMention = groupEntry?.requireMention ?? account.config.requireMention ?? true;
|
||||||
|
const allowTextCommands = core.channel.commands.shouldHandleTextCommands({
|
||||||
|
cfg: config,
|
||||||
|
surface: "feishu",
|
||||||
|
});
|
||||||
|
let botMentioned = false;
|
||||||
|
if (requireMention) {
|
||||||
|
try {
|
||||||
|
const bot = await getBotIdentity(account).catch(() => null);
|
||||||
|
if (bot?.openId || bot?.userId) {
|
||||||
|
botMentioned = wasBotMentioned({
|
||||||
|
mentions,
|
||||||
|
bot: { openId: bot.openId, userId: bot.userId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logVerbose(core, runtime, `bot identity lookup failed: ${String(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mentionGate = resolveMentionGatingWithBypass({
|
||||||
|
isGroup: true,
|
||||||
|
requireMention,
|
||||||
|
canDetectMention: true,
|
||||||
|
wasMentioned: botMentioned,
|
||||||
|
implicitMention: false,
|
||||||
|
hasAnyMention: mentions.length > 0,
|
||||||
|
allowTextCommands,
|
||||||
|
hasControlCommand: core.channel.text.hasControlCommand(rawBody, config),
|
||||||
|
commandAuthorized: commandAuthorized === true,
|
||||||
|
});
|
||||||
|
if (mentionGate.shouldSkip) {
|
||||||
|
logVerbose(core, runtime, `drop group message (mention required, chat=${chatId})`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isGroup) {
|
||||||
|
if (dmPolicy === "disabled" || account.config.dm?.enabled === false) {
|
||||||
|
logVerbose(core, runtime, `blocked Feishu DM from ${senderOpenId} (dmPolicy=disabled)`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dmPolicy !== "open") {
|
||||||
|
if (!senderAllowedForCommands) {
|
||||||
|
if (dmPolicy === "pairing") {
|
||||||
|
const { code, created } = await core.channel.pairing.upsertPairingRequest({
|
||||||
|
channel: "feishu",
|
||||||
|
id: senderOpenId,
|
||||||
|
meta: { userId: senderUserId },
|
||||||
|
});
|
||||||
|
if (created) {
|
||||||
|
logVerbose(core, runtime, `feishu pairing request sender=${senderOpenId}`);
|
||||||
|
try {
|
||||||
|
await sendFeishuTextMessage({
|
||||||
|
account,
|
||||||
|
target: { kind: "user", openId: senderOpenId },
|
||||||
|
text: core.channel.pairing.buildPairingReply({
|
||||||
|
channel: "feishu",
|
||||||
|
idLine: `Your Feishu open id: ${senderOpenId}`,
|
||||||
|
code,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
statusSink?.({ lastOutboundAt: Date.now() });
|
||||||
|
} catch (err) {
|
||||||
|
logVerbose(core, runtime, `pairing reply failed for ${senderOpenId}: ${String(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logVerbose(
|
||||||
|
core,
|
||||||
|
runtime,
|
||||||
|
`blocked unauthorized Feishu sender ${senderOpenId} (dmPolicy=${dmPolicy})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
isGroup &&
|
||||||
|
core.channel.commands.isControlCommandMessage(rawBody, config) &&
|
||||||
|
commandAuthorized !== true
|
||||||
|
) {
|
||||||
|
logVerbose(core, runtime, `feishu: drop control command from ${senderOpenId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const peerId = isGroup ? chatId : senderOpenId;
|
||||||
|
const route = core.channel.routing.resolveAgentRoute({
|
||||||
|
cfg: config,
|
||||||
|
channel: "feishu",
|
||||||
|
accountId: account.accountId,
|
||||||
|
peer: { kind: isGroup ? "group" : "dm", id: peerId },
|
||||||
|
});
|
||||||
|
|
||||||
|
const fromLabel = isGroup ? `chat:${chatId}` : `user:${senderOpenId}`;
|
||||||
|
const storePath = core.channel.session.resolveStorePath(config.session?.store, {
|
||||||
|
agentId: route.agentId,
|
||||||
|
});
|
||||||
|
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config);
|
||||||
|
const previousTimestamp = core.channel.session.readSessionUpdatedAt({
|
||||||
|
storePath,
|
||||||
|
sessionKey: route.sessionKey,
|
||||||
|
});
|
||||||
|
const body = core.channel.reply.formatAgentEnvelope({
|
||||||
|
channel: "Feishu",
|
||||||
|
from: fromLabel,
|
||||||
|
timestamp: createdAt,
|
||||||
|
previousTimestamp,
|
||||||
|
envelope: envelopeOptions,
|
||||||
|
body: rawBody,
|
||||||
|
});
|
||||||
|
|
||||||
|
const groupSystemPrompt = isGroup ? groupEntry?.systemPrompt?.trim() || undefined : undefined;
|
||||||
|
|
||||||
|
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
||||||
|
Body: body,
|
||||||
|
RawBody: rawBody,
|
||||||
|
CommandBody: rawBody,
|
||||||
|
From: `feishu:${senderOpenId}`,
|
||||||
|
To: isGroup ? `feishu:${chatId}` : `feishu:${senderOpenId}`,
|
||||||
|
SessionKey: route.sessionKey,
|
||||||
|
AccountId: route.accountId,
|
||||||
|
ChatType: isGroup ? "channel" : "direct",
|
||||||
|
ConversationLabel: fromLabel,
|
||||||
|
SenderId: senderOpenId,
|
||||||
|
SenderUsername: senderUserId,
|
||||||
|
CommandAuthorized: commandAuthorized,
|
||||||
|
Provider: "feishu",
|
||||||
|
Surface: "feishu",
|
||||||
|
MessageSid: messageId,
|
||||||
|
MessageSidFull: messageId,
|
||||||
|
ReplyToId: messageId,
|
||||||
|
ReplyToIdFull: messageId,
|
||||||
|
GroupSpace: isGroup ? chatId : undefined,
|
||||||
|
GroupSystemPrompt: groupSystemPrompt,
|
||||||
|
OriginatingChannel: "feishu",
|
||||||
|
OriginatingTo: isGroup ? `feishu:${chatId}` : `feishu:${senderOpenId}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
void core.channel.session
|
||||||
|
.recordSessionMetaFromInbound({
|
||||||
|
storePath,
|
||||||
|
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
||||||
|
ctx: ctxPayload,
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
runtime.error?.(`feishu: failed updating session meta: ${String(err)}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
const target: FeishuMessagingTarget = isGroup
|
||||||
|
? { kind: "chat", chatId }
|
||||||
|
: { kind: "user", openId: senderOpenId };
|
||||||
|
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
||||||
|
ctx: ctxPayload,
|
||||||
|
cfg: config,
|
||||||
|
dispatcherOptions: {
|
||||||
|
deliver: async (payload) => {
|
||||||
|
await deliverFeishuReply({
|
||||||
|
payload,
|
||||||
|
account,
|
||||||
|
target,
|
||||||
|
runtime,
|
||||||
|
core,
|
||||||
|
cfg: config,
|
||||||
|
statusSink,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (err, info) => {
|
||||||
|
runtime.error?.(`[${account.accountId}] Feishu ${info.kind} reply failed: ${String(err)}`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
80
extensions/feishu/src/monitor.lifecycle.test.ts
Normal file
80
extensions/feishu/src/monitor.lifecycle.test.ts
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
import { createServer } from "node:http";
|
||||||
|
import type { AddressInfo } from "node:net";
|
||||||
|
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import type { ClawdbotConfig, PluginRuntime } from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
|
import type { ResolvedFeishuAccount } from "./accounts.js";
|
||||||
|
import { startFeishuMonitor, handleFeishuWebhookRequest } from "./monitor.js";
|
||||||
|
import { setFeishuRuntime } from "./runtime.js";
|
||||||
|
|
||||||
|
async function withServer(
|
||||||
|
handler: Parameters<typeof createServer>[0],
|
||||||
|
fn: (baseUrl: string) => Promise<void>,
|
||||||
|
) {
|
||||||
|
const server = createServer(handler);
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
server.listen(0, "127.0.0.1", () => resolve());
|
||||||
|
});
|
||||||
|
const address = server.address() as AddressInfo | null;
|
||||||
|
if (!address) throw new Error("missing server address");
|
||||||
|
try {
|
||||||
|
await fn(`http://127.0.0.1:${address.port}`);
|
||||||
|
} finally {
|
||||||
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Feishu monitor lifecycle", () => {
|
||||||
|
it("unregisters webhook targets on abort", async () => {
|
||||||
|
const core = { logging: { shouldLogVerbose: () => false } } as unknown as PluginRuntime;
|
||||||
|
setFeishuRuntime(core);
|
||||||
|
|
||||||
|
const account: ResolvedFeishuAccount = {
|
||||||
|
accountId: "default",
|
||||||
|
enabled: true,
|
||||||
|
config: { mode: "http", verificationToken: "vtok" },
|
||||||
|
credentialSource: "config",
|
||||||
|
};
|
||||||
|
const abort = new AbortController();
|
||||||
|
const error = vi.fn();
|
||||||
|
|
||||||
|
const monitorTask = startFeishuMonitor({
|
||||||
|
account,
|
||||||
|
config: {} as ClawdbotConfig,
|
||||||
|
runtime: { error },
|
||||||
|
abortSignal: abort.signal,
|
||||||
|
webhookPath: "/hook",
|
||||||
|
});
|
||||||
|
|
||||||
|
await withServer(
|
||||||
|
async (req, res) => {
|
||||||
|
const handled = await handleFeishuWebhookRequest(req, res);
|
||||||
|
if (!handled) {
|
||||||
|
res.statusCode = 404;
|
||||||
|
res.end("not found");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async (baseUrl) => {
|
||||||
|
const payload = { token: "vtok", challenge: "abc", type: "url_verification" };
|
||||||
|
const response = await fetch(`${baseUrl}/hook`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
|
||||||
|
abort.abort();
|
||||||
|
await monitorTask;
|
||||||
|
|
||||||
|
const response2 = await fetch(`${baseUrl}/hook`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
expect(response2.status).toBe(404);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(error).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,18 +1,13 @@
|
|||||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||||
|
|
||||||
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||||
import { resolveMentionGatingWithBypass } from "clawdbot/plugin-sdk";
|
|
||||||
|
|
||||||
import type { ResolvedFeishuAccount } from "./accounts.js";
|
import type { ResolvedFeishuAccount } from "./accounts.js";
|
||||||
import { getBotIdentity, sendFeishuTextMessage } from "./api.js";
|
|
||||||
import { decryptFeishuEncrypt, verifyFeishuSignature } from "./auth.js";
|
import { decryptFeishuEncrypt, verifyFeishuSignature } from "./auth.js";
|
||||||
import { getFeishuRuntime } from "./runtime.js";
|
import { getFeishuRuntime } from "./runtime.js";
|
||||||
import type { FeishuMessagingTarget } from "./targets.js";
|
import { processFeishuMessage, parseFeishuTimestampMs, extractMentions } from "./inbound.js";
|
||||||
|
import type { FeishuRuntimeEnv, FeishuStatusSink } from "./inbound.js";
|
||||||
export type FeishuRuntimeEnv = {
|
import { monitorFeishuLongConnection } from "./ws.js";
|
||||||
log?: (message: string) => void;
|
|
||||||
error?: (message: string) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type FeishuMonitorOptions = {
|
export type FeishuMonitorOptions = {
|
||||||
account: ResolvedFeishuAccount;
|
account: ResolvedFeishuAccount;
|
||||||
@ -21,7 +16,7 @@ export type FeishuMonitorOptions = {
|
|||||||
abortSignal: AbortSignal;
|
abortSignal: AbortSignal;
|
||||||
webhookPath?: string;
|
webhookPath?: string;
|
||||||
webhookUrl?: string;
|
webhookUrl?: string;
|
||||||
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
statusSink?: FeishuStatusSink;
|
||||||
};
|
};
|
||||||
|
|
||||||
type FeishuCoreRuntime = ReturnType<typeof getFeishuRuntime>;
|
type FeishuCoreRuntime = ReturnType<typeof getFeishuRuntime>;
|
||||||
@ -32,7 +27,7 @@ type WebhookTarget = {
|
|||||||
runtime: FeishuRuntimeEnv;
|
runtime: FeishuRuntimeEnv;
|
||||||
core: FeishuCoreRuntime;
|
core: FeishuCoreRuntime;
|
||||||
path: string;
|
path: string;
|
||||||
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
statusSink?: FeishuStatusSink;
|
||||||
};
|
};
|
||||||
|
|
||||||
const webhookTargets = new Map<string, WebhookTarget[]>();
|
const webhookTargets = new Map<string, WebhookTarget[]>();
|
||||||
@ -45,12 +40,6 @@ function readString(value: unknown): string | undefined {
|
|||||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function logVerbose(core: FeishuCoreRuntime, runtime: FeishuRuntimeEnv, message: string): void {
|
|
||||||
if (core.logging.shouldLogVerbose()) {
|
|
||||||
runtime.log?.(`[feishu] ${message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeWebhookPath(raw: string): string {
|
function normalizeWebhookPath(raw: string): string {
|
||||||
const trimmed = raw.trim();
|
const trimmed = raw.trim();
|
||||||
if (!trimmed) return "/";
|
if (!trimmed) return "/";
|
||||||
@ -224,419 +213,6 @@ function readHeaderValues(req: IncomingMessage) {
|
|||||||
return { timestamp, nonce, signature };
|
return { timestamp, nonce, signature };
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseFeishuTimestampMs(value: string | undefined): number | undefined {
|
|
||||||
const raw = value?.trim();
|
|
||||||
if (!raw) return undefined;
|
|
||||||
const num = Number.parseInt(raw, 10);
|
|
||||||
if (!Number.isFinite(num)) return undefined;
|
|
||||||
if (raw.length >= 13) return num;
|
|
||||||
return num * 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeAllowEntry(raw: string): string {
|
|
||||||
return raw
|
|
||||||
.trim()
|
|
||||||
.replace(/^feishu:/i, "")
|
|
||||||
.replace(/^fs:/i, "")
|
|
||||||
.replace(/^user:/i, "")
|
|
||||||
.replace(/^open_id:/i, "")
|
|
||||||
.replace(/^openid:/i, "")
|
|
||||||
.toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
function isSenderAllowed(
|
|
||||||
senderOpenId: string,
|
|
||||||
senderUserId: string | undefined,
|
|
||||||
allowFrom: string[],
|
|
||||||
): boolean {
|
|
||||||
if (allowFrom.includes("*")) return true;
|
|
||||||
const openId = normalizeAllowEntry(senderOpenId);
|
|
||||||
const userId = senderUserId?.trim().toLowerCase() ?? "";
|
|
||||||
return allowFrom.some((entry) => {
|
|
||||||
const normalized = normalizeAllowEntry(String(entry));
|
|
||||||
if (!normalized) return false;
|
|
||||||
if (normalized === openId) return true;
|
|
||||||
if (userId && normalized === userId) return true;
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveGroupEntry(account: ResolvedFeishuAccount, chatId: string) {
|
|
||||||
const groups = account.config.groups ?? {};
|
|
||||||
const keys = Object.keys(groups).filter(Boolean);
|
|
||||||
if (keys.length === 0) return { entry: undefined, allowlistConfigured: false };
|
|
||||||
const entry = groups[chatId] ?? groups["*"];
|
|
||||||
return { entry, allowlistConfigured: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractTextFromMessageContent(content: string): string {
|
|
||||||
const trimmed = content.trim();
|
|
||||||
if (!trimmed) return "";
|
|
||||||
const parsed = parseJson(trimmed);
|
|
||||||
if (parsed.ok && isRecord(parsed.value)) {
|
|
||||||
const text = readString(parsed.value.text);
|
|
||||||
if (text) return text;
|
|
||||||
}
|
|
||||||
return trimmed;
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractMentions(value: unknown): Array<Record<string, unknown>> {
|
|
||||||
if (!Array.isArray(value)) return [];
|
|
||||||
return value.filter(isRecord);
|
|
||||||
}
|
|
||||||
|
|
||||||
function wasBotMentioned(params: {
|
|
||||||
mentions: Array<Record<string, unknown>>;
|
|
||||||
bot: { openId?: string; userId?: string };
|
|
||||||
}): boolean {
|
|
||||||
const botOpenId = params.bot.openId?.trim();
|
|
||||||
const botUserId = params.bot.userId?.trim();
|
|
||||||
if (!botOpenId && !botUserId) return false;
|
|
||||||
return params.mentions.some((mention) => {
|
|
||||||
const id = isRecord(mention.id) ? mention.id : null;
|
|
||||||
const openId = readString((id ?? mention).open_id) ?? readString((id ?? mention).openId);
|
|
||||||
const userId = readString((id ?? mention).user_id) ?? readString((id ?? mention).userId);
|
|
||||||
if (botOpenId && openId && botOpenId === openId) return true;
|
|
||||||
if (botUserId && userId && botUserId === userId) return true;
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deliverFeishuReply(params: {
|
|
||||||
payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string; replyToId?: string };
|
|
||||||
account: ResolvedFeishuAccount;
|
|
||||||
target: FeishuMessagingTarget;
|
|
||||||
runtime: FeishuRuntimeEnv;
|
|
||||||
core: FeishuCoreRuntime;
|
|
||||||
cfg: ClawdbotConfig;
|
|
||||||
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
|
||||||
}) {
|
|
||||||
const { payload, account, target, runtime, core, cfg, statusSink } = params;
|
|
||||||
const mediaList = payload.mediaUrls?.length
|
|
||||||
? payload.mediaUrls
|
|
||||||
: payload.mediaUrl
|
|
||||||
? [payload.mediaUrl]
|
|
||||||
: [];
|
|
||||||
const replyToMessageId = payload.replyToId?.trim() || undefined;
|
|
||||||
|
|
||||||
let text = payload.text ?? "";
|
|
||||||
if (mediaList.length > 0) {
|
|
||||||
const suffix = mediaList.join("\n");
|
|
||||||
text = text.trim() ? `${text.trim()}\n${suffix}` : suffix;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!text.trim()) return;
|
|
||||||
|
|
||||||
const chunkLimit = account.config.textChunkLimit ?? 4000;
|
|
||||||
const chunkMode = core.channel.text.resolveChunkMode(cfg, "feishu", account.accountId);
|
|
||||||
const chunks = core.channel.text.chunkMarkdownTextWithMode(text, chunkLimit, chunkMode);
|
|
||||||
for (const chunk of chunks) {
|
|
||||||
try {
|
|
||||||
await sendFeishuTextMessage({
|
|
||||||
account,
|
|
||||||
target,
|
|
||||||
text: chunk,
|
|
||||||
replyToMessageId,
|
|
||||||
});
|
|
||||||
statusSink?.({ lastOutboundAt: Date.now() });
|
|
||||||
} catch (err) {
|
|
||||||
runtime.error?.(`[${account.accountId}] Feishu send failed: ${String(err)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function processFeishuMessage(params: {
|
|
||||||
account: ResolvedFeishuAccount;
|
|
||||||
config: ClawdbotConfig;
|
|
||||||
runtime: FeishuRuntimeEnv;
|
|
||||||
core: FeishuCoreRuntime;
|
|
||||||
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
|
||||||
messageId: string;
|
|
||||||
chatId: string;
|
|
||||||
chatType: string;
|
|
||||||
messageType: string;
|
|
||||||
content: string;
|
|
||||||
mentions: Array<Record<string, unknown>>;
|
|
||||||
senderOpenId: string;
|
|
||||||
senderUserId?: string;
|
|
||||||
senderType?: string;
|
|
||||||
createdAt?: number;
|
|
||||||
}) {
|
|
||||||
const {
|
|
||||||
account,
|
|
||||||
config,
|
|
||||||
runtime,
|
|
||||||
core,
|
|
||||||
statusSink,
|
|
||||||
messageId,
|
|
||||||
chatId,
|
|
||||||
chatType,
|
|
||||||
messageType,
|
|
||||||
content,
|
|
||||||
mentions,
|
|
||||||
senderOpenId,
|
|
||||||
senderUserId,
|
|
||||||
senderType,
|
|
||||||
createdAt,
|
|
||||||
} = params;
|
|
||||||
|
|
||||||
const isGroup = chatType.toLowerCase() !== "p2p";
|
|
||||||
|
|
||||||
if (senderType && senderType.toLowerCase() !== "user") {
|
|
||||||
logVerbose(core, runtime, `skip non-user sender (type=${senderType})`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (messageType.toLowerCase() !== "text") {
|
|
||||||
logVerbose(core, runtime, `skip non-text message (type=${messageType})`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rawBody = extractTextFromMessageContent(content).trim();
|
|
||||||
if (!rawBody) return;
|
|
||||||
|
|
||||||
const defaultGroupPolicy = config.channels?.defaults?.groupPolicy;
|
|
||||||
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
|
|
||||||
const groupResolved = isGroup ? resolveGroupEntry(account, chatId) : null;
|
|
||||||
const groupEntry = groupResolved?.entry;
|
|
||||||
|
|
||||||
const groupUsers = isGroup
|
|
||||||
? (groupEntry?.users ?? account.config.groupAllowFrom ?? []).map((v) => String(v))
|
|
||||||
: [];
|
|
||||||
|
|
||||||
if (isGroup) {
|
|
||||||
if (groupPolicy === "disabled") {
|
|
||||||
logVerbose(core, runtime, `drop group message (groupPolicy=disabled, chat=${chatId})`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const allowlistConfigured = groupResolved?.allowlistConfigured ?? false;
|
|
||||||
const allowlisted = Boolean(groupEntry) || Boolean((account.config.groups ?? {})["*"]);
|
|
||||||
if (groupPolicy === "allowlist") {
|
|
||||||
if (!allowlistConfigured) {
|
|
||||||
logVerbose(
|
|
||||||
core,
|
|
||||||
runtime,
|
|
||||||
`drop group message (groupPolicy=allowlist, no allowlist, chat=${chatId})`,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!allowlisted) {
|
|
||||||
logVerbose(core, runtime, `drop group message (not allowlisted, chat=${chatId})`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (groupEntry?.enabled === false || groupEntry?.allow === false) {
|
|
||||||
logVerbose(core, runtime, `drop group message (group disabled, chat=${chatId})`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (groupUsers.length > 0) {
|
|
||||||
const ok = isSenderAllowed(senderOpenId, senderUserId, groupUsers);
|
|
||||||
if (!ok) {
|
|
||||||
logVerbose(core, runtime, `drop group message (sender not allowed, user=${senderOpenId})`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const dmPolicy = account.config.dm?.policy ?? "pairing";
|
|
||||||
const configAllowFrom = (account.config.dm?.allowFrom ?? []).map((v) => String(v));
|
|
||||||
const shouldComputeAuth = core.channel.commands.shouldComputeCommandAuthorized(rawBody, config);
|
|
||||||
const storeAllowFrom =
|
|
||||||
!isGroup && (dmPolicy !== "open" || shouldComputeAuth)
|
|
||||||
? await core.channel.pairing.readAllowFromStore("feishu").catch(() => [])
|
|
||||||
: [];
|
|
||||||
const effectiveAllowFrom = [...configAllowFrom, ...storeAllowFrom];
|
|
||||||
const commandAllowFrom = isGroup ? groupUsers : effectiveAllowFrom;
|
|
||||||
const useAccessGroups = config.commands?.useAccessGroups !== false;
|
|
||||||
const senderAllowedForCommands = isSenderAllowed(senderOpenId, senderUserId, commandAllowFrom);
|
|
||||||
const commandAuthorized = shouldComputeAuth
|
|
||||||
? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
|
|
||||||
useAccessGroups,
|
|
||||||
authorizers: [
|
|
||||||
{ configured: commandAllowFrom.length > 0, allowed: senderAllowedForCommands },
|
|
||||||
],
|
|
||||||
})
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
if (isGroup) {
|
|
||||||
const requireMention = groupEntry?.requireMention ?? account.config.requireMention ?? true;
|
|
||||||
const allowTextCommands = core.channel.commands.shouldHandleTextCommands({
|
|
||||||
cfg: config,
|
|
||||||
surface: "feishu",
|
|
||||||
});
|
|
||||||
let botMentioned = false;
|
|
||||||
if (requireMention) {
|
|
||||||
try {
|
|
||||||
const bot = await getBotIdentity(account).catch(() => null);
|
|
||||||
if (bot?.openId || bot?.userId) {
|
|
||||||
botMentioned = wasBotMentioned({
|
|
||||||
mentions,
|
|
||||||
bot: { openId: bot.openId, userId: bot.userId },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
logVerbose(core, runtime, `bot identity lookup failed: ${String(err)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const mentionGate = resolveMentionGatingWithBypass({
|
|
||||||
isGroup: true,
|
|
||||||
requireMention,
|
|
||||||
canDetectMention: true,
|
|
||||||
wasMentioned: botMentioned,
|
|
||||||
implicitMention: false,
|
|
||||||
hasAnyMention: mentions.length > 0,
|
|
||||||
allowTextCommands,
|
|
||||||
hasControlCommand: core.channel.text.hasControlCommand(rawBody, config),
|
|
||||||
commandAuthorized: commandAuthorized === true,
|
|
||||||
});
|
|
||||||
if (mentionGate.shouldSkip) {
|
|
||||||
logVerbose(core, runtime, `drop group message (mention required, chat=${chatId})`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isGroup) {
|
|
||||||
if (dmPolicy === "disabled" || account.config.dm?.enabled === false) {
|
|
||||||
logVerbose(core, runtime, `blocked Feishu DM from ${senderOpenId} (dmPolicy=disabled)`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dmPolicy !== "open") {
|
|
||||||
if (!senderAllowedForCommands) {
|
|
||||||
if (dmPolicy === "pairing") {
|
|
||||||
const { code, created } = await core.channel.pairing.upsertPairingRequest({
|
|
||||||
channel: "feishu",
|
|
||||||
id: senderOpenId,
|
|
||||||
meta: { userId: senderUserId },
|
|
||||||
});
|
|
||||||
if (created) {
|
|
||||||
logVerbose(core, runtime, `feishu pairing request sender=${senderOpenId}`);
|
|
||||||
try {
|
|
||||||
await sendFeishuTextMessage({
|
|
||||||
account,
|
|
||||||
target: { kind: "user", openId: senderOpenId },
|
|
||||||
text: core.channel.pairing.buildPairingReply({
|
|
||||||
channel: "feishu",
|
|
||||||
idLine: `Your Feishu open id: ${senderOpenId}`,
|
|
||||||
code,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
statusSink?.({ lastOutboundAt: Date.now() });
|
|
||||||
} catch (err) {
|
|
||||||
logVerbose(core, runtime, `pairing reply failed for ${senderOpenId}: ${String(err)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
logVerbose(
|
|
||||||
core,
|
|
||||||
runtime,
|
|
||||||
`blocked unauthorized Feishu sender ${senderOpenId} (dmPolicy=${dmPolicy})`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
isGroup &&
|
|
||||||
core.channel.commands.isControlCommandMessage(rawBody, config) &&
|
|
||||||
commandAuthorized !== true
|
|
||||||
) {
|
|
||||||
logVerbose(core, runtime, `feishu: drop control command from ${senderOpenId}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const peerId = isGroup ? chatId : senderOpenId;
|
|
||||||
const route = core.channel.routing.resolveAgentRoute({
|
|
||||||
cfg: config,
|
|
||||||
channel: "feishu",
|
|
||||||
accountId: account.accountId,
|
|
||||||
peer: { kind: isGroup ? "group" : "dm", id: peerId },
|
|
||||||
});
|
|
||||||
|
|
||||||
const fromLabel = isGroup ? `chat:${chatId}` : `user:${senderOpenId}`;
|
|
||||||
const storePath = core.channel.session.resolveStorePath(config.session?.store, {
|
|
||||||
agentId: route.agentId,
|
|
||||||
});
|
|
||||||
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config);
|
|
||||||
const previousTimestamp = core.channel.session.readSessionUpdatedAt({
|
|
||||||
storePath,
|
|
||||||
sessionKey: route.sessionKey,
|
|
||||||
});
|
|
||||||
const body = core.channel.reply.formatAgentEnvelope({
|
|
||||||
channel: "Feishu",
|
|
||||||
from: fromLabel,
|
|
||||||
timestamp: createdAt,
|
|
||||||
previousTimestamp,
|
|
||||||
envelope: envelopeOptions,
|
|
||||||
body: rawBody,
|
|
||||||
});
|
|
||||||
|
|
||||||
const groupSystemPrompt = isGroup ? groupEntry?.systemPrompt?.trim() || undefined : undefined;
|
|
||||||
|
|
||||||
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
|
||||||
Body: body,
|
|
||||||
RawBody: rawBody,
|
|
||||||
CommandBody: rawBody,
|
|
||||||
From: `feishu:${senderOpenId}`,
|
|
||||||
To: isGroup ? `feishu:${chatId}` : `feishu:${senderOpenId}`,
|
|
||||||
SessionKey: route.sessionKey,
|
|
||||||
AccountId: route.accountId,
|
|
||||||
ChatType: isGroup ? "channel" : "direct",
|
|
||||||
ConversationLabel: fromLabel,
|
|
||||||
SenderId: senderOpenId,
|
|
||||||
SenderUsername: senderUserId,
|
|
||||||
CommandAuthorized: commandAuthorized,
|
|
||||||
Provider: "feishu",
|
|
||||||
Surface: "feishu",
|
|
||||||
MessageSid: messageId,
|
|
||||||
MessageSidFull: messageId,
|
|
||||||
ReplyToId: messageId,
|
|
||||||
ReplyToIdFull: messageId,
|
|
||||||
GroupSpace: isGroup ? chatId : undefined,
|
|
||||||
GroupSystemPrompt: groupSystemPrompt,
|
|
||||||
OriginatingChannel: "feishu",
|
|
||||||
OriginatingTo: isGroup ? `feishu:${chatId}` : `feishu:${senderOpenId}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
void core.channel.session
|
|
||||||
.recordSessionMetaFromInbound({
|
|
||||||
storePath,
|
|
||||||
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
|
||||||
ctx: ctxPayload,
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
runtime.error?.(`feishu: failed updating session meta: ${String(err)}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
const target: FeishuMessagingTarget = isGroup
|
|
||||||
? { kind: "chat", chatId }
|
|
||||||
: { kind: "user", openId: senderOpenId };
|
|
||||||
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
||||||
ctx: ctxPayload,
|
|
||||||
cfg: config,
|
|
||||||
dispatcherOptions: {
|
|
||||||
deliver: async (payload) => {
|
|
||||||
await deliverFeishuReply({
|
|
||||||
payload,
|
|
||||||
account,
|
|
||||||
target,
|
|
||||||
runtime,
|
|
||||||
core,
|
|
||||||
cfg: config,
|
|
||||||
statusSink,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onError: (err, info) => {
|
|
||||||
runtime.error?.(`[${account.accountId}] Feishu ${info.kind} reply failed: ${String(err)}`);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function handleFeishuWebhookRequest(
|
export async function handleFeishuWebhookRequest(
|
||||||
req: IncomingMessage,
|
req: IncomingMessage,
|
||||||
res: ServerResponse,
|
res: ServerResponse,
|
||||||
@ -771,12 +347,33 @@ export async function handleFeishuWebhookRequest(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function startFeishuMonitor(options: FeishuMonitorOptions): Promise<() => void> {
|
function waitForAbort(signal: AbortSignal): Promise<void> {
|
||||||
|
if (signal.aborted) return Promise.resolve();
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
signal.addEventListener("abort", () => resolve(), { once: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startFeishuMonitor(options: FeishuMonitorOptions): Promise<void> {
|
||||||
const core = getFeishuRuntime();
|
const core = getFeishuRuntime();
|
||||||
|
const mode = options.account.config.mode ?? "http";
|
||||||
|
|
||||||
|
if (mode === "ws") {
|
||||||
|
await monitorFeishuLongConnection({
|
||||||
|
account: options.account,
|
||||||
|
config: options.config,
|
||||||
|
runtime: options.runtime,
|
||||||
|
abortSignal: options.abortSignal,
|
||||||
|
statusSink: options.statusSink,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const webhookPath = resolveWebhookPath(options.webhookPath, options.webhookUrl);
|
const webhookPath = resolveWebhookPath(options.webhookPath, options.webhookUrl);
|
||||||
if (!webhookPath) {
|
if (!webhookPath) {
|
||||||
options.runtime.error?.(`[${options.account.accountId}] invalid webhook path`);
|
options.runtime.error?.(`[${options.account.accountId}] invalid webhook path`);
|
||||||
return () => {};
|
await waitForAbort(options.abortSignal);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const unregister = registerFeishuWebhookTarget({
|
const unregister = registerFeishuWebhookTarget({
|
||||||
@ -788,7 +385,11 @@ export async function startFeishuMonitor(options: FeishuMonitorOptions): Promise
|
|||||||
statusSink: options.statusSink,
|
statusSink: options.statusSink,
|
||||||
});
|
});
|
||||||
|
|
||||||
return unregister;
|
try {
|
||||||
|
await waitForAbort(options.abortSignal);
|
||||||
|
} finally {
|
||||||
|
unregister();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveFeishuWebhookPath(params: { account: ResolvedFeishuAccount }): string {
|
export function resolveFeishuWebhookPath(params: { account: ResolvedFeishuAccount }): string {
|
||||||
|
|||||||
@ -43,9 +43,11 @@ async function noteFeishuAppHelp(prompter: WizardPrompter): Promise<void> {
|
|||||||
await prompter.note(
|
await prompter.note(
|
||||||
[
|
[
|
||||||
"1) Create a Feishu app and enable the bot capability",
|
"1) Create a Feishu app and enable the bot capability",
|
||||||
"2) Configure event subscription callback: https://gateway.example.com/feishu",
|
"2) Subscribe to the im.message.receive_v1 event",
|
||||||
"3) Subscribe to the im.message.receive_v1 event",
|
"3) Choose one delivery mode:",
|
||||||
"4) Copy appId/appSecret + verification token (or encrypt key) into Clawdbot",
|
" - HTTP callback: configure request URL (e.g. https://gateway.example.com/feishu) and copy the verification token (or encrypt key)",
|
||||||
|
" - Long connection: enable long connection mode (no public URL required)",
|
||||||
|
"4) Copy appId/appSecret into Clawdbot",
|
||||||
"Docs: https://docs.clawd.bot/channels/feishu",
|
"Docs: https://docs.clawd.bot/channels/feishu",
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
"Feishu bot app",
|
"Feishu bot app",
|
||||||
@ -212,32 +214,43 @@ export const feishuOnboardingAdapter: ChannelOnboardingAdapter = {
|
|||||||
}),
|
}),
|
||||||
).trim();
|
).trim();
|
||||||
|
|
||||||
const validationMode = await prompter.select({
|
const mode = await prompter.select({
|
||||||
message: "Webhook validation",
|
message: "Receive events via",
|
||||||
options: [
|
options: [
|
||||||
{ value: "token", label: "Verification token", hint: "Simple shared secret check" },
|
{ value: "http", label: "HTTP callback", hint: "Public webhook URL required" },
|
||||||
{ value: "encrypt", label: "Encrypt key", hint: "Signature + encrypted payload" },
|
{ value: "ws", label: "Long connection", hint: "WebSocket; no public URL required" },
|
||||||
],
|
],
|
||||||
initialValue: "token",
|
initialValue: resolved.config.mode ?? "http",
|
||||||
});
|
});
|
||||||
|
|
||||||
const patch: Record<string, unknown> = { appId, appSecret };
|
const patch: Record<string, unknown> = { appId, appSecret, mode };
|
||||||
if (validationMode === "encrypt") {
|
if (mode === "http") {
|
||||||
const encryptKey = String(
|
const validationMode = await prompter.select({
|
||||||
await prompter.text({
|
message: "Webhook validation",
|
||||||
message: "Feishu encrypt key",
|
options: [
|
||||||
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
|
{ value: "token", label: "Verification token", hint: "Simple shared secret check" },
|
||||||
}),
|
{ value: "encrypt", label: "Encrypt key", hint: "Signature + encrypted payload" },
|
||||||
).trim();
|
],
|
||||||
patch.encryptKey = encryptKey;
|
initialValue: "token",
|
||||||
} else {
|
});
|
||||||
const verificationToken = String(
|
|
||||||
await prompter.text({
|
if (validationMode === "encrypt") {
|
||||||
message: "Feishu verification token",
|
const encryptKey = String(
|
||||||
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
|
await prompter.text({
|
||||||
}),
|
message: "Feishu encrypt key",
|
||||||
).trim();
|
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
|
||||||
patch.verificationToken = verificationToken;
|
}),
|
||||||
|
).trim();
|
||||||
|
patch.encryptKey = encryptKey;
|
||||||
|
} else {
|
||||||
|
const verificationToken = String(
|
||||||
|
await prompter.text({
|
||||||
|
message: "Feishu verification token",
|
||||||
|
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
|
||||||
|
}),
|
||||||
|
).trim();
|
||||||
|
patch.verificationToken = verificationToken;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const wantsPath = await prompter.confirm({
|
const wantsPath = await prompter.confirm({
|
||||||
|
|||||||
@ -21,6 +21,7 @@ export type FeishuAccountConfig = {
|
|||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
appId?: string;
|
appId?: string;
|
||||||
appSecret?: string;
|
appSecret?: string;
|
||||||
|
mode?: "http" | "ws";
|
||||||
verificationToken?: string;
|
verificationToken?: string;
|
||||||
encryptKey?: string;
|
encryptKey?: string;
|
||||||
webhookPath?: string;
|
webhookPath?: string;
|
||||||
|
|||||||
53
extensions/feishu/src/ws.frame.test.ts
Normal file
53
extensions/feishu/src/ws.frame.test.ts
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { decodeFeishuLongConnectionFrame, encodeFeishuLongConnectionFrame } from "./ws.js";
|
||||||
|
|
||||||
|
describe("Feishu long connection frame codec", () => {
|
||||||
|
it("roundtrips a ping frame", () => {
|
||||||
|
const frame = {
|
||||||
|
SeqID: 0n,
|
||||||
|
LogID: 0n,
|
||||||
|
service: 123,
|
||||||
|
method: 0,
|
||||||
|
headers: [{ key: "type", value: "ping" }],
|
||||||
|
};
|
||||||
|
const encoded = encodeFeishuLongConnectionFrame(frame);
|
||||||
|
expect(Array.from(encoded.slice(0, 8))).toEqual([8, 0, 16, 0, 24, 123, 32, 0]);
|
||||||
|
const decoded = decodeFeishuLongConnectionFrame(encoded);
|
||||||
|
expect(decoded.SeqID).toBe(frame.SeqID);
|
||||||
|
expect(decoded.LogID).toBe(frame.LogID);
|
||||||
|
expect(decoded.service).toBe(frame.service);
|
||||||
|
expect(decoded.method).toBe(frame.method);
|
||||||
|
expect(decoded.headers).toEqual(frame.headers);
|
||||||
|
expect(decoded.payload).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("roundtrips frames with payload and metadata", () => {
|
||||||
|
const payload = new TextEncoder().encode(JSON.stringify({ code: 200 }));
|
||||||
|
const frame = {
|
||||||
|
SeqID: 12n,
|
||||||
|
LogID: 34n,
|
||||||
|
service: 9,
|
||||||
|
method: 1,
|
||||||
|
headers: [
|
||||||
|
{ key: "type", value: "event" },
|
||||||
|
{ key: "message_id", value: "m_1" },
|
||||||
|
],
|
||||||
|
payloadEncoding: "raw",
|
||||||
|
payloadType: "json",
|
||||||
|
LogIDNew: "log_1",
|
||||||
|
payload,
|
||||||
|
};
|
||||||
|
const encoded = encodeFeishuLongConnectionFrame(frame);
|
||||||
|
const decoded = decodeFeishuLongConnectionFrame(encoded);
|
||||||
|
expect(decoded.SeqID).toBe(frame.SeqID);
|
||||||
|
expect(decoded.LogID).toBe(frame.LogID);
|
||||||
|
expect(decoded.service).toBe(frame.service);
|
||||||
|
expect(decoded.method).toBe(frame.method);
|
||||||
|
expect(decoded.headers).toEqual(frame.headers);
|
||||||
|
expect(decoded.payloadEncoding).toBe("raw");
|
||||||
|
expect(decoded.payloadType).toBe("json");
|
||||||
|
expect(decoded.LogIDNew).toBe("log_1");
|
||||||
|
expect(decoded.payload).toEqual(payload);
|
||||||
|
});
|
||||||
|
});
|
||||||
824
extensions/feishu/src/ws.ts
Normal file
824
extensions/feishu/src/ws.ts
Normal file
@ -0,0 +1,824 @@
|
|||||||
|
import type { ClawdbotConfig, PluginRuntime } from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
|
import type { ResolvedFeishuAccount } from "./accounts.js";
|
||||||
|
import { decryptFeishuEncrypt } from "./auth.js";
|
||||||
|
import { getFeishuRuntime } from "./runtime.js";
|
||||||
|
import {
|
||||||
|
extractMentions,
|
||||||
|
parseFeishuTimestampMs,
|
||||||
|
processFeishuMessage,
|
||||||
|
type FeishuRuntimeEnv,
|
||||||
|
type FeishuStatusSink,
|
||||||
|
} from "./inbound.js";
|
||||||
|
|
||||||
|
type FeishuLongConnectionOptions = {
|
||||||
|
account: ResolvedFeishuAccount;
|
||||||
|
config: ClawdbotConfig;
|
||||||
|
runtime: FeishuRuntimeEnv;
|
||||||
|
abortSignal: AbortSignal;
|
||||||
|
statusSink?: FeishuStatusSink;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FeishuWsConnectConfig = {
|
||||||
|
connectUrl: string;
|
||||||
|
serviceId: number;
|
||||||
|
pingIntervalMs: number;
|
||||||
|
reconnectCount: number;
|
||||||
|
reconnectIntervalMs: number;
|
||||||
|
reconnectNonceMs: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FrameHeader = { key: string; value: string };
|
||||||
|
|
||||||
|
type FrameMessage = {
|
||||||
|
SeqID: bigint;
|
||||||
|
LogID: bigint;
|
||||||
|
service: number;
|
||||||
|
method: number;
|
||||||
|
headers: FrameHeader[];
|
||||||
|
payloadEncoding?: string;
|
||||||
|
payloadType?: string;
|
||||||
|
payload?: Uint8Array;
|
||||||
|
LogIDNew?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FEISHU_WS_CONFIG_URL = "https://open.feishu.cn/callback/ws/endpoint";
|
||||||
|
|
||||||
|
const FRAME_TYPE_CONTROL = 0;
|
||||||
|
const FRAME_TYPE_DATA = 1;
|
||||||
|
|
||||||
|
const MESSAGE_TYPE_EVENT = "event";
|
||||||
|
const MESSAGE_TYPE_PING = "ping";
|
||||||
|
const MESSAGE_TYPE_PONG = "pong";
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function readString(value: unknown): string | undefined {
|
||||||
|
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJson(raw: string): { ok: true; value: unknown } | { ok: false; error: string } {
|
||||||
|
try {
|
||||||
|
return { ok: true, value: JSON.parse(raw) as unknown };
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sleepMs(ms: number, signal: AbortSignal): Promise<void> {
|
||||||
|
const delay = Math.max(0, ms);
|
||||||
|
if (delay === 0 || signal.aborted) return;
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
const id = setTimeout(() => {
|
||||||
|
signal.removeEventListener("abort", onAbort);
|
||||||
|
resolve();
|
||||||
|
}, delay);
|
||||||
|
const onAbort = () => {
|
||||||
|
clearTimeout(id);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
signal.addEventListener("abort", onAbort, { once: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchConnectConfig(
|
||||||
|
options: FeishuLongConnectionOptions,
|
||||||
|
): Promise<FeishuWsConnectConfig> {
|
||||||
|
const appId = options.account.config.appId?.trim() ?? "";
|
||||||
|
const appSecret = options.account.config.appSecret?.trim() ?? "";
|
||||||
|
if (!appId || !appSecret) {
|
||||||
|
throw new Error("Feishu long connection requires appId/appSecret");
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const onAbort = () => controller.abort();
|
||||||
|
options.abortSignal.addEventListener("abort", onAbort, { once: true });
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 15_000);
|
||||||
|
try {
|
||||||
|
const res = await fetch(FEISHU_WS_CONFIG_URL, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
locale: "zh",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ AppID: appId, AppSecret: appSecret }),
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
const text = await res.text();
|
||||||
|
const payload = text.trim() ? parseJson(text) : { ok: true as const, value: null };
|
||||||
|
if (!payload.ok) {
|
||||||
|
throw new Error(`Feishu ws config returned invalid JSON: ${payload.error}`);
|
||||||
|
}
|
||||||
|
if (!isRecord(payload.value)) {
|
||||||
|
throw new Error(`Feishu ws config returned invalid payload (status=${res.status})`);
|
||||||
|
}
|
||||||
|
const code = typeof payload.value.code === "number" ? payload.value.code : -1;
|
||||||
|
const msg = readString(payload.value.msg) ?? "request failed";
|
||||||
|
if (!res.ok || code !== 0) {
|
||||||
|
throw new Error(`Feishu ws config failed: code=${code} msg=${msg}`);
|
||||||
|
}
|
||||||
|
const data = isRecord(payload.value.data) ? payload.value.data : null;
|
||||||
|
const connectUrl = data ? readString(data.URL) : undefined;
|
||||||
|
const clientConfig = data && isRecord(data.ClientConfig) ? data.ClientConfig : null;
|
||||||
|
if (!connectUrl || !clientConfig) {
|
||||||
|
throw new Error("Feishu ws config missing URL/ClientConfig");
|
||||||
|
}
|
||||||
|
|
||||||
|
let serviceId = 0;
|
||||||
|
try {
|
||||||
|
const url = new URL(connectUrl);
|
||||||
|
const raw = url.searchParams.get("service_id")?.trim() ?? "";
|
||||||
|
serviceId = Number.parseInt(raw, 10);
|
||||||
|
} catch {
|
||||||
|
serviceId = 0;
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(serviceId) || serviceId <= 0) {
|
||||||
|
throw new Error("Feishu ws config missing service_id");
|
||||||
|
}
|
||||||
|
|
||||||
|
const pingIntervalSec =
|
||||||
|
typeof clientConfig.PingInterval === "number" ? clientConfig.PingInterval : 0;
|
||||||
|
const reconnectCount =
|
||||||
|
typeof clientConfig.ReconnectCount === "number" ? clientConfig.ReconnectCount : -1;
|
||||||
|
const reconnectIntervalSec =
|
||||||
|
typeof clientConfig.ReconnectInterval === "number" ? clientConfig.ReconnectInterval : 0;
|
||||||
|
const reconnectNonceSec =
|
||||||
|
typeof clientConfig.ReconnectNonce === "number" ? clientConfig.ReconnectNonce : 0;
|
||||||
|
const pingIntervalMs = Math.max(5_000, Math.floor(pingIntervalSec * 1000));
|
||||||
|
const reconnectIntervalMs = Math.max(1_000, Math.floor(reconnectIntervalSec * 1000));
|
||||||
|
const reconnectNonceMs = Math.max(0, Math.floor(reconnectNonceSec * 1000));
|
||||||
|
|
||||||
|
return {
|
||||||
|
connectUrl,
|
||||||
|
serviceId,
|
||||||
|
pingIntervalMs,
|
||||||
|
reconnectCount,
|
||||||
|
reconnectIntervalMs,
|
||||||
|
reconnectNonceMs,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
options.abortSignal.removeEventListener("abort", onAbort);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connectWebSocket(params: {
|
||||||
|
url: string;
|
||||||
|
abortSignal: AbortSignal;
|
||||||
|
}): Promise<WebSocket> {
|
||||||
|
if (params.abortSignal.aborted) {
|
||||||
|
throw new Error("aborted");
|
||||||
|
}
|
||||||
|
const ws = new WebSocket(params.url);
|
||||||
|
ws.binaryType = "arraybuffer";
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const onAbort = () => {
|
||||||
|
cleanup();
|
||||||
|
try {
|
||||||
|
ws.close();
|
||||||
|
} finally {
|
||||||
|
reject(new Error("aborted"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onOpen = () => {
|
||||||
|
cleanup();
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
const onError = () => {
|
||||||
|
cleanup();
|
||||||
|
reject(new Error("websocket error"));
|
||||||
|
};
|
||||||
|
const cleanup = () => {
|
||||||
|
ws.removeEventListener("open", onOpen);
|
||||||
|
ws.removeEventListener("error", onError);
|
||||||
|
params.abortSignal.removeEventListener("abort", onAbort);
|
||||||
|
};
|
||||||
|
ws.addEventListener("open", onOpen);
|
||||||
|
ws.addEventListener("error", onError);
|
||||||
|
params.abortSignal.addEventListener("abort", onAbort, { once: true });
|
||||||
|
});
|
||||||
|
return ws;
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeVarint(value: bigint): number[] {
|
||||||
|
const bytes: number[] = [];
|
||||||
|
let remaining = value;
|
||||||
|
while (remaining >= 0x80n) {
|
||||||
|
bytes.push(Number(remaining & 0x7fn) | 0x80);
|
||||||
|
remaining >>= 7n;
|
||||||
|
}
|
||||||
|
bytes.push(Number(remaining));
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeVarint(data: Uint8Array, offset: number): { value: bigint; offset: number } {
|
||||||
|
let value = 0n;
|
||||||
|
let shift = 0n;
|
||||||
|
let pos = offset;
|
||||||
|
while (pos < data.length) {
|
||||||
|
const byte = data[pos++];
|
||||||
|
value |= BigInt(byte & 0x7f) << shift;
|
||||||
|
if ((byte & 0x80) === 0) {
|
||||||
|
return { value, offset: pos };
|
||||||
|
}
|
||||||
|
shift += 7n;
|
||||||
|
if (shift > 70n) {
|
||||||
|
throw new Error("varint overflow");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error("unexpected EOF");
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeStringField(tag: number, value: string): number[] {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const bytes = encoder.encode(value);
|
||||||
|
return [tag, ...encodeVarint(BigInt(bytes.length)), ...Array.from(bytes)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeBytesField(tag: number, value: Uint8Array): number[] {
|
||||||
|
return [tag, ...encodeVarint(BigInt(value.length)), ...Array.from(value)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeString(data: Uint8Array): string {
|
||||||
|
return new TextDecoder().decode(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeLengthDelimited(
|
||||||
|
data: Uint8Array,
|
||||||
|
offset: number,
|
||||||
|
): { bytes: Uint8Array; offset: number } {
|
||||||
|
const len = decodeVarint(data, offset);
|
||||||
|
const length = Number(len.value);
|
||||||
|
if (!Number.isFinite(length) || length < 0) {
|
||||||
|
throw new Error("invalid length-delimited field");
|
||||||
|
}
|
||||||
|
const start = len.offset;
|
||||||
|
const end = start + length;
|
||||||
|
if (end > data.length) {
|
||||||
|
throw new Error("unexpected EOF");
|
||||||
|
}
|
||||||
|
return { bytes: data.subarray(start, end), offset: end };
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeHeader(header: FrameHeader): number[] {
|
||||||
|
const keyField = encodeStringField(10, header.key);
|
||||||
|
const valueField = encodeStringField(18, header.value);
|
||||||
|
const body = [...keyField, ...valueField];
|
||||||
|
return [42, ...encodeVarint(BigInt(body.length)), ...body];
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeHeader(data: Uint8Array): FrameHeader {
|
||||||
|
let offset = 0;
|
||||||
|
let key = "";
|
||||||
|
let value = "";
|
||||||
|
while (offset < data.length) {
|
||||||
|
const tag = decodeVarint(data, offset);
|
||||||
|
offset = tag.offset;
|
||||||
|
const fieldNumber = Number(tag.value >> 3n);
|
||||||
|
const wireType = Number(tag.value & 7n);
|
||||||
|
if (wireType !== 2) {
|
||||||
|
throw new Error("invalid header encoding");
|
||||||
|
}
|
||||||
|
const field = decodeLengthDelimited(data, offset);
|
||||||
|
offset = field.offset;
|
||||||
|
const decoded = decodeString(field.bytes);
|
||||||
|
if (fieldNumber === 1) key = decoded;
|
||||||
|
if (fieldNumber === 2) value = decoded;
|
||||||
|
}
|
||||||
|
return { key, value };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encodeFeishuLongConnectionFrame(frame: FrameMessage): Uint8Array {
|
||||||
|
const out: number[] = [];
|
||||||
|
out.push(8, ...encodeVarint(frame.SeqID));
|
||||||
|
out.push(16, ...encodeVarint(frame.LogID));
|
||||||
|
out.push(24, ...encodeVarint(BigInt(frame.service)));
|
||||||
|
out.push(32, ...encodeVarint(BigInt(frame.method)));
|
||||||
|
for (const header of frame.headers) {
|
||||||
|
out.push(...encodeHeader(header));
|
||||||
|
}
|
||||||
|
if (frame.payloadEncoding) {
|
||||||
|
out.push(...encodeStringField(50, frame.payloadEncoding));
|
||||||
|
}
|
||||||
|
if (frame.payloadType) {
|
||||||
|
out.push(...encodeStringField(58, frame.payloadType));
|
||||||
|
}
|
||||||
|
if (frame.payload) {
|
||||||
|
out.push(...encodeBytesField(66, frame.payload));
|
||||||
|
}
|
||||||
|
if (frame.LogIDNew) {
|
||||||
|
out.push(...encodeStringField(74, frame.LogIDNew));
|
||||||
|
}
|
||||||
|
return Uint8Array.from(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeFeishuLongConnectionFrame(data: Uint8Array): FrameMessage {
|
||||||
|
let offset = 0;
|
||||||
|
let SeqID: bigint | null = null;
|
||||||
|
let LogID: bigint | null = null;
|
||||||
|
let service: number | null = null;
|
||||||
|
let method: number | null = null;
|
||||||
|
const headers: FrameHeader[] = [];
|
||||||
|
let payloadEncoding: string | undefined;
|
||||||
|
let payloadType: string | undefined;
|
||||||
|
let payload: Uint8Array | undefined;
|
||||||
|
let LogIDNew: string | undefined;
|
||||||
|
|
||||||
|
while (offset < data.length) {
|
||||||
|
const tag = decodeVarint(data, offset);
|
||||||
|
offset = tag.offset;
|
||||||
|
const fieldNumber = Number(tag.value >> 3n);
|
||||||
|
const wireType = Number(tag.value & 7n);
|
||||||
|
|
||||||
|
if (fieldNumber === 1 && wireType === 0) {
|
||||||
|
const v = decodeVarint(data, offset);
|
||||||
|
SeqID = v.value;
|
||||||
|
offset = v.offset;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (fieldNumber === 2 && wireType === 0) {
|
||||||
|
const v = decodeVarint(data, offset);
|
||||||
|
LogID = v.value;
|
||||||
|
offset = v.offset;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (fieldNumber === 3 && wireType === 0) {
|
||||||
|
const v = decodeVarint(data, offset);
|
||||||
|
service = Number(v.value);
|
||||||
|
offset = v.offset;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (fieldNumber === 4 && wireType === 0) {
|
||||||
|
const v = decodeVarint(data, offset);
|
||||||
|
method = Number(v.value);
|
||||||
|
offset = v.offset;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (fieldNumber === 5 && wireType === 2) {
|
||||||
|
const field = decodeLengthDelimited(data, offset);
|
||||||
|
offset = field.offset;
|
||||||
|
headers.push(decodeHeader(field.bytes));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (fieldNumber === 6 && wireType === 2) {
|
||||||
|
const field = decodeLengthDelimited(data, offset);
|
||||||
|
offset = field.offset;
|
||||||
|
payloadEncoding = decodeString(field.bytes);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (fieldNumber === 7 && wireType === 2) {
|
||||||
|
const field = decodeLengthDelimited(data, offset);
|
||||||
|
offset = field.offset;
|
||||||
|
payloadType = decodeString(field.bytes);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (fieldNumber === 8 && wireType === 2) {
|
||||||
|
const field = decodeLengthDelimited(data, offset);
|
||||||
|
offset = field.offset;
|
||||||
|
payload = field.bytes;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (fieldNumber === 9 && wireType === 2) {
|
||||||
|
const field = decodeLengthDelimited(data, offset);
|
||||||
|
offset = field.offset;
|
||||||
|
LogIDNew = decodeString(field.bytes);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wireType === 0) {
|
||||||
|
const v = decodeVarint(data, offset);
|
||||||
|
offset = v.offset;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (wireType === 1) {
|
||||||
|
offset += 8;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (wireType === 2) {
|
||||||
|
const field = decodeLengthDelimited(data, offset);
|
||||||
|
offset = field.offset;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (wireType === 5) {
|
||||||
|
offset += 4;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw new Error(`unsupported wireType=${wireType}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (SeqID == null || LogID == null || service == null || method == null) {
|
||||||
|
throw new Error("invalid frame missing required fields");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
SeqID,
|
||||||
|
LogID,
|
||||||
|
service,
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
payloadEncoding,
|
||||||
|
payloadType,
|
||||||
|
payload,
|
||||||
|
LogIDNew,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveHeaderMap(headers: FrameHeader[]): Record<string, string> {
|
||||||
|
const map: Record<string, string> = {};
|
||||||
|
for (const header of headers) {
|
||||||
|
const key = header.key.trim();
|
||||||
|
if (!key) continue;
|
||||||
|
map[key] = String(header.value ?? "");
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildControlPingFrame(params: { serviceId: number }): FrameMessage {
|
||||||
|
return {
|
||||||
|
SeqID: 0n,
|
||||||
|
LogID: 0n,
|
||||||
|
service: params.serviceId,
|
||||||
|
method: FRAME_TYPE_CONTROL,
|
||||||
|
headers: [{ key: "type", value: MESSAGE_TYPE_PING }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAckFrame(params: { request: FrameMessage; code: number }): FrameMessage {
|
||||||
|
const payload = new TextEncoder().encode(JSON.stringify({ code: params.code }));
|
||||||
|
return {
|
||||||
|
SeqID: params.request.SeqID,
|
||||||
|
LogID: params.request.LogID,
|
||||||
|
service: params.request.service,
|
||||||
|
method: params.request.method,
|
||||||
|
headers: [...params.request.headers, { key: "biz_rt", value: "0" }],
|
||||||
|
payloadEncoding: params.request.payloadEncoding,
|
||||||
|
payloadType: params.request.payloadType,
|
||||||
|
LogIDNew: params.request.LogIDNew,
|
||||||
|
payload,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChunkBuffer = {
|
||||||
|
parts: Array<Uint8Array | undefined>;
|
||||||
|
received: number;
|
||||||
|
createdAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function mergeChunks(buffer: ChunkBuffer): Uint8Array | null {
|
||||||
|
if (!buffer.parts.every(Boolean)) return null;
|
||||||
|
const combinedLength = buffer.parts.reduce((acc, cur) => acc + (cur?.length ?? 0), 0);
|
||||||
|
const combined = new Uint8Array(combinedLength);
|
||||||
|
let offset = 0;
|
||||||
|
for (const part of buffer.parts) {
|
||||||
|
if (!part) continue;
|
||||||
|
combined.set(part, offset);
|
||||||
|
offset += part.length;
|
||||||
|
}
|
||||||
|
return combined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function normalizeWebSocketData(data: unknown): Promise<Uint8Array | null> {
|
||||||
|
if (typeof data === "string") {
|
||||||
|
return new TextEncoder().encode(data);
|
||||||
|
}
|
||||||
|
if (data instanceof ArrayBuffer) {
|
||||||
|
return new Uint8Array(data);
|
||||||
|
}
|
||||||
|
if (ArrayBuffer.isView(data)) {
|
||||||
|
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
||||||
|
}
|
||||||
|
if (typeof Blob !== "undefined" && data instanceof Blob) {
|
||||||
|
const buf = await data.arrayBuffer();
|
||||||
|
return new Uint8Array(buf);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decryptEnvelopeIfNeeded(params: {
|
||||||
|
envelope: Record<string, unknown>;
|
||||||
|
encryptKey?: string;
|
||||||
|
}): Record<string, unknown> {
|
||||||
|
const encrypt = readString(params.envelope.encrypt);
|
||||||
|
if (!encrypt) return params.envelope;
|
||||||
|
const encryptKey = params.encryptKey?.trim();
|
||||||
|
if (!encryptKey) return params.envelope;
|
||||||
|
|
||||||
|
const { encrypt: _ignored, ...rest } = params.envelope;
|
||||||
|
try {
|
||||||
|
const decrypted = decryptFeishuEncrypt({ encrypt, encryptKey });
|
||||||
|
const parsed = parseJson(decrypted);
|
||||||
|
if (!parsed.ok || !isRecord(parsed.value)) return params.envelope;
|
||||||
|
return { ...parsed.value, ...rest };
|
||||||
|
} catch {
|
||||||
|
return params.envelope;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type FeishuExtractedMessage = {
|
||||||
|
messageId: string;
|
||||||
|
chatId: string;
|
||||||
|
chatType: string;
|
||||||
|
messageType: string;
|
||||||
|
content: string;
|
||||||
|
mentions: Array<Record<string, unknown>>;
|
||||||
|
senderOpenId: string;
|
||||||
|
senderUserId?: string;
|
||||||
|
senderType?: string;
|
||||||
|
createdAt?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function extractFeishuMessageFromEnvelope(
|
||||||
|
envelope: Record<string, unknown>,
|
||||||
|
): FeishuExtractedMessage | null {
|
||||||
|
const eventType = readString(isRecord(envelope.header) ? envelope.header.event_type : undefined);
|
||||||
|
if (eventType !== "im.message.receive_v1") return null;
|
||||||
|
const event = isRecord(envelope.event) ? envelope.event : null;
|
||||||
|
const message = event && isRecord(event.message) ? event.message : null;
|
||||||
|
const sender = event && isRecord(event.sender) ? event.sender : null;
|
||||||
|
if (!message || !sender) return null;
|
||||||
|
|
||||||
|
const messageId =
|
||||||
|
readString(message.message_id) ?? readString(message.messageId) ?? readString(message.id) ?? "";
|
||||||
|
const chatId = readString(message.chat_id) ?? readString(message.chatId) ?? "";
|
||||||
|
const chatType = readString(message.chat_type) ?? readString(message.chatType) ?? "";
|
||||||
|
const messageType = readString(message.message_type) ?? readString(message.messageType) ?? "";
|
||||||
|
const content = readString(message.content) ?? "";
|
||||||
|
if (!messageId || !chatId || !chatType || !messageType || !content) return null;
|
||||||
|
|
||||||
|
const senderId = isRecord(sender.sender_id) ? sender.sender_id : null;
|
||||||
|
const senderOpenId =
|
||||||
|
readString((senderId ?? sender).open_id) ?? readString((senderId ?? sender).openId) ?? "";
|
||||||
|
const senderUserId =
|
||||||
|
readString((senderId ?? sender).user_id) ?? readString((senderId ?? sender).userId);
|
||||||
|
const senderType = readString(sender.sender_type) ?? readString(sender.senderType);
|
||||||
|
if (!senderOpenId) return null;
|
||||||
|
|
||||||
|
const mentions = extractMentions(message.mentions);
|
||||||
|
const createdAt = parseFeishuTimestampMs(
|
||||||
|
readString(isRecord(envelope.header) ? envelope.header.create_time : undefined),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
messageId,
|
||||||
|
chatId,
|
||||||
|
chatType,
|
||||||
|
messageType,
|
||||||
|
content,
|
||||||
|
mentions,
|
||||||
|
senderOpenId,
|
||||||
|
senderUserId,
|
||||||
|
senderType,
|
||||||
|
createdAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleEventPayload(params: {
|
||||||
|
core: PluginRuntime;
|
||||||
|
account: ResolvedFeishuAccount;
|
||||||
|
config: ClawdbotConfig;
|
||||||
|
runtime: FeishuRuntimeEnv;
|
||||||
|
statusSink?: FeishuStatusSink;
|
||||||
|
event: unknown;
|
||||||
|
}) {
|
||||||
|
if (!isRecord(params.event)) return;
|
||||||
|
const normalized = decryptEnvelopeIfNeeded({
|
||||||
|
envelope: params.event,
|
||||||
|
encryptKey: params.account.config.encryptKey,
|
||||||
|
});
|
||||||
|
const token =
|
||||||
|
readString(normalized.token) ??
|
||||||
|
readString(isRecord(normalized.header) ? normalized.header.token : undefined);
|
||||||
|
const configuredToken = params.account.config.verificationToken?.trim();
|
||||||
|
if (configuredToken && token && configuredToken !== token) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const extracted = extractFeishuMessageFromEnvelope(normalized);
|
||||||
|
if (!extracted) return;
|
||||||
|
|
||||||
|
params.statusSink?.({ lastInboundAt: Date.now() });
|
||||||
|
void processFeishuMessage({
|
||||||
|
account: params.account,
|
||||||
|
config: params.config,
|
||||||
|
runtime: params.runtime,
|
||||||
|
core: params.core,
|
||||||
|
statusSink: params.statusSink,
|
||||||
|
...extracted,
|
||||||
|
}).catch((err) => {
|
||||||
|
params.runtime.error?.(
|
||||||
|
`[${params.account.accountId}] Feishu long connection handler failed: ${String(err)}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function monitorSingleSession(params: {
|
||||||
|
ws: WebSocket;
|
||||||
|
connectConfig: FeishuWsConnectConfig;
|
||||||
|
options: FeishuLongConnectionOptions;
|
||||||
|
core: PluginRuntime;
|
||||||
|
}): Promise<void> {
|
||||||
|
const { ws, connectConfig, options, core } = params;
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
const chunkCache = new Map<string, ChunkBuffer>();
|
||||||
|
const pingState = { intervalMs: connectConfig.pingIntervalMs };
|
||||||
|
let pingTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
const schedulePing = () => {
|
||||||
|
if (options.abortSignal.aborted) return;
|
||||||
|
if (pingTimer) clearTimeout(pingTimer);
|
||||||
|
pingTimer = setTimeout(() => {
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
try {
|
||||||
|
const ping = buildControlPingFrame({ serviceId: connectConfig.serviceId });
|
||||||
|
ws.send(encodeFeishuLongConnectionFrame(ping));
|
||||||
|
} catch (err) {
|
||||||
|
options.runtime.error?.(
|
||||||
|
`[${options.account.accountId}] Feishu ping failed: ${String(err)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
schedulePing();
|
||||||
|
}, pingState.intervalMs);
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearCache = () => {
|
||||||
|
chunkCache.clear();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleControlFrame = (frame: FrameMessage) => {
|
||||||
|
const headerMap = resolveHeaderMap(frame.headers);
|
||||||
|
const type = headerMap.type?.trim().toLowerCase();
|
||||||
|
if (type === MESSAGE_TYPE_PONG && frame.payload) {
|
||||||
|
const payload = decoder.decode(frame.payload);
|
||||||
|
const parsed = parseJson(payload);
|
||||||
|
if (parsed.ok && isRecord(parsed.value)) {
|
||||||
|
const PingInterval =
|
||||||
|
typeof parsed.value.PingInterval === "number" ? parsed.value.PingInterval : undefined;
|
||||||
|
if (typeof PingInterval === "number" && PingInterval > 0) {
|
||||||
|
pingState.intervalMs = Math.max(5_000, Math.floor(PingInterval * 1000));
|
||||||
|
schedulePing();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDataFrame = async (frame: FrameMessage) => {
|
||||||
|
const headerMap = resolveHeaderMap(frame.headers);
|
||||||
|
const type = headerMap.type?.trim().toLowerCase();
|
||||||
|
if (type !== MESSAGE_TYPE_EVENT) return;
|
||||||
|
|
||||||
|
const messageId = headerMap.message_id?.trim() ?? "";
|
||||||
|
const sum = Number.parseInt(headerMap.sum ?? "", 10);
|
||||||
|
const seq = Number.parseInt(headerMap.seq ?? "", 10);
|
||||||
|
if (!messageId || !Number.isFinite(sum) || !Number.isFinite(seq) || sum <= 0 || seq < 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = frame.payload;
|
||||||
|
if (!payload || payload.length === 0) return;
|
||||||
|
|
||||||
|
const existing = chunkCache.get(messageId);
|
||||||
|
const buffer =
|
||||||
|
existing ??
|
||||||
|
({
|
||||||
|
parts: new Array(sum).fill(undefined),
|
||||||
|
received: 0,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
} satisfies ChunkBuffer);
|
||||||
|
if (!existing) chunkCache.set(messageId, buffer);
|
||||||
|
|
||||||
|
if (!buffer.parts[seq]) {
|
||||||
|
buffer.parts[seq] = payload;
|
||||||
|
buffer.received += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const merged = mergeChunks(buffer);
|
||||||
|
if (!merged) return;
|
||||||
|
chunkCache.delete(messageId);
|
||||||
|
|
||||||
|
const raw = decoder.decode(merged);
|
||||||
|
const parsed = parseJson(raw);
|
||||||
|
if (!parsed.ok) {
|
||||||
|
if (core.logging.shouldLogVerbose()) {
|
||||||
|
options.runtime.error?.(
|
||||||
|
`[${options.account.accountId}] Feishu long connection invalid JSON: ${parsed.error}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ws.send(encodeFeishuLongConnectionFrame(buildAckFrame({ request: frame, code: 500 })));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.send(encodeFeishuLongConnectionFrame(buildAckFrame({ request: frame, code: 200 })));
|
||||||
|
await handleEventPayload({
|
||||||
|
core,
|
||||||
|
account: options.account,
|
||||||
|
config: options.config,
|
||||||
|
runtime: options.runtime,
|
||||||
|
statusSink: options.statusSink,
|
||||||
|
event: parsed.value,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const closePromise = new Promise<void>((resolve) => {
|
||||||
|
const onAbort = () => {
|
||||||
|
cleanup();
|
||||||
|
try {
|
||||||
|
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
|
||||||
|
ws.close();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onClose = () => {
|
||||||
|
cleanup();
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
const onMessage = (event: { data: unknown }) => {
|
||||||
|
void (async () => {
|
||||||
|
const bytes = await normalizeWebSocketData(event.data);
|
||||||
|
if (!bytes) return;
|
||||||
|
const frame = decodeFeishuLongConnectionFrame(bytes);
|
||||||
|
if (frame.method === FRAME_TYPE_CONTROL) {
|
||||||
|
handleControlFrame(frame);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (frame.method === FRAME_TYPE_DATA) {
|
||||||
|
await handleDataFrame(frame);
|
||||||
|
}
|
||||||
|
})().catch((err) => {
|
||||||
|
options.runtime.error?.(
|
||||||
|
`[${options.account.accountId}] Feishu long connection message failed: ${String(err)}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const onError = () => {
|
||||||
|
// The close event triggers the reconnect loop.
|
||||||
|
};
|
||||||
|
const cleanup = () => {
|
||||||
|
ws.removeEventListener("close", onClose);
|
||||||
|
ws.removeEventListener("message", onMessage);
|
||||||
|
ws.removeEventListener("error", onError);
|
||||||
|
options.abortSignal.removeEventListener("abort", onAbort);
|
||||||
|
if (pingTimer) clearTimeout(pingTimer);
|
||||||
|
pingTimer = null;
|
||||||
|
clearCache();
|
||||||
|
};
|
||||||
|
ws.addEventListener("close", onClose);
|
||||||
|
ws.addEventListener("message", onMessage);
|
||||||
|
ws.addEventListener("error", onError);
|
||||||
|
options.abortSignal.addEventListener("abort", onAbort, { once: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
schedulePing();
|
||||||
|
await closePromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function monitorFeishuLongConnection(
|
||||||
|
options: FeishuLongConnectionOptions,
|
||||||
|
): Promise<void> {
|
||||||
|
const core = getFeishuRuntime();
|
||||||
|
|
||||||
|
let attempts = 0;
|
||||||
|
while (!options.abortSignal.aborted) {
|
||||||
|
let connectConfig: FeishuWsConnectConfig;
|
||||||
|
try {
|
||||||
|
connectConfig = await fetchConnectConfig(options);
|
||||||
|
} catch (err) {
|
||||||
|
if (options.abortSignal.aborted) return;
|
||||||
|
options.runtime.error?.(
|
||||||
|
`[${options.account.accountId}] Feishu long connection config failed: ${String(err)}`,
|
||||||
|
);
|
||||||
|
await sleepMs(5_000, options.abortSignal);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ws = await connectWebSocket({
|
||||||
|
url: connectConfig.connectUrl,
|
||||||
|
abortSignal: options.abortSignal,
|
||||||
|
});
|
||||||
|
attempts = 0;
|
||||||
|
await monitorSingleSession({ ws, connectConfig, options, core });
|
||||||
|
} catch (err) {
|
||||||
|
if (options.abortSignal.aborted) return;
|
||||||
|
options.runtime.error?.(
|
||||||
|
`[${options.account.accountId}] Feishu long connection failed: ${String(err)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.abortSignal.aborted) return;
|
||||||
|
attempts += 1;
|
||||||
|
const maxAttempts = connectConfig.reconnectCount;
|
||||||
|
if (maxAttempts >= 0 && attempts > maxAttempts) {
|
||||||
|
options.runtime.error?.(
|
||||||
|
`[${options.account.accountId}] Feishu long connection exceeded reconnect attempts`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const jitter =
|
||||||
|
connectConfig.reconnectNonceMs > 0 ? Math.random() * connectConfig.reconnectNonceMs : 0;
|
||||||
|
await sleepMs(connectConfig.reconnectIntervalMs + jitter, options.abortSignal);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user