From eb60b2208cf8fdfb695fa9d8f8136db1d58b4df6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 13 Jul 2026 19:02:54 +0100 Subject: [PATCH] refactor(feishu): remove unused exports --- .../feishu/src/app-registration.test.ts | 3 +- extensions/feishu/src/bot-agent-body.ts | 60 ++++++ extensions/feishu/src/bot-broadcast.ts | 21 ++ extensions/feishu/src/bot-content.ts | 2 +- extensions/feishu/src/bot-group-name-state.ts | 1 + extensions/feishu/src/bot-group-name.test.ts | 5 +- extensions/feishu/src/bot-group-name.ts | 87 ++++++++ extensions/feishu/src/bot-runtime-api.ts | 7 +- extensions/feishu/src/bot.broadcast.test.ts | 5 +- extensions/feishu/src/bot.card-action.test.ts | 27 +-- extensions/feishu/src/bot.helpers.test.ts | 24 +-- extensions/feishu/src/bot.ts | 191 +----------------- extensions/feishu/src/card-action-state.ts | 9 + extensions/feishu/src/card-action.ts | 81 +++----- .../feishu/src/card-ux-launcher.test.ts | 48 +---- extensions/feishu/src/card-ux-launcher.ts | 4 +- extensions/feishu/src/client-timeout.ts | 4 +- extensions/feishu/src/client.test.ts | 70 +------ extensions/feishu/src/client.ts | 36 +--- extensions/feishu/src/comment-shared.test.ts | 16 +- extensions/feishu/src/comment-shared.ts | 50 +---- extensions/feishu/src/config-schema.test.ts | 8 +- extensions/feishu/src/config-schema.ts | 2 +- extensions/feishu/src/dedup-state.ts | 23 +++ extensions/feishu/src/dedup.test.ts | 19 +- extensions/feishu/src/dedup.ts | 48 +---- extensions/feishu/src/delivery-trace.test.ts | 8 +- extensions/feishu/src/doctor.test.ts | 11 +- extensions/feishu/src/doctor.ts | 4 +- extensions/feishu/src/json-response.ts | 2 +- .../feishu/src/lifecycle.test-support.ts | 4 +- extensions/feishu/src/media.test.ts | 53 +---- extensions/feishu/src/media.ts | 2 +- extensions/feishu/src/monitor-defaults.ts | 64 ++++++ .../feishu/src/monitor-rate-limit-key.ts | 19 ++ .../feishu/src/monitor-startup-timeout.ts | 18 ++ ...itor.card-action.lifecycle.test-support.ts | 8 +- .../src/monitor.cleanup.test-helpers.ts | 24 +++ extensions/feishu/src/monitor.cleanup.test.ts | 91 +-------- extensions/feishu/src/monitor.startup.test.ts | 9 +- extensions/feishu/src/monitor.startup.ts | 19 +- .../feishu/src/monitor.state.defaults.test.ts | 14 +- extensions/feishu/src/monitor.state.ts | 154 +------------- extensions/feishu/src/monitor.transport.ts | 23 +-- extensions/feishu/src/monitor.ts | 16 -- .../feishu/src/monitor.webhook-e2e.test.ts | 7 +- .../src/monitor.webhook-security.test.ts | 41 ++-- extensions/feishu/src/probe.test.ts | 23 +-- extensions/feishu/src/probe.ts | 7 +- .../feishu/src/reply-dispatcher-state.ts | 1 + .../feishu/src/reply-dispatcher.test.ts | 8 +- extensions/feishu/src/reply-dispatcher.ts | 6 +- extensions/feishu/src/send-rate-limit.ts | 24 +++ extensions/feishu/src/send.retry.test.ts | 4 +- extensions/feishu/src/send.test.ts | 146 +++++-------- extensions/feishu/src/send.ts | 6 +- .../feishu/src/streaming-card-send-mode.ts | 12 ++ extensions/feishu/src/streaming-card.test.ts | 17 +- extensions/feishu/src/streaming-card.ts | 13 +- extensions/feishu/src/typing-backoff.ts | 39 ++++ extensions/feishu/src/typing.test.ts | 6 +- extensions/feishu/src/typing.ts | 83 +------- scripts/deadcode-exports.baseline.mjs | 49 ----- 63 files changed, 672 insertions(+), 1214 deletions(-) create mode 100644 extensions/feishu/src/bot-agent-body.ts create mode 100644 extensions/feishu/src/bot-broadcast.ts create mode 100644 extensions/feishu/src/bot-group-name-state.ts create mode 100644 extensions/feishu/src/bot-group-name.ts create mode 100644 extensions/feishu/src/card-action-state.ts create mode 100644 extensions/feishu/src/dedup-state.ts create mode 100644 extensions/feishu/src/monitor-defaults.ts create mode 100644 extensions/feishu/src/monitor-rate-limit-key.ts create mode 100644 extensions/feishu/src/monitor-startup-timeout.ts create mode 100644 extensions/feishu/src/monitor.cleanup.test-helpers.ts create mode 100644 extensions/feishu/src/reply-dispatcher-state.ts create mode 100644 extensions/feishu/src/send-rate-limit.ts create mode 100644 extensions/feishu/src/streaming-card-send-mode.ts create mode 100644 extensions/feishu/src/typing-backoff.ts diff --git a/extensions/feishu/src/app-registration.test.ts b/extensions/feishu/src/app-registration.test.ts index 98af5b6dad08..6be4beda7dc1 100644 --- a/extensions/feishu/src/app-registration.test.ts +++ b/extensions/feishu/src/app-registration.test.ts @@ -11,7 +11,8 @@ import { pollAppRegistration, printQrCode, } from "./app-registration.js"; -import { FEISHU_JSON_MAX_BYTES } from "./json-response.js"; + +const FEISHU_JSON_MAX_BYTES = 16 * 1024 * 1024; const { renderQrTerminalMock } = vi.hoisted(() => ({ renderQrTerminalMock: vi.fn(async () => "terminal-qr"), diff --git a/extensions/feishu/src/bot-agent-body.ts b/extensions/feishu/src/bot-agent-body.ts new file mode 100644 index 000000000000..7acab253a8c6 --- /dev/null +++ b/extensions/feishu/src/bot-agent-body.ts @@ -0,0 +1,60 @@ +import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; +import type { FeishuPermissionError } from "./bot-sender-name.js"; +import type { FeishuMessageContext } from "./types.js"; + +const MAX_MENTION_CONTEXT_NAME_LENGTH = 80; + +function formatMentionNameForAgentContext(name: string): string { + const stripped = Array.from(name, (char) => { + const code = char.charCodeAt(0); + return code < 0x20 || char === "[" || char === "]" ? " " : char; + }).join(""); + const normalized = stripped.replace(/\s+/g, " ").trim(); + const bounded = + normalized.length > MAX_MENTION_CONTEXT_NAME_LENGTH + ? `${truncateUtf16Safe(normalized, MAX_MENTION_CONTEXT_NAME_LENGTH - 3)}...` + : normalized; + return JSON.stringify(bounded || "unknown"); +} + +export function buildFeishuAgentBody(params: { + ctx: Pick< + FeishuMessageContext, + "content" | "senderName" | "senderOpenId" | "mentionTargets" | "messageId" | "hasAnyMention" + >; + quotedContent?: string; + permissionErrorForAgent?: FeishuPermissionError; + botOpenId?: string; +}): string { + const { ctx, quotedContent, permissionErrorForAgent, botOpenId } = params; + let messageBody = ctx.content; + if (quotedContent) { + messageBody = `[Replying to: "${quotedContent}"]\n\n${ctx.content}`; + } + + messageBody = `${ctx.senderName ?? ctx.senderOpenId}: ${messageBody}`; + + if (ctx.hasAnyMention) { + const botIdHint = botOpenId?.trim(); + messageBody += + `\n\n[System: The content may include mention tags in the form name. ` + + `Treat these as real mentions of Feishu entities (users or bots).]`; + if (botIdHint) { + messageBody += `\n[System: If user_id is "${botIdHint}", that mention refers to you.]`; + } + } + + if (ctx.mentionTargets && ctx.mentionTargets.length > 0) { + const targetNames = ctx.mentionTargets + .map((target) => formatMentionNameForAgentContext(target.name)) + .join(", "); + messageBody += `\n\n[System: Feishu users mentioned in the incoming message, for context only: ${targetNames}. Do not notify or mention these users solely because they are listed here.]`; + } + + messageBody = `[message_id: ${ctx.messageId}]\n${messageBody}`; + if (permissionErrorForAgent) { + const grantUrl = permissionErrorForAgent.grantUrl ?? ""; + messageBody += `\n\n[System: The bot encountered a Feishu API permission error. Please inform the user about this issue and provide the permission grant URL for the admin to authorize. Permission grant URL: ${grantUrl}]`; + } + return messageBody; +} diff --git a/extensions/feishu/src/bot-broadcast.ts b/extensions/feishu/src/bot-broadcast.ts new file mode 100644 index 000000000000..7433489099c3 --- /dev/null +++ b/extensions/feishu/src/bot-broadcast.ts @@ -0,0 +1,21 @@ +import type { ClawdbotConfig } from "./bot-runtime-api.js"; + +export function resolveBroadcastAgents(cfg: ClawdbotConfig, peerId: string): string[] | null { + const broadcast = (cfg as Record).broadcast; + if (!broadcast || typeof broadcast !== "object") { + return null; + } + const agents = (broadcast as Record)[peerId]; + return Array.isArray(agents) && agents.length > 0 ? (agents as string[]) : null; +} + +export function buildBroadcastSessionKey( + baseSessionKey: string, + originalAgentId: string, + targetAgentId: string, +): string { + const prefix = `agent:${originalAgentId}:`; + return baseSessionKey.startsWith(prefix) + ? `agent:${targetAgentId}:${baseSessionKey.slice(prefix.length)}` + : baseSessionKey; +} diff --git a/extensions/feishu/src/bot-content.ts b/extensions/feishu/src/bot-content.ts index dec08a6bb09f..1878cf75f028 100644 --- a/extensions/feishu/src/bot-content.ts +++ b/extensions/feishu/src/bot-content.ts @@ -373,7 +373,7 @@ function parseMediaKeys( } } -export function toMessageResourceType(messageType: string): "image" | "file" { +function toMessageResourceType(messageType: string): "image" | "file" { return messageType === "image" ? "image" : "file"; } diff --git a/extensions/feishu/src/bot-group-name-state.ts b/extensions/feishu/src/bot-group-name-state.ts new file mode 100644 index 000000000000..66bda65792b6 --- /dev/null +++ b/extensions/feishu/src/bot-group-name-state.ts @@ -0,0 +1 @@ +export const feishuGroupNameCache = new Map(); diff --git a/extensions/feishu/src/bot-group-name.test.ts b/extensions/feishu/src/bot-group-name.test.ts index 135dfa8eb0a0..13b95ba44794 100644 --- a/extensions/feishu/src/bot-group-name.test.ts +++ b/extensions/feishu/src/bot-group-name.test.ts @@ -1,6 +1,7 @@ // Feishu tests cover bot group name plugin behavior. import { afterAll, describe, it, expect, vi, beforeEach } from "vitest"; -import { resolveGroupName, clearGroupNameCache } from "./bot.js"; +import { feishuGroupNameCache } from "./bot-group-name-state.js"; +import { resolveGroupName } from "./bot-group-name.js"; import type { ResolvedFeishuAccount } from "./types.js"; const mockGetChatInfo = vi.hoisted(() => vi.fn()); @@ -52,7 +53,7 @@ describe("resolveGroupName", () => { mockGetChatInfo.mockReset(); mockCreateFeishuClient.mockReset(); mockCreateFeishuClient.mockReturnValue({}); - clearGroupNameCache(); + feishuGroupNameCache.clear(); }); it("returns the trimmed group name on successful API call", async () => { diff --git a/extensions/feishu/src/bot-group-name.ts b/extensions/feishu/src/bot-group-name.ts new file mode 100644 index 000000000000..4189df8bf29c --- /dev/null +++ b/extensions/feishu/src/bot-group-name.ts @@ -0,0 +1,87 @@ +import { + asDateTimestampMs, + resolveExpiresAtMsFromDurationMs, +} from "openclaw/plugin-sdk/number-runtime"; +import { feishuGroupNameCache } from "./bot-group-name-state.js"; +import { getChatInfo } from "./chat.js"; +import { createFeishuClient } from "./client.js"; +import type { ResolvedFeishuAccount } from "./types.js"; + +const GROUP_NAME_CACHE_TTL_MS = 30 * 60 * 1000; +const GROUP_NAME_CACHE_MAX_SIZE = 500; + +function evictGroupNameCache(): void { + const now = asDateTimestampMs(Date.now()); + if (now === undefined) { + feishuGroupNameCache.clear(); + return; + } + for (const [key, value] of feishuGroupNameCache) { + const expiresAt = asDateTimestampMs(value.expiresAt); + if (expiresAt === undefined || expiresAt <= now) { + feishuGroupNameCache.delete(key); + } + } + + const excess = feishuGroupNameCache.size - GROUP_NAME_CACHE_MAX_SIZE; + if (excess <= 0) { + return; + } + let removed = 0; + for (const key of feishuGroupNameCache.keys()) { + if (removed >= excess) { + break; + } + feishuGroupNameCache.delete(key); + removed++; + } +} + +function setCacheEntry(key: string, name: string): void { + const expiresAt = resolveExpiresAtMsFromDurationMs(GROUP_NAME_CACHE_TTL_MS); + feishuGroupNameCache.delete(key); + if (expiresAt !== undefined) { + feishuGroupNameCache.set(key, { name, expiresAt }); + } +} + +export async function resolveGroupName(params: { + account: ResolvedFeishuAccount; + chatId: string; + log: (...args: unknown[]) => void; +}): Promise { + const { account, chatId, log } = params; + if (!account.configured) { + return undefined; + } + + const cacheKey = `${account.accountId}:${chatId}`; + const cached = feishuGroupNameCache.get(cacheKey); + if (cached) { + const now = asDateTimestampMs(Date.now()); + const expiresAt = asDateTimestampMs(cached.expiresAt); + if (now !== undefined && expiresAt !== undefined && expiresAt > now) { + return cached.name || undefined; + } + feishuGroupNameCache.delete(cacheKey); + } + + let resolvedName: string | undefined; + try { + const client = createFeishuClient(account); + const chatInfo = await getChatInfo(client, chatId); + const name = chatInfo?.name?.trim(); + if (name) { + setCacheEntry(cacheKey, name); + resolvedName = name; + } else { + setCacheEntry(cacheKey, ""); + } + } catch (err) { + log(`feishu[${account.accountId}]: getChatInfo failed for ${chatId}: ${String(err)}`); + setCacheEntry(cacheKey, ""); + } + + evictGroupNameCache(); + return resolvedName; +} diff --git a/extensions/feishu/src/bot-runtime-api.ts b/extensions/feishu/src/bot-runtime-api.ts index 50dd05afdef8..efbd4265f22c 100644 --- a/extensions/feishu/src/bot-runtime-api.ts +++ b/extensions/feishu/src/bot-runtime-api.ts @@ -1,13 +1,8 @@ // Feishu API module exposes the plugin public contract. export { - buildAgentMediaPayload, resolveChannelContextVisibilityMode, type ClawdbotConfig, type RuntimeEnv, } from "../runtime-api.js"; -export { - evaluateSupplementalContextVisibility, - filterSupplementalContextItems, - normalizeAgentId, -} from "../runtime-api.js"; +export { evaluateSupplementalContextVisibility, normalizeAgentId } from "../runtime-api.js"; export { getSessionEntry } from "../runtime-api.js"; diff --git a/extensions/feishu/src/bot.broadcast.test.ts b/extensions/feishu/src/bot.broadcast.test.ts index 8d1d227e6331..69533e241919 100644 --- a/extensions/feishu/src/bot.broadcast.test.ts +++ b/extensions/feishu/src/bot.broadcast.test.ts @@ -2,8 +2,9 @@ import type { EnvelopeFormatOptions } from "openclaw/plugin-sdk/channel-inbound"; import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { ClawdbotConfig, PluginRuntime } from "../runtime-api.js"; +import { feishuGroupNameCache } from "./bot-group-name-state.js"; import type { FeishuMessageEvent } from "./bot.js"; -import { clearGroupNameCache, handleFeishuMessage } from "./bot.js"; +import { handleFeishuMessage } from "./bot.js"; import { setFeishuRuntime } from "./runtime.js"; const { mockCreateFeishuReplyDispatcher, mockCreateFeishuClient, mockResolveAgentRoute } = @@ -218,7 +219,7 @@ describe("broadcast dispatch", () => { beforeEach(() => { vi.clearAllMocks(); - clearGroupNameCache(); + feishuGroupNameCache.clear(); finalizeInboundContextCalls.length = 0; mockResolveAgentRoute.mockReturnValue({ agentId: "main", diff --git a/extensions/feishu/src/bot.card-action.test.ts b/extensions/feishu/src/bot.card-action.test.ts index acf507a9e36d..6da25176cac5 100644 --- a/extensions/feishu/src/bot.card-action.test.ts +++ b/extensions/feishu/src/bot.card-action.test.ts @@ -2,12 +2,8 @@ import { createRuntimeEnv } from "openclaw/plugin-sdk/plugin-test-runtime"; import { afterAll, afterEach, describe, it, expect, vi, beforeEach } from "vitest"; import type { ClawdbotConfig, RuntimeEnv } from "../runtime-api.js"; -import { - FeishuRetryableCardActionError, - handleFeishuCardAction, - resetProcessedFeishuCardActionTokensForTests, - type FeishuCardActionEvent, -} from "./card-action.js"; +import { processedCardActions, resolvedCardActionChatTypes } from "./card-action-state.js"; +import { handleFeishuCardAction, type FeishuCardActionEvent } from "./card-action.js"; import { createFeishuCardInteractionEnvelope } from "./card-interaction.js"; import { expectFirstSentCardUsesFillWidthOnly, @@ -121,7 +117,8 @@ describe("Feishu Card Action Handler", () => { vi.mocked(handleFeishuMessage) .mockReset() .mockResolvedValue(undefined as never); - resetProcessedFeishuCardActionTokensForTests(); + processedCardActions.clear(); + resolvedCardActionChatTypes.clear(); }); function mockCallArg( @@ -636,22 +633,6 @@ describe("Feishu Card Action Handler", () => { expect(handleFeishuMessage).toHaveBeenCalledTimes(1); }); - it("releases a claimed token for explicit retryable dispatch failures", async () => { - const event = createStructuredQuickActionEvent({ - token: "tok11-retryable", - action: "feishu.quick_actions.help", - command: "/help", - }); - vi.mocked(handleFeishuMessage) - .mockRejectedValueOnce(new FeishuRetryableCardActionError("retry me")) - .mockResolvedValueOnce(undefined as never); - - await expect(handleFeishuCardAction({ cfg, event, runtime })).rejects.toThrow("retry me"); - await handleFeishuCardAction({ cfg, event, runtime }); - - expect(handleFeishuMessage).toHaveBeenCalledTimes(2); - }); - it("keeps an in-flight token claimed while a slow dispatch is still running", async () => { vi.useFakeTimers(); const event: FeishuCardActionEvent = { diff --git a/extensions/feishu/src/bot.helpers.test.ts b/extensions/feishu/src/bot.helpers.test.ts index 3926cded5a27..f037c8f104d1 100644 --- a/extensions/feishu/src/bot.helpers.test.ts +++ b/extensions/feishu/src/bot.helpers.test.ts @@ -1,13 +1,9 @@ // Feishu tests cover bot.helpers plugin behavior. import { describe, expect, it } from "vitest"; import type { ClawdbotConfig } from "../runtime-api.js"; +import { buildFeishuAgentBody } from "./bot-agent-body.js"; +import { buildBroadcastSessionKey, resolveBroadcastAgents } from "./bot-broadcast.js"; import { parseMessageContent, resolveFeishuMediaFailurePresentation } from "./bot-content.js"; -import { - buildBroadcastSessionKey, - buildFeishuAgentBody, - resolveBroadcastAgents, - toMessageResourceType, -} from "./bot.js"; describe("buildFeishuAgentBody", () => { it("builds message id, speaker, quoted content, mention context, and permission notice in order", () => { @@ -67,22 +63,6 @@ describe("buildFeishuAgentBody", () => { }); }); -describe("toMessageResourceType", () => { - it("maps image to image", () => { - expect(toMessageResourceType("image")).toBe("image"); - }); - - it("maps audio to file", () => { - expect(toMessageResourceType("audio")).toBe("file"); - }); - - it("maps video/file/sticker to file", () => { - expect(toMessageResourceType("video")).toBe("file"); - expect(toMessageResourceType("file")).toBe("file"); - expect(toMessageResourceType("sticker")).toBe("file"); - }); -}); - describe("parseMessageContent media placeholders", () => { it("uses an audio placeholder instead of leaking raw file_key JSON", () => { expect( diff --git a/extensions/feishu/src/bot.ts b/extensions/feishu/src/bot.ts index e1de4277a613..aa82dd598386 100644 --- a/extensions/feishu/src/bot.ts +++ b/extensions/feishu/src/bot.ts @@ -11,11 +11,7 @@ import { resolveConfiguredBindingRoute, resolveRuntimeConversationBindingRoute, } from "openclaw/plugin-sdk/conversation-runtime"; -import { - asDateTimestampMs, - parseStrictNonNegativeInteger, - resolveExpiresAtMsFromDurationMs, -} from "openclaw/plugin-sdk/number-runtime"; +import { parseStrictNonNegativeInteger } from "openclaw/plugin-sdk/number-runtime"; import { DEFAULT_GROUP_HISTORY_LIMIT, createChannelHistoryWindow, @@ -31,6 +27,8 @@ import { resolvePinnedMainDmOwnerFromAllowlist } from "openclaw/plugin-sdk/secur import { normalizeOptionalString, uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import { resolveFeishuRuntimeAccount } from "./accounts.js"; +import { buildFeishuAgentBody } from "./bot-agent-body.js"; +import { buildBroadcastSessionKey, resolveBroadcastAgents } from "./bot-broadcast.js"; import { checkBotMentioned, normalizeFeishuCommandProbeBody, @@ -41,14 +39,14 @@ import { resolveFeishuMediaList, resolveFeishuMediaFailurePresentation, } from "./bot-content.js"; +import { resolveGroupName } from "./bot-group-name.js"; import { evaluateSupplementalContextVisibility, normalizeAgentId, resolveChannelContextVisibilityMode, } from "./bot-runtime-api.js"; import type { ClawdbotConfig, RuntimeEnv } from "./bot-runtime-api.js"; -import { type FeishuPermissionError, resolveFeishuSenderName } from "./bot-sender-name.js"; -import { getChatInfo } from "./chat.js"; +import { resolveFeishuSenderName, type FeishuPermissionError } from "./bot-sender-name.js"; import { createFeishuClient } from "./client.js"; import { resolveConfiguredFeishuGroupSessionScope } from "./conversation-id.js"; import { finalizeFeishuMessageProcessing, recordProcessedFeishuMessage } from "./dedup.js"; @@ -75,20 +73,13 @@ import { type FeishuMessageContext, type FeishuMediaInfo, type FeishuMessageInfo, - type ResolvedFeishuAccount, } from "./types.js"; -export { toMessageResourceType } from "./bot-content.js"; - // Cache permission errors to avoid spamming the user with repeated notifications. // Key: appId or "default", Value: timestamp of last notification const permissionErrorNotifiedAt = new Map(); const PERMISSION_ERROR_COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes -const groupNameCache = new Map(); -const GROUP_NAME_CACHE_TTL_MS = 30 * 60 * 1000; // 30 minutes -const GROUP_NAME_CACHE_MAX_SIZE = 500; // hard cap - function shouldSendNoVisibleReplyFallback(dispatchResult: { counts: { final?: number }; failedCounts?: { final?: number }; @@ -117,87 +108,6 @@ function isFeishuTopicSessionScope( return scope === "group_topic" || scope === "group_topic_sender"; } -function evictGroupNameCache(): void { - const now = asDateTimestampMs(Date.now()); - if (now === undefined) { - groupNameCache.clear(); - return; - } - for (const [key, val] of groupNameCache) { - const expiresAt = asDateTimestampMs(val.expiresAt); - if (expiresAt === undefined || expiresAt <= now) { - groupNameCache.delete(key); - } - } - - if (groupNameCache.size > GROUP_NAME_CACHE_MAX_SIZE) { - const excess = groupNameCache.size - GROUP_NAME_CACHE_MAX_SIZE; - let removed = 0; - for (const key of groupNameCache.keys()) { - if (removed >= excess) { - break; - } - groupNameCache.delete(key); - removed++; - } - } -} - -function setCacheEntry(key: string, name: string): void { - const expiresAt = resolveExpiresAtMsFromDurationMs(GROUP_NAME_CACHE_TTL_MS); - groupNameCache.delete(key); - if (expiresAt !== undefined) { - groupNameCache.set(key, { name, expiresAt }); - } -} - -export function clearGroupNameCache(): void { - groupNameCache.clear(); -} - -export async function resolveGroupName(params: { - account: ResolvedFeishuAccount; - chatId: string; - log: (...args: unknown[]) => void; -}): Promise { - const { account, chatId, log } = params; - if (!account.configured) { - return undefined; - } - - const cacheKey = `${account.accountId}:${chatId}`; - - const cached = groupNameCache.get(cacheKey); - if (cached) { - const now = asDateTimestampMs(Date.now()); - const expiresAt = asDateTimestampMs(cached.expiresAt); - if (now !== undefined && expiresAt !== undefined && expiresAt > now) { - return cached.name || undefined; - } - groupNameCache.delete(cacheKey); - } - - let resolvedName: string | undefined; - try { - const client = createFeishuClient(account); - const chatInfo = await getChatInfo(client, chatId); - const name = chatInfo?.name?.trim(); - if (name) { - setCacheEntry(cacheKey, name); - resolvedName = name; - } else { - setCacheEntry(cacheKey, ""); - } - } catch (err) { - log(`feishu[${account.accountId}]: getChatInfo failed for ${chatId}: ${String(err)}`); - setCacheEntry(cacheKey, ""); - } - - evictGroupNameCache(); - - return resolvedName; -} - async function resolveFeishuAudioPreflightTranscript(params: { cfg: ClawdbotConfig; mediaList: FeishuMediaInfo[]; @@ -229,35 +139,6 @@ async function resolveFeishuAudioPreflightTranscript(params: { } } -// --- Broadcast support --- -// Resolve broadcast agent list for a given peer (group) ID. -// Returns null if no broadcast config exists or the peer is not in the broadcast list. -export function resolveBroadcastAgents(cfg: ClawdbotConfig, peerId: string): string[] | null { - const broadcast = (cfg as Record).broadcast; - if (!broadcast || typeof broadcast !== "object") { - return null; - } - const agents = (broadcast as Record)[peerId]; - if (!Array.isArray(agents) || agents.length === 0) { - return null; - } - return agents as string[]; -} - -// Build a session key for a broadcast target agent by replacing the agent ID prefix. -// Session keys follow the format: agent:::: -export function buildBroadcastSessionKey( - baseSessionKey: string, - originalAgentId: string, - targetAgentId: string, -): string { - const prefix = `agent:${originalAgentId}:`; - if (baseSessionKey.startsWith(prefix)) { - return `agent:${targetAgentId}:${baseSessionKey.slice(prefix.length)}`; - } - return baseSessionKey; -} - /** * Build media payload for inbound context. * Similar to Discord's buildDiscordMediaPayload(). @@ -312,68 +193,6 @@ export function parseFeishuMessageEvent( return ctx; } -const MAX_MENTION_CONTEXT_NAME_LENGTH = 80; - -function formatMentionNameForAgentContext(name: string): string { - const stripped = Array.from(name, (char) => { - const code = char.charCodeAt(0); - return code < 0x20 || char === "[" || char === "]" ? " " : char; - }).join(""); - const normalized = stripped.replace(/\s+/g, " ").trim(); - const bounded = - normalized.length > MAX_MENTION_CONTEXT_NAME_LENGTH - ? `${truncateUtf16Safe(normalized, MAX_MENTION_CONTEXT_NAME_LENGTH - 3)}...` - : normalized; - return JSON.stringify(bounded || "unknown"); -} - -export function buildFeishuAgentBody(params: { - ctx: Pick< - FeishuMessageContext, - "content" | "senderName" | "senderOpenId" | "mentionTargets" | "messageId" | "hasAnyMention" - >; - quotedContent?: string; - permissionErrorForAgent?: FeishuPermissionError; - botOpenId?: string; -}): string { - const { ctx, quotedContent, permissionErrorForAgent, botOpenId } = params; - let messageBody = ctx.content; - if (quotedContent) { - messageBody = `[Replying to: "${quotedContent}"]\n\n${ctx.content}`; - } - - // DMs already have per-sender sessions, but this label still improves attribution. - const speaker = ctx.senderName ?? ctx.senderOpenId; - messageBody = `${speaker}: ${messageBody}`; - - if (ctx.hasAnyMention) { - const botIdHint = botOpenId?.trim(); - messageBody += - `\n\n[System: The content may include mention tags in the form name. ` + - `Treat these as real mentions of Feishu entities (users or bots).]`; - if (botIdHint) { - messageBody += `\n[System: If user_id is "${botIdHint}", that mention refers to you.]`; - } - } - - if (ctx.mentionTargets && ctx.mentionTargets.length > 0) { - const targetNames = ctx.mentionTargets - .map((t) => formatMentionNameForAgentContext(t.name)) - .join(", "); - messageBody += `\n\n[System: Feishu users mentioned in the incoming message, for context only: ${targetNames}. Do not notify or mention these users solely because they are listed here.]`; - } - - // Keep message_id on its own line so shared message-id hint stripping can parse it reliably. - messageBody = `[message_id: ${ctx.messageId}]\n${messageBody}`; - - if (permissionErrorForAgent) { - const grantUrl = permissionErrorForAgent.grantUrl ?? ""; - messageBody += `\n\n[System: The bot encountered a Feishu API permission error. Please inform the user about this issue and provide the permission grant URL for the admin to authorize. Permission grant URL: ${grantUrl}]`; - } - - return messageBody; -} - async function shouldIncludeFetchedGroupContextMessage(params: { cfg: ClawdbotConfig; accountId: string; diff --git a/extensions/feishu/src/card-action-state.ts b/extensions/feishu/src/card-action-state.ts new file mode 100644 index 000000000000..6ffcf15e1961 --- /dev/null +++ b/extensions/feishu/src/card-action-state.ts @@ -0,0 +1,9 @@ +export const processedCardActions = new Map< + string, + { status: "inflight" | "completed"; expiresAt: number } +>(); + +export const resolvedCardActionChatTypes = new Map< + string, + { value: "p2p" | "group"; expiresAt: number } +>(); diff --git a/extensions/feishu/src/card-action.ts b/extensions/feishu/src/card-action.ts index 44c68af70e00..9ea506c7f440 100644 --- a/extensions/feishu/src/card-action.ts +++ b/extensions/feishu/src/card-action.ts @@ -8,6 +8,7 @@ import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import type { ClawdbotConfig, PluginRuntime, RuntimeEnv } from "../runtime-api.js"; import { resolveFeishuRuntimeAccount } from "./accounts.js"; import { handleFeishuMessage, type FeishuMessageEvent } from "./bot.js"; +import { processedCardActions, resolvedCardActionChatTypes } from "./card-action-state.js"; import { decodeFeishuCardAction, buildFeishuCardActionTextFallback } from "./card-interaction.js"; import { createApprovalCard, @@ -41,32 +42,15 @@ export type FeishuCardActionEvent = { const FEISHU_APPROVAL_CARD_TTL_MS = 5 * 60_000; const FEISHU_CARD_ACTION_TOKEN_TTL_MS = 15 * 60_000; -const processedCardActionTokens = new Map< - string, - { status: "inflight" | "completed"; expiresAt: number } ->(); - -export class FeishuRetryableCardActionError extends Error { - constructor(message: string, options?: ErrorOptions) { - super(message, options); - this.name = "FeishuRetryableCardActionError"; - } -} - -export function resetProcessedFeishuCardActionTokensForTests(): void { - processedCardActionTokens.clear(); - resolvedChatTypeCache.clear(); -} - function pruneProcessedCardActionTokens(now: number): void { const validNow = asDateTimestampMs(now); if (validNow === undefined) { - processedCardActionTokens.clear(); + processedCardActions.clear(); return; } - for (const [key, entry] of processedCardActionTokens.entries()) { + for (const [key, entry] of processedCardActions.entries()) { if (!isFutureDateTimestampMs(entry.expiresAt, { nowMs: validNow })) { - processedCardActionTokens.delete(key); + processedCardActions.delete(key); } } } @@ -87,14 +71,14 @@ function beginFeishuCardActionToken(params: { return false; } const key = `${params.accountId}:${normalizedToken}`; - const existing = processedCardActionTokens.get(key); + const existing = processedCardActions.get(key); if (existing && isFutureDateTimestampMs(existing.expiresAt, { nowMs: now })) { return false; } - processedCardActionTokens.delete(key); + processedCardActions.delete(key); const expiresAt = resolveProcessedCardActionTokenExpiresAt(now); if (expiresAt !== undefined) { - processedCardActionTokens.set(key, { + processedCardActions.set(key, { status: "inflight", expiresAt, }); @@ -102,36 +86,23 @@ function beginFeishuCardActionToken(params: { return true; } -function completeFeishuCardActionToken(params: { - token: string; - accountId: string; - now?: number; -}): void { - const now = params.now ?? Date.now(); - const normalizedToken = params.token.trim(); - if (!normalizedToken) { +function completeFeishuCardAction(actionId: string, accountId: string, now = Date.now()): void { + const normalizedActionId = actionId.trim(); + if (!normalizedActionId) { return; } - const key = `${params.accountId}:${normalizedToken}`; + const key = `${accountId}:${normalizedActionId}`; const expiresAt = resolveProcessedCardActionTokenExpiresAt(now); if (expiresAt === undefined) { - processedCardActionTokens.delete(key); + processedCardActions.delete(key); return; } - processedCardActionTokens.set(key, { + processedCardActions.set(key, { status: "completed", expiresAt, }); } -function releaseFeishuCardActionToken(params: { token: string; accountId: string }): void { - const normalizedToken = params.token.trim(); - if (!normalizedToken) { - return; - } - processedCardActionTokens.delete(`${params.accountId}:${normalizedToken}`); -} - function buildSyntheticMessageEvent( event: FeishuCardActionEvent, content: string, @@ -199,7 +170,7 @@ async function dispatchSyntheticCommand(params: { }); } -const resolvedChatTypeCache = new Map(); +const resolvedChatTypeCache = resolvedCardActionChatTypes; const CHAT_TYPE_CACHE_TTL_MS = 30 * 60_000; const CHAT_TYPE_CACHE_MAX_SIZE = 5_000; @@ -367,7 +338,7 @@ export async function handleFeishuCardAction(params: { reason: decoded.reason, accountId, }); - completeFeishuCardActionToken({ token: event.token, accountId: account.accountId }); + completeFeishuCardAction(event.token, account.accountId); return; } @@ -386,7 +357,7 @@ export async function handleFeishuCardAction(params: { reason: "malformed", accountId, }); - completeFeishuCardActionToken({ token: event.token, accountId: account.accountId }); + completeFeishuCardAction(event.token, account.accountId); return; } const prompt = @@ -401,7 +372,7 @@ export async function handleFeishuCardAction(params: { reason: "malformed", accountId, }); - completeFeishuCardActionToken({ token: event.token, accountId: account.accountId }); + completeFeishuCardAction(event.token, account.accountId); return; } await sendCardFeishu({ @@ -424,7 +395,7 @@ export async function handleFeishuCardAction(params: { }), accountId, }); - completeFeishuCardActionToken({ token: event.token, accountId: account.accountId }); + completeFeishuCardAction(event.token, account.accountId); return; } @@ -435,7 +406,7 @@ export async function handleFeishuCardAction(params: { text: "Cancelled.", accountId, }); - completeFeishuCardActionToken({ token: event.token, accountId: account.accountId }); + completeFeishuCardAction(event.token, account.accountId); return; } @@ -448,7 +419,7 @@ export async function handleFeishuCardAction(params: { reason: "malformed", accountId, }); - completeFeishuCardActionToken({ token: event.token, accountId: account.accountId }); + completeFeishuCardAction(event.token, account.accountId); return; } await dispatchSyntheticCommand({ @@ -462,7 +433,7 @@ export async function handleFeishuCardAction(params: { accountId, chatType: envelope.c?.t, }); - completeFeishuCardActionToken({ token: event.token, accountId: account.accountId }); + completeFeishuCardAction(event.token, account.accountId); return; } @@ -472,7 +443,7 @@ export async function handleFeishuCardAction(params: { reason: "malformed", accountId, }); - completeFeishuCardActionToken({ token: event.token, accountId: account.accountId }); + completeFeishuCardAction(event.token, account.accountId); return; } @@ -492,13 +463,9 @@ export async function handleFeishuCardAction(params: { channelRuntime: params.channelRuntime, accountId, }); - completeFeishuCardActionToken({ token: event.token, accountId: account.accountId }); + completeFeishuCardAction(event.token, account.accountId); } catch (err) { - if (err instanceof FeishuRetryableCardActionError) { - releaseFeishuCardActionToken({ token: event.token, accountId: account.accountId }); - } else { - completeFeishuCardActionToken({ token: event.token, accountId: account.accountId }); - } + completeFeishuCardAction(event.token, account.accountId); throw err; } } diff --git a/extensions/feishu/src/card-ux-launcher.test.ts b/extensions/feishu/src/card-ux-launcher.test.ts index 1d1122b5a224..fcffd4e2ce2d 100644 --- a/extensions/feishu/src/card-ux-launcher.test.ts +++ b/extensions/feishu/src/card-ux-launcher.test.ts @@ -6,11 +6,7 @@ import { expectFirstSentCardUsesFillWidthOnly, expectSentCardHasP2pAction, } from "./card-test-helpers.js"; -import { - createQuickActionLauncherCard, - isFeishuQuickActionMenuEventKey, - maybeHandleFeishuQuickActionMenu, -} from "./card-ux-launcher.js"; +import { maybeHandleFeishuQuickActionMenu } from "./card-ux-launcher.js"; const sendCardFeishuMock = vi.hoisted(() => vi.fn()); @@ -30,39 +26,15 @@ describe("feishu quick-action launcher", () => { vi.clearAllMocks(); }); - it("recognizes the quick-actions bot menu key", () => { - expect(isFeishuQuickActionMenuEventKey("quick-actions")).toBe(true); - expect(isFeishuQuickActionMenuEventKey("other")).toBe(false); - }); - - it("builds a launcher card with interactive actions", () => { - const card = createQuickActionLauncherCard({ - operatorOpenId: "u123", - chatId: "chat1", - expiresAt: 123, - sessionKey: "agent:codex:feishu:chat:chat1", - }) as { - config: { - width_mode?: string; - enable_forward?: boolean; - wide_screen_mode?: boolean; - }; - body: { - elements: Array<{ - tag: string; - actions?: Array<{ value?: { oc?: string; c?: { s?: string; t?: string } } }>; - }>; - }; - }; - - expect(card.config.width_mode).toBe("fill"); - expect(card.config.enable_forward).toBeUndefined(); - expect(card.config.wide_screen_mode).toBeUndefined(); - const actionBlock = card.body.elements.find((entry) => entry.tag === "action"); - expect(actionBlock?.actions).toHaveLength(3); - expect(actionBlock?.actions?.[0]?.value?.oc).toBe("ocf1"); - expect(actionBlock?.actions?.[0]?.value?.c?.s).toBe("agent:codex:feishu:chat:chat1"); - expect(actionBlock?.actions?.[0]?.value?.c?.t).toBeUndefined(); + it("ignores unsupported bot menu keys", async () => { + await expect( + maybeHandleFeishuQuickActionMenu({ + cfg, + eventKey: "other", + operatorOpenId: "u123", + }), + ).resolves.toBe(false); + expect(sendCardFeishuMock).not.toHaveBeenCalled(); }); it("opens the launcher from a supported bot menu event", async () => { diff --git a/extensions/feishu/src/card-ux-launcher.ts b/extensions/feishu/src/card-ux-launcher.ts index 34849e1d1482..3a84dc64edad 100644 --- a/extensions/feishu/src/card-ux-launcher.ts +++ b/extensions/feishu/src/card-ux-launcher.ts @@ -14,11 +14,11 @@ const FEISHU_QUICK_ACTION_CARD_TTL_MS = 10 * 60_000; const QUICK_ACTION_MENU_KEYS = new Set(["quick-actions", "quick_actions", "launcher"]); -export function isFeishuQuickActionMenuEventKey(eventKey: string): boolean { +function isFeishuQuickActionMenuEventKey(eventKey: string): boolean { return QUICK_ACTION_MENU_KEYS.has(normalizeOptionalLowercaseString(eventKey) ?? ""); } -export function createQuickActionLauncherCard(params: { +function createQuickActionLauncherCard(params: { operatorOpenId: string; chatId?: string; expiresAt: number; diff --git a/extensions/feishu/src/client-timeout.ts b/extensions/feishu/src/client-timeout.ts index d38a5afdc24e..e6644bd76ec8 100644 --- a/extensions/feishu/src/client-timeout.ts +++ b/extensions/feishu/src/client-timeout.ts @@ -4,8 +4,8 @@ import type { FeishuConfig } from "./types.js"; /** Default HTTP timeout for Feishu API requests (30 seconds). */ export const FEISHU_HTTP_TIMEOUT_MS = 30_000; -export const FEISHU_HTTP_TIMEOUT_MAX_MS = 300_000; -export const FEISHU_HTTP_TIMEOUT_ENV_VAR = "OPENCLAW_FEISHU_HTTP_TIMEOUT_MS"; +const FEISHU_HTTP_TIMEOUT_MAX_MS = 300_000; +const FEISHU_HTTP_TIMEOUT_ENV_VAR = "OPENCLAW_FEISHU_HTTP_TIMEOUT_MS"; type FeishuClientTimeoutConfig = { httpTimeoutMs?: number; diff --git a/extensions/feishu/src/client.test.ts b/extensions/feishu/src/client.test.ts index 026bf5640c3a..8daf5a841768 100644 --- a/extensions/feishu/src/client.test.ts +++ b/extensions/feishu/src/client.test.ts @@ -1,12 +1,15 @@ // Feishu tests cover client plugin behavior. import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { FEISHU_HTTP_TIMEOUT_MS } from "./client-timeout.js"; import { FeishuConfigSchema } from "./config-schema.js"; import type { ResolvedFeishuAccount } from "./types.js"; +const FEISHU_HTTP_TIMEOUT_ENV_VAR = "OPENCLAW_FEISHU_HTTP_TIMEOUT_MS"; +const FEISHU_HTTP_TIMEOUT_MAX_MS = 300_000; + type CreateFeishuClient = typeof import("./client.js").createFeishuClient; type CreateFeishuWSClient = typeof import("./client.js").createFeishuWSClient; -type ClearClientCache = typeof import("./client.js").clearClientCache; -type SetFeishuClientRuntimeForTest = typeof import("./client.js").setFeishuClientRuntimeForTest; +type GetFeishuUserAgent = typeof import("./client.js").getFeishuUserAgent; const requestInterceptorState = vi.hoisted(() => { let registered: ((req: unknown) => unknown) | undefined; @@ -73,12 +76,7 @@ const registerFeishuSubagentHooksMock = vi.hoisted(() => vi.fn()); let createFeishuClient: CreateFeishuClient; let createFeishuWSClient: CreateFeishuWSClient; -let clearClientCache: ClearClientCache; -let setFeishuClientRuntimeForTest: SetFeishuClientRuntimeForTest; -let FEISHU_HTTP_TIMEOUT_MS: number; -let FEISHU_HTTP_TIMEOUT_MAX_MS: number; -let FEISHU_HTTP_TIMEOUT_ENV_VAR: string; -let FEISHU_USER_AGENT: string; +let getFeishuUserAgent: GetFeishuUserAgent; let priorProxyEnv: Partial> = {}; let priorFeishuTimeoutEnv: string | undefined; @@ -206,16 +204,7 @@ beforeAll(async () => { ), })); - ({ - createFeishuClient, - createFeishuWSClient, - clearClientCache, - setFeishuClientRuntimeForTest, - FEISHU_HTTP_TIMEOUT_MS, - FEISHU_HTTP_TIMEOUT_MAX_MS, - FEISHU_HTTP_TIMEOUT_ENV_VAR, - FEISHU_USER_AGENT, - } = await import("./client.js")); + ({ createFeishuClient, createFeishuWSClient, getFeishuUserAgent } = await import("./client.js")); }); beforeEach(() => { @@ -227,21 +216,6 @@ beforeEach(() => { setFeishuTestEnvValue(key, undefined); } vi.clearAllMocks(); - clearClientCache(); - setFeishuClientRuntimeForTest({ - sdk: { - AppType: { SelfBuild: "self" } as never, - Domain: { - Feishu: "https://open.feishu.cn", - Lark: "https://open.larksuite.com", - } as never, - LoggerLevel: { info: "info" } as never, - Client: clientCtorMock as never, - WSClient: wsClientCtorMock as never, - EventDispatcher: vi.fn() as never, - defaultHttpInstance: mockBaseHttpInstance as never, - }, - }); }); afterEach(() => { @@ -249,7 +223,6 @@ afterEach(() => { setFeishuTestEnvValue(key, priorProxyEnv[key]); } setFeishuTestEnvValue(FEISHU_HTTP_TIMEOUT_ENV_VAR, priorFeishuTimeoutEnv); - setFeishuClientRuntimeForTest(); }); afterAll(() => { @@ -274,7 +247,7 @@ describe("Feishu default User-Agent interceptor", () => { const req = { headers: { "User-Agent": "oapi-node-sdk/1.0.0" } }; expect(requestInterceptorState.registered?.(req)).toBe(req); - expect(req.headers["User-Agent"]).toBe(FEISHU_USER_AGENT); + expect(req.headers["User-Agent"]).toBe(getFeishuUserAgent()); }); it("sets the User-Agent on AxiosHeaders-like request headers", () => { @@ -283,7 +256,7 @@ describe("Feishu default User-Agent interceptor", () => { expect(requestInterceptorState.registered?.(req)).toBe(req); - expect(headers.set).toHaveBeenCalledWith("User-Agent", FEISHU_USER_AGENT); + expect(headers.set).toHaveBeenCalledWith("User-Agent", getFeishuUserAgent()); }); }); @@ -436,31 +409,6 @@ describe("createFeishuClient HTTP timeout", () => { timeout: 45_000, }); }); - - it("evicts client cache when SDK is replaced via setFeishuClientRuntimeForTest (#83911)", () => { - const ctorCountA = clientCtorMock.mock.calls.length; - - // First client gets cached - createFeishuClient({ appId: "app_7", appSecret: "secret_7", accountId: "cache-clear-test" }); // pragma: allowlist secret - expect(clientCtorMock.mock.calls.length).toBe(ctorCountA + 1); - - // SDK swap via setFeishuClientRuntimeForTest should clear the cache - setFeishuClientRuntimeForTest({ - sdk: { - AppType: { SelfBuild: "self" } as never, - Client: clientCtorMock as never, - Domain: { Feishu: "https://open.feishu.cn", Lark: "https://open.larksuite.com" } as never, - LoggerLevel: { info: "info" } as never, - WSClient: vi.fn() as never, - EventDispatcher: vi.fn() as never, - defaultHttpInstance: mockBaseHttpInstance as never, - }, - }); - - // Same credentials — would hit cache before the fix; now evicted - createFeishuClient({ appId: "app_7", appSecret: "secret_7", accountId: "cache-clear-test" }); // pragma: allowlist secret - expect(clientCtorMock.mock.calls.length).toBe(ctorCountA + 2); - }); }); describe("createFeishuWSClient proxy handling", () => { diff --git a/extensions/feishu/src/client.ts b/extensions/feishu/src/client.ts index 583465a0cf5d..aacafeab2058 100644 --- a/extensions/feishu/src/client.ts +++ b/extensions/feishu/src/client.ts @@ -6,21 +6,13 @@ import { readPluginPackageVersion, resolveAmbientNodeProxyAgent, } from "openclaw/plugin-sdk/extension-shared"; -import { - FEISHU_HTTP_TIMEOUT_ENV_VAR, - FEISHU_HTTP_TIMEOUT_MAX_MS, - FEISHU_HTTP_TIMEOUT_MS, - resolveConfiguredHttpTimeoutMs, -} from "./client-timeout.js"; +import { resolveConfiguredHttpTimeoutMs } from "./client-timeout.js"; import type { FeishuConfig, FeishuDomain, ResolvedFeishuAccount } from "./types.js"; const require = createRequire(import.meta.url); const pluginVersion = readPluginPackageVersion({ require }); -export { pluginVersion }; - const FEISHU_USER_AGENT = `openclaw-feishu-builtin/${pluginVersion}/${process.platform}`; -export { FEISHU_USER_AGENT }; const FEISHU_WS_CONFIG = { pingTimeout: 3, @@ -42,7 +34,7 @@ type FeishuClientSdk = Pick< | "WSClient" >; -const defaultFeishuClientSdk: FeishuClientSdk = { +const feishuClientSdk: FeishuClientSdk = { AppType: Lark.AppType, Client: Lark.Client, defaultHttpInstance: Lark.defaultHttpInstance, @@ -52,8 +44,6 @@ const defaultFeishuClientSdk: FeishuClientSdk = { WSClient: Lark.WSClient, }; -let feishuClientSdk: FeishuClientSdk = defaultFeishuClientSdk; - type RequestInterceptorApi = { use: (fn: (req: unknown) => unknown) => unknown; }; @@ -90,8 +80,6 @@ function setRequestUserAgent(req: unknown) { inst.interceptors?.request?.use(setRequestUserAgent); } -export { FEISHU_HTTP_TIMEOUT_ENV_VAR, FEISHU_HTTP_TIMEOUT_MAX_MS, FEISHU_HTTP_TIMEOUT_MS }; - type FeishuHttpInstanceLike = Pick< typeof feishuClientSdk.defaultHttpInstance, "request" | "get" | "post" | "put" | "patch" | "delete" | "head" | "options" @@ -239,23 +227,3 @@ export function createEventDispatcher(account: ResolvedFeishuAccount): Lark.Even verificationToken: account.verificationToken, }); } - -/** - * Clear client cache for a specific account or all accounts. - */ -export function clearClientCache(accountId?: string): void { - if (accountId) { - clientCache.delete(accountId); - } else { - clientCache.clear(); - } -} - -export function setFeishuClientRuntimeForTest(overrides?: { - sdk?: Partial; -}): void { - feishuClientSdk = overrides?.sdk - ? { ...defaultFeishuClientSdk, ...overrides.sdk } - : defaultFeishuClientSdk; - clearClientCache(); -} diff --git a/extensions/feishu/src/comment-shared.test.ts b/extensions/feishu/src/comment-shared.test.ts index c44e3db14ce6..9b520735ec1e 100644 --- a/extensions/feishu/src/comment-shared.test.ts +++ b/extensions/feishu/src/comment-shared.test.ts @@ -1,9 +1,17 @@ // Feishu tests cover comment shared plugin behavior. import { describe, expect, it } from "vitest"; -import { - parseCommentContentElements, - resolveCommentLinkedDocumentFromUrl, -} from "./comment-shared.js"; +import { parseCommentContentElements } from "./comment-shared.js"; + +function resolveCommentLinkedDocumentFromUrl(params: { + rawUrl: string; + currentDocument?: Parameters[0]["currentDocument"]; +}) { + const parsed = parseCommentContentElements({ + elements: [{ type: "docs_link", docs_link: { url: params.rawUrl } }], + currentDocument: params.currentDocument, + }); + return parsed.linkedDocuments[0] ?? { rawUrl: params.rawUrl, urlKind: "unknown" as const }; +} const VALID_TOKEN_22 = "ABCDEFGHIJKLMNOPQRSTUV"; const VALID_TOKEN_27 = "ZsJfdxrBFo0RwuxteOLc1Ekvneb"; diff --git a/extensions/feishu/src/comment-shared.ts b/extensions/feishu/src/comment-shared.ts index e44df3c88d0a..a535836e34c8 100644 --- a/extensions/feishu/src/comment-shared.ts +++ b/extensions/feishu/src/comment-shared.ts @@ -7,6 +7,10 @@ import { readStringValue, } from "openclaw/plugin-sdk/string-coerce-runtime"; import { FEISHU_COMMENT_FILE_TYPES, type CommentFileType } from "./comment-target.js"; +import { + getFeishuSendRateLimitCode, + getFeishuSendRateLimitCodeFromResponse, +} from "./send-rate-limit.js"; export function encodeQuery(params: Record): string { const query = new URLSearchParams(); @@ -90,53 +94,9 @@ function createFeishuApiError( return new Error(formatFeishuApiFailure(error, errorPrefix, options), { cause: error }); } -// Feishu message-API error codes that signal a transient rate limit; safe to retry with backoff. -// 230020: per-chat rate limit (ext=chat rate limit) — confirmed by real concurrent load test. -// 11232: tenant-level "create message service trigger rate limit" (100/min, 5/sec per app/bot). -// Distinct from FEISHU_BACKOFF_CODES in typing.ts, which covers the reaction API (99991400+). -const FEISHU_SEND_RATE_LIMIT_CODES = new Set([230020, 11232]); const FEISHU_SEND_MAX_RETRIES = 2; const FEISHU_SEND_RETRY_BASE_MS = 500; -/** - * Returns a numeric rate-limit signal when an AxiosError indicates a retryable - * Feishu message-API rate limit. Sources, in priority order: - * 1. Gateway-level HTTP 429 (app-wide quota; `x-ogw-ratelimit-reset` header) - * 2. Business-level `code` in `error.response.data.code` matching - * FEISHU_SEND_RATE_LIMIT_CODES (e.g. 230020 per-chat, 11232 tenant-level). - * Returns `undefined` for all other errors so they propagate without retry. - */ -export function getFeishuSendRateLimitCode(error: unknown): number | undefined { - if (!isRecord(error)) { - return undefined; - } - const response = isRecord(error.response) ? error.response : undefined; - // HTTP 429: Feishu Open API gateway-level rate limit, always retry. - if (typeof response?.status === "number" && response.status === 429) { - return 429; - } - const data = isRecord(response?.data) ? response.data : undefined; - const code = data?.code; - return typeof code === "number" && FEISHU_SEND_RATE_LIMIT_CODES.has(code) ? code : undefined; -} - -/** - * Returns a retryable rate-limit code when a fulfilled (non-throwing) Feishu - * SDK response embeds it in the response body. The Feishu node SDK can resolve - * with `{ code: 11232, msg: "..." }` instead of throwing — see typing.ts - * (getBackoffCodeFromResponse) and issue #28157 for the same behavior on - * messageReaction.create. Without this classification, requestFeishuApi would - * `return` the rate-limited body and downstream `assertFeishuMessageApiSuccess` - * would fail once with no retry. - */ -export function getFeishuSendRateLimitCodeFromResponse(response: unknown): number | undefined { - if (!isRecord(response)) { - return undefined; - } - const code = (response as { code?: unknown }).code; - return typeof code === "number" && FEISHU_SEND_RATE_LIMIT_CODES.has(code) ? code : undefined; -} - export async function requestFeishuApi( request: () => Promise, errorPrefix: string, @@ -329,7 +289,7 @@ function hasResolvedLinkedDocumentReference(link: ParsedCommentLinkedDocument): ); } -export function resolveCommentLinkedDocumentFromUrl(params: { +function resolveCommentLinkedDocumentFromUrl(params: { rawUrl: string; currentDocument?: ParsedCommentDocumentRef; }): ParsedCommentLinkedDocument { diff --git a/extensions/feishu/src/config-schema.test.ts b/extensions/feishu/src/config-schema.test.ts index 9d3ea80e4d60..bc6e1f5a880b 100644 --- a/extensions/feishu/src/config-schema.test.ts +++ b/extensions/feishu/src/config-schema.test.ts @@ -1,6 +1,6 @@ // Feishu tests cover config schema plugin behavior. import { describe, expect, it } from "vitest"; -import { FeishuConfigSchema, FeishuGroupSchema } from "./config-schema.js"; +import { FeishuConfigSchema } from "./config-schema.js"; // The NEGATIVE webhook fixtures below spread these bases and add // verificationToken separately so the GHSA-G353-MGV3-8PCJ opengrep pattern — @@ -202,8 +202,10 @@ describe("FeishuConfigSchema replyInThread", () => { }); it("accepts replyInThread in group config", () => { - const result = FeishuGroupSchema.parse({ replyInThread: "enabled" }); - expect(result.replyInThread).toBe("enabled"); + const result = FeishuConfigSchema.parse({ + groups: { "oc-group": { replyInThread: "enabled" } }, + }); + expect(result.groups?.["oc-group"]?.replyInThread).toBe("enabled"); }); it("accepts replyInThread in account config", () => { diff --git a/extensions/feishu/src/config-schema.ts b/extensions/feishu/src/config-schema.ts index a2991c9f3afe..4234d41dae77 100644 --- a/extensions/feishu/src/config-schema.ts +++ b/extensions/feishu/src/config-schema.ts @@ -178,7 +178,7 @@ const ReactionNotificationModeSchema = z.enum(["off", "own", "all"]).optional(); */ const ReplyInThreadSchema = z.enum(["disabled", "enabled"]).optional(); -export const FeishuGroupSchema = z +const FeishuGroupSchema = z .object({ requireMention: z.boolean().optional(), tools: ToolPolicySchema, diff --git a/extensions/feishu/src/dedup-state.ts b/extensions/feishu/src/dedup-state.ts new file mode 100644 index 000000000000..729865e3da80 --- /dev/null +++ b/extensions/feishu/src/dedup-state.ts @@ -0,0 +1,23 @@ +import { createClaimableDedupe } from "openclaw/plugin-sdk/persistent-dedupe"; + +const DEDUPE_NAMESPACE_PREFIX = "feishu.dedup"; +const DEDUP_TTL_MS = 24 * 60 * 60 * 1000; +const MEMORY_MAX_SIZE = 1_000; +const STORE_MAX_ENTRIES = 10_000; + +function createFeishuDedupeGuard() { + return createClaimableDedupe({ + pluginId: "feishu", + namespacePrefix: DEDUPE_NAMESPACE_PREFIX, + ttlMs: DEDUP_TTL_MS, + memoryMaxSize: MEMORY_MAX_SIZE, + stateMaxEntries: STORE_MAX_ENTRIES, + }); +} + +export const feishuDedupeState = { + guard: createFeishuDedupeGuard(), + reset() { + this.guard = createFeishuDedupeGuard(); + }, +}; diff --git a/extensions/feishu/src/dedup.test.ts b/extensions/feishu/src/dedup.test.ts index c4c050a74ca3..81ff4583e5af 100644 --- a/extensions/feishu/src/dedup.test.ts +++ b/extensions/feishu/src/dedup.test.ts @@ -4,13 +4,13 @@ import os from "node:os"; import path from "node:path"; import { resetPluginStateStoreForTests } from "openclaw/plugin-sdk/plugin-state-test-runtime"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { feishuDedupeState } from "./dedup-state.js"; import { claimUnprocessedFeishuMessage, finalizeFeishuMessageProcessing, hasProcessedFeishuMessage, recordProcessedFeishuMessage, releaseFeishuMessageProcessing, - testingHooks, warmupDedupFromPluginState, } from "./dedup.js"; @@ -21,12 +21,11 @@ beforeEach(() => { previousStateDir = process.env.OPENCLAW_STATE_DIR; tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-feishu-dedup-")); process.env.OPENCLAW_STATE_DIR = tempDir; - testingHooks.resetFeishuDedupForTests(); + feishuDedupeState.reset(); }); afterEach(() => { vi.useRealTimers(); - testingHooks.resetFeishuDedupForTests(); resetPluginStateStoreForTests(); if (previousStateDir === undefined) { delete process.env.OPENCLAW_STATE_DIR; @@ -41,8 +40,8 @@ afterEach(() => { // Simulates a process restart: a fresh guard has empty memory and no in-flight // claims, so any duplicate verdict must come from the persisted SQLite rows. -function restartFeishuDedup(): void { - testingHooks.resetFeishuDedupForTests(); +async function restartFeishuDedup(): Promise { + feishuDedupeState.reset(); } describe("Feishu claimable dedupe", () => { @@ -79,7 +78,7 @@ describe("Feishu claimable dedupe", () => { ).resolves.toBe("claimed"); releaseFeishuMessageProcessing("msg-3", "account-a"); - restartFeishuDedup(); + await restartFeishuDedup(); await expect( claimUnprocessedFeishuMessage({ messageId: "msg-3", namespace: "account-a" }), ).resolves.toBe("claimed"); @@ -90,7 +89,7 @@ describe("Feishu claimable dedupe", () => { finalizeFeishuMessageProcessing({ messageId: "msg-4", namespace: "account-a" }), ).resolves.toBe(true); - restartFeishuDedup(); + await restartFeishuDedup(); await expect( claimUnprocessedFeishuMessage({ messageId: "msg-4", namespace: "account-a" }), ).resolves.toBe("duplicate"); @@ -125,13 +124,13 @@ describe("Feishu claimable dedupe", () => { await expect(recordProcessedFeishuMessage("msg-6", "broadcast")).resolves.toBe(true); await expect(recordProcessedFeishuMessage("msg-6", "broadcast")).resolves.toBe(false); - restartFeishuDedup(); + await restartFeishuDedup(); await expect(recordProcessedFeishuMessage("msg-6", "broadcast")).resolves.toBe(false); }); it("warms memory from persisted plugin state", async () => { await expect(recordProcessedFeishuMessage("msg-7", "account-a")).resolves.toBe(true); - restartFeishuDedup(); + await restartFeishuDedup(); await expect(warmupDedupFromPluginState("account-a")).resolves.toBe(1); await expect(recordProcessedFeishuMessage("msg-7", "account-a")).resolves.toBe(false); @@ -141,7 +140,7 @@ describe("Feishu claimable dedupe", () => { vi.useFakeTimers(); vi.setSystemTime(1_000); await expect(recordProcessedFeishuMessage("msg-8", "account-a")).resolves.toBe(true); - restartFeishuDedup(); + await restartFeishuDedup(); vi.setSystemTime(1_000 + 24 * 60 * 60 * 1000 + 1); await expect(hasProcessedFeishuMessage("msg-8", "account-a")).resolves.toBe(false); diff --git a/extensions/feishu/src/dedup.ts b/extensions/feishu/src/dedup.ts index ebd19153cadd..9f18fcd270a0 100644 --- a/extensions/feishu/src/dedup.ts +++ b/extensions/feishu/src/dedup.ts @@ -3,34 +3,12 @@ // the same event once per bot, so handlers claim a dedupe key before // processing, commit once handling is dispatched, and release on retryable // failure so the event can be redelivered. -import { createClaimableDedupe } from "openclaw/plugin-sdk/persistent-dedupe"; - -// Persisted namespaces resolve to `feishu.dedup.` in the shared -// plugin-state SQLite store. Rows from the retired hand-rolled `dedup.*` store -// are dropped without import: replay protection is cache and rebuilds after -// upgrade, leaving only a brief unclean-shutdown redelivery gap. -const DEDUPE_NAMESPACE_PREFIX = "feishu.dedup"; -// Persistent TTL: 24 hours — survives restarts & WebSocket reconnects. -const DEDUP_TTL_MS = 24 * 60 * 60 * 1000; -const MEMORY_MAX_SIZE = 1_000; -const STORE_MAX_ENTRIES = 10_000; +import { feishuDedupeState } from "./dedup-state.js"; type FeishuDedupeLog = (...args: unknown[]) => void; type FeishuMessageClaim = "claimed" | "duplicate" | "inflight"; -function createFeishuDedupeGuard() { - return createClaimableDedupe({ - pluginId: "feishu", - namespacePrefix: DEDUPE_NAMESPACE_PREFIX, - ttlMs: DEDUP_TTL_MS, - memoryMaxSize: MEMORY_MAX_SIZE, - stateMaxEntries: STORE_MAX_ENTRIES, - }); -} - -let guard = createFeishuDedupeGuard(); - function dedupeKey(messageId: string | undefined | null): string { return messageId?.trim() ?? ""; } @@ -64,7 +42,8 @@ export async function claimUnprocessedFeishuMessage(params: { if (!key) { return "claimed"; } - return (await guard.claim(key, dedupeOptions(params.namespace, params.log))).kind; + return (await feishuDedupeState.guard.claim(key, dedupeOptions(params.namespace, params.log))) + .kind; } /** Drops an uncommitted claim so a failed handler can retry the message. */ @@ -74,7 +53,7 @@ export function releaseFeishuMessageProcessing( ): void { const key = dedupeKey(messageId); if (key) { - guard.release(key, { namespace }); + feishuDedupeState.guard.release(key, { namespace }); } } @@ -94,10 +73,10 @@ export async function finalizeFeishuMessageProcessing(params: { return false; } const options = dedupeOptions(params.namespace, params.log); - if (!params.claimHeld && (await guard.claim(key, options)).kind !== "claimed") { + if (!params.claimHeld && (await feishuDedupeState.guard.claim(key, options)).kind !== "claimed") { return false; } - return await guard.commit(key, options); + return await feishuDedupeState.guard.commit(key, options); } /** Records a handled message so restart/replay cannot dispatch it again; false when already recorded. */ @@ -110,7 +89,7 @@ export async function recordProcessedFeishuMessage( if (!key) { return false; } - return await guard.commit(key, dedupeOptions(namespace, log)); + return await feishuDedupeState.guard.commit(key, dedupeOptions(namespace, log)); } /** Forgets a recorded message so a retryable synthetic event can be handled on redelivery. */ @@ -123,7 +102,7 @@ export async function forgetProcessedFeishuMessage( if (!key) { return false; } - return await guard.forget(key, dedupeOptions(namespace, log)); + return await feishuDedupeState.guard.forget(key, dedupeOptions(namespace, log)); } /** Checks recency without claiming or recording. */ @@ -136,7 +115,7 @@ export async function hasProcessedFeishuMessage( if (!key) { return false; } - return await guard.hasRecent(key, dedupeOptions(namespace, log)); + return await feishuDedupeState.guard.hasRecent(key, dedupeOptions(namespace, log)); } /** Loads recent persisted entries into memory at account start; returns the loaded count. */ @@ -144,14 +123,7 @@ export async function warmupDedupFromPluginState( namespace: string, log?: FeishuDedupeLog, ): Promise { - return await guard.warmup(namespace, (error) => + return await feishuDedupeState.guard.warmup(namespace, (error) => log?.(`feishu-dedup: warmup persistent state error: ${String(error)}`), ); } - -export const testingHooks = { - /** Drops in-flight claims and process memory; persisted rows follow the test's state dir. */ - resetFeishuDedupForTests() { - guard = createFeishuDedupeGuard(); - }, -}; diff --git a/extensions/feishu/src/delivery-trace.test.ts b/extensions/feishu/src/delivery-trace.test.ts index 82f31b20f1ca..6773ca689c5e 100644 --- a/extensions/feishu/src/delivery-trace.test.ts +++ b/extensions/feishu/src/delivery-trace.test.ts @@ -17,6 +17,7 @@ import { withFetchPreconnect } from "openclaw/plugin-sdk/test-env"; import { afterAll, afterEach, describe, it, vi } from "vitest"; import { FeishuConfigSchema } from "./config-schema.js"; import type { ReplyPayload } from "./reply-dispatcher-runtime-api.js"; +import { streamingStartBackoffUntilByAccount } from "./reply-dispatcher-state.js"; import type { ResolvedFeishuAccount } from "./types.js"; type RecordedWireCall = Parameters[0]; @@ -137,10 +138,7 @@ vi.mock("./streaming-card.js", async (importOriginal) => { return { ...actual, FeishuStreamingSession: RecordingFeishuStreamingSession }; }); -import { - clearFeishuStreamingStartBackoffForTests, - createFeishuReplyDispatcher, -} from "./reply-dispatcher.js"; +import { createFeishuReplyDispatcher } from "./reply-dispatcher.js"; afterAll(() => { vi.doUnmock("./accounts.js"); @@ -156,7 +154,7 @@ afterEach(() => { traceState.cardKitFetch = null; traceState.dispatcherOptions = null; traceState.wireFaults = []; - clearFeishuStreamingStartBackoffForTests(); + streamingStartBackoffUntilByAccount.clear(); }); function jsonResponse(payload: unknown, status = 200, headers?: Record): Response { diff --git a/extensions/feishu/src/doctor.test.ts b/extensions/feishu/src/doctor.test.ts index ca08854ba5b1..c99055e71c4e 100644 --- a/extensions/feishu/src/doctor.test.ts +++ b/extensions/feishu/src/doctor.test.ts @@ -15,7 +15,9 @@ import { } from "openclaw/plugin-sdk/session-transcript-runtime"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../runtime-api.js"; -import { isFeishuSessionStoreKey, runFeishuDoctorSequence } from "./doctor.js"; +import { feishuDoctor } from "./doctor.js"; + +const runFeishuDoctorSequence = feishuDoctor.runConfigSequence!; type EnvSnapshot = { HOME?: string; @@ -155,13 +157,6 @@ describe("Feishu doctor state repair", () => { fs.rmSync(tempHome, { recursive: true, force: true }); }); - it("matches only Feishu channel session keys", () => { - expect(isFeishuSessionStoreKey("agent:main:feishu:direct:ou_user")).toBe(true); - expect(isFeishuSessionStoreKey("feishu:direct:ou_user")).toBe(true); - expect(isFeishuSessionStoreKey("agent:codex:acp:binding:feishu:default:abc123")).toBe(false); - expect(isFeishuSessionStoreKey("agent:main:discord:direct:user")).toBe(false); - }); - it("stays quiet for healthy Feishu state and transcripts", async () => { const feishuDedupDir = path.join(stateDir(), "feishu", "dedup"); fs.mkdirSync(feishuDedupDir, { recursive: true }); diff --git a/extensions/feishu/src/doctor.ts b/extensions/feishu/src/doctor.ts index c4b0bec1edad..cd864a8dd400 100644 --- a/extensions/feishu/src/doctor.ts +++ b/extensions/feishu/src/doctor.ts @@ -171,7 +171,7 @@ function formatFinding(finding: FeishuDoctorFinding): string { return exhaustive; } -export function isFeishuSessionStoreKey(key: string): boolean { +function isFeishuSessionStoreKey(key: string): boolean { const normalized = key.trim().toLowerCase(); return /^agent:[^:]+:feishu(?::|$)/.test(normalized) || /^feishu(?::|$)/.test(normalized); } @@ -935,7 +935,7 @@ function hasConfiguredFeishuChannel(cfg: OpenClawConfig): boolean { return Boolean(cfg.channels?.feishu); } -export async function runFeishuDoctorSequence(params: { +async function runFeishuDoctorSequence(params: { cfg: OpenClawConfig; env: NodeJS.ProcessEnv; shouldRepair: boolean; diff --git a/extensions/feishu/src/json-response.ts b/extensions/feishu/src/json-response.ts index c067b4db6d82..908e21b2c49a 100644 --- a/extensions/feishu/src/json-response.ts +++ b/extensions/feishu/src/json-response.ts @@ -2,7 +2,7 @@ import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http"; /** Feishu control-plane JSON responses are tiny; 16 MiB leaves ample headroom. */ -export const FEISHU_JSON_MAX_BYTES = 16 * 1024 * 1024; +const FEISHU_JSON_MAX_BYTES = 16 * 1024 * 1024; export async function readFeishuJsonResponse( response: Response, diff --git a/extensions/feishu/src/lifecycle.test-support.ts b/extensions/feishu/src/lifecycle.test-support.ts index bd5e0d229a5c..bf757a6c25fb 100644 --- a/extensions/feishu/src/lifecycle.test-support.ts +++ b/extensions/feishu/src/lifecycle.test-support.ts @@ -1,6 +1,6 @@ // Feishu plugin module implements lifecycle support behavior. import { vi, type Mock } from "vitest"; -import { testingHooks as dedupTestingHooks } from "./dedup.js"; +import { feishuDedupeState } from "./dedup-state.js"; type BoundConversation = { bindingId: string; @@ -90,7 +90,7 @@ export function getFeishuLifecycleTestMocks(): FeishuLifecycleTestMocks { } export function resetFeishuLifecycleTestMocks(): void { - dedupTestingHooks.resetFeishuDedupForTests(); + feishuDedupeState.reset(); for (const mock of Object.values(feishuLifecycleTestMocks)) { mock.mockReset(); } diff --git a/extensions/feishu/src/media.test.ts b/extensions/feishu/src/media.test.ts index ee407425bc13..37bf1aeea185 100644 --- a/extensions/feishu/src/media.test.ts +++ b/extensions/feishu/src/media.test.ts @@ -55,7 +55,6 @@ vi.mock("openclaw/plugin-sdk/media-runtime", async (importOriginal) => { }); let saveMessageResourceFeishu: typeof import("./media.js").saveMessageResourceFeishu; -let sanitizeFileNameForUpload: typeof import("./media.js").sanitizeFileNameForUpload; let sendMediaFeishu: typeof import("./media.js").sendMediaFeishu; let shouldSuppressFeishuTextForVoiceMedia: typeof import("./media.js").shouldSuppressFeishuTextForVoiceMedia; @@ -118,12 +117,8 @@ async function withIsolatedHome(run: () => Promise): Promise { describe("sendMediaFeishu msg_type routing", () => { beforeAll(async () => { - ({ - saveMessageResourceFeishu, - sanitizeFileNameForUpload, - sendMediaFeishu, - shouldSuppressFeishuTextForVoiceMedia, - } = await import("./media.js")); + ({ saveMessageResourceFeishu, sendMediaFeishu, shouldSuppressFeishuTextForVoiceMedia } = + await import("./media.js")); }); afterAll(() => { @@ -650,50 +645,6 @@ describe("sendMediaFeishu msg_type routing", () => { }); }); -describe("sanitizeFileNameForUpload", () => { - it("returns ASCII filenames unchanged", () => { - expect(sanitizeFileNameForUpload("report.pdf")).toBe("report.pdf"); - expect(sanitizeFileNameForUpload("my-file_v2.txt")).toBe("my-file_v2.txt"); - }); - - it("preserves Chinese characters", () => { - expect(sanitizeFileNameForUpload("测试文件.md")).toBe("测试文件.md"); - expect(sanitizeFileNameForUpload("武汉15座山登山信息汇总.csv")).toBe( - "武汉15座山登山信息汇总.csv", - ); - }); - - it("preserves em-dash and full-width brackets", () => { - expect(sanitizeFileNameForUpload("文件—说明(v2).pdf")).toBe("文件—说明(v2).pdf"); - }); - - it("preserves single quotes and parentheses", () => { - expect(sanitizeFileNameForUpload("文件'(test).txt")).toBe("文件'(test).txt"); - }); - - it("preserves filenames without extension", () => { - expect(sanitizeFileNameForUpload("测试文件")).toBe("测试文件"); - }); - - it("preserves mixed ASCII and non-ASCII", () => { - expect(sanitizeFileNameForUpload("Report_报告_2026.xlsx")).toBe("Report_报告_2026.xlsx"); - }); - - it("preserves emoji filenames", () => { - expect(sanitizeFileNameForUpload("report_😀.txt")).toBe("report_😀.txt"); - }); - - it("strips control characters", () => { - expect(sanitizeFileNameForUpload("bad\x00file.txt")).toBe("bad_file.txt"); - expect(sanitizeFileNameForUpload("inject\r\nheader.txt")).toBe("inject__header.txt"); - }); - - it("strips quotes and backslashes to prevent header injection", () => { - expect(sanitizeFileNameForUpload('file"name.txt')).toBe("file_name.txt"); - expect(sanitizeFileNameForUpload("file\\name.txt")).toBe("file_name.txt"); - }); -}); - describe("saveMessageResourceFeishu", () => { function httpStatusError(status: number): Error & { response: { status: number } } { return Object.assign(new Error(`Request failed with status code ${status}`), { diff --git a/extensions/feishu/src/media.ts b/extensions/feishu/src/media.ts index 2d3e64abb80d..bf41653cf537 100644 --- a/extensions/feishu/src/media.ts +++ b/extensions/feishu/src/media.ts @@ -462,7 +462,7 @@ async function uploadImageFeishu(params: { * NOT decode percent-encoding — so encoded filenames appeared as garbled text * in chat (regression in v2026.3.2). */ -export function sanitizeFileNameForUpload(fileName: string): string { +function sanitizeFileNameForUpload(fileName: string): string { return fileName.replace(/[\p{Cc}"\\]/gu, "_"); } diff --git a/extensions/feishu/src/monitor-defaults.ts b/extensions/feishu/src/monitor-defaults.ts new file mode 100644 index 000000000000..159ac873fb2f --- /dev/null +++ b/extensions/feishu/src/monitor-defaults.ts @@ -0,0 +1,64 @@ +type WebhookRateLimitDefaults = { + windowMs: number; + maxRequests: number; + maxTrackedKeys: number; +}; + +type WebhookAnomalyDefaults = { + maxTrackedKeys: number; + ttlMs: number; + logEvery: number; +}; + +const FEISHU_WEBHOOK_RATE_LIMIT_FALLBACK_DEFAULTS: WebhookRateLimitDefaults = { + windowMs: 60_000, + maxRequests: 120, + maxTrackedKeys: 4_096, +}; + +const FEISHU_WEBHOOK_ANOMALY_FALLBACK_DEFAULTS: WebhookAnomalyDefaults = { + maxTrackedKeys: 4_096, + ttlMs: 6 * 60 * 60_000, + logEvery: 25, +}; + +function coercePositiveInt(value: unknown, fallback: number): number { + if (typeof value !== "number" || !Number.isFinite(value)) { + return fallback; + } + const normalized = Math.floor(value); + return normalized > 0 ? normalized : fallback; +} + +export function resolveFeishuWebhookRateLimitDefaults(defaults: unknown): WebhookRateLimitDefaults { + const resolved = defaults as Partial | null | undefined; + return { + windowMs: coercePositiveInt( + resolved?.windowMs, + FEISHU_WEBHOOK_RATE_LIMIT_FALLBACK_DEFAULTS.windowMs, + ), + maxRequests: coercePositiveInt( + resolved?.maxRequests, + FEISHU_WEBHOOK_RATE_LIMIT_FALLBACK_DEFAULTS.maxRequests, + ), + maxTrackedKeys: coercePositiveInt( + resolved?.maxTrackedKeys, + FEISHU_WEBHOOK_RATE_LIMIT_FALLBACK_DEFAULTS.maxTrackedKeys, + ), + }; +} + +export function resolveFeishuWebhookAnomalyDefaults(defaults: unknown): WebhookAnomalyDefaults { + const resolved = defaults as Partial | null | undefined; + return { + maxTrackedKeys: coercePositiveInt( + resolved?.maxTrackedKeys, + FEISHU_WEBHOOK_ANOMALY_FALLBACK_DEFAULTS.maxTrackedKeys, + ), + ttlMs: coercePositiveInt(resolved?.ttlMs, FEISHU_WEBHOOK_ANOMALY_FALLBACK_DEFAULTS.ttlMs), + logEvery: coercePositiveInt( + resolved?.logEvery, + FEISHU_WEBHOOK_ANOMALY_FALLBACK_DEFAULTS.logEvery, + ), + }; +} diff --git a/extensions/feishu/src/monitor-rate-limit-key.ts b/extensions/feishu/src/monitor-rate-limit-key.ts new file mode 100644 index 000000000000..ac9a5adc5af1 --- /dev/null +++ b/extensions/feishu/src/monitor-rate-limit-key.ts @@ -0,0 +1,19 @@ +function normalizeFeishuWebhookRateLimitClient(clientIp: string | undefined): string { + if (!clientIp) { + return "unknown"; + } + if (clientIp === "::1" || clientIp.startsWith("127.")) { + return "loopback"; + } + return clientIp; +} + +export function buildFeishuWebhookRateLimitKey(params: { + accountId: string; + path: string; + clientIp?: string; +}): string { + return `${params.accountId}:${params.path}:${normalizeFeishuWebhookRateLimitClient( + params.clientIp, + )}`; +} diff --git a/extensions/feishu/src/monitor-startup-timeout.ts b/extensions/feishu/src/monitor-startup-timeout.ts new file mode 100644 index 000000000000..65445cc21445 --- /dev/null +++ b/extensions/feishu/src/monitor-startup-timeout.ts @@ -0,0 +1,18 @@ +import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime"; + +const FEISHU_STARTUP_BOT_INFO_TIMEOUT_DEFAULT_MS = 30_000; +const FEISHU_STARTUP_BOT_INFO_TIMEOUT_ENV = "OPENCLAW_FEISHU_STARTUP_PROBE_TIMEOUT_MS"; + +export function resolveStartupProbeTimeoutMs(env: NodeJS.ProcessEnv = process.env): number { + const raw = env[FEISHU_STARTUP_BOT_INFO_TIMEOUT_ENV]; + if (raw) { + const parsed = parseStrictPositiveInteger(raw); + if (parsed !== undefined) { + return parsed; + } + console.warn( + `[feishu] ${FEISHU_STARTUP_BOT_INFO_TIMEOUT_ENV}="${raw}" is invalid; using default ${FEISHU_STARTUP_BOT_INFO_TIMEOUT_DEFAULT_MS}ms`, + ); + } + return FEISHU_STARTUP_BOT_INFO_TIMEOUT_DEFAULT_MS; +} diff --git a/extensions/feishu/src/monitor.card-action.lifecycle.test-support.ts b/extensions/feishu/src/monitor.card-action.lifecycle.test-support.ts index ead917ed8d60..69b5bf90d010 100644 --- a/extensions/feishu/src/monitor.card-action.lifecycle.test-support.ts +++ b/extensions/feishu/src/monitor.card-action.lifecycle.test-support.ts @@ -2,7 +2,7 @@ import { createRuntimeEnv } from "openclaw/plugin-sdk/plugin-test-runtime"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import "./lifecycle.test-support.js"; -import { resetProcessedFeishuCardActionTokensForTests } from "./card-action.js"; +import { processedCardActions, resolvedCardActionChatTypes } from "./card-action-state.js"; import { createFeishuCardInteractionEnvelope } from "./card-interaction.js"; import { getFeishuLifecycleTestMocks, @@ -145,7 +145,8 @@ describe("Feishu card-action lifecycle", () => { vi.useRealTimers(); resetFeishuLifecycleTestMocks(); lastRuntime = createRuntimeEnv(); - resetProcessedFeishuCardActionTokensForTests(); + processedCardActions.clear(); + resolvedCardActionChatTypes.clear(); setFeishuLifecycleStateDir("openclaw-feishu-card-action"); createFeishuReplyDispatcherMock.mockReturnValue(createFeishuLifecycleReplyDispatcher()); @@ -182,7 +183,8 @@ describe("Feishu card-action lifecycle", () => { afterEach(() => { vi.useRealTimers(); - resetProcessedFeishuCardActionTokensForTests(); + processedCardActions.clear(); + resolvedCardActionChatTypes.clear(); restoreFeishuLifecycleStateDir(originalStateDir); }); diff --git a/extensions/feishu/src/monitor.cleanup.test-helpers.ts b/extensions/feishu/src/monitor.cleanup.test-helpers.ts new file mode 100644 index 000000000000..2ff9700ca1dc --- /dev/null +++ b/extensions/feishu/src/monitor.cleanup.test-helpers.ts @@ -0,0 +1,24 @@ +import { botNames, botOpenIds, httpServers, wsClients } from "./monitor.state.js"; + +export function cleanupFeishuMonitorStateForTests(): void { + for (const client of wsClients.values()) { + try { + client.close(); + } catch { + // Best-effort test cleanup. + } + } + wsClients.clear(); + + for (const server of httpServers.values()) { + try { + server.closeAllConnections(); + server.close(); + } catch { + // Best-effort test cleanup. + } + } + httpServers.clear(); + botOpenIds.clear(); + botNames.clear(); +} diff --git a/extensions/feishu/src/monitor.cleanup.test.ts b/extensions/feishu/src/monitor.cleanup.test.ts index d52dc9332743..83e78e6190cf 100644 --- a/extensions/feishu/src/monitor.cleanup.test.ts +++ b/extensions/feishu/src/monitor.cleanup.test.ts @@ -1,17 +1,19 @@ // Feishu tests cover monitor.cleanup plugin behavior. import type { Server } from "node:http"; import { afterAll, afterEach, describe, expect, it, vi } from "vitest"; +import { cleanupFeishuMonitorStateForTests } from "./monitor.cleanup.test-helpers.js"; import { botNames, botOpenIds, - FEISHU_HTTP_SERVER_CLOSE_TIMEOUT_MS, + closeTrackedFeishuHttpServer, httpServers, setFeishuBotIdentityState, - stopFeishuMonitorState, wsClients, } from "./monitor.state.js"; import type { ResolvedFeishuAccount } from "./types.js"; +const FEISHU_HTTP_SERVER_CLOSE_TIMEOUT_MS = 5_000; + const createFeishuWSClientMock = vi.hoisted(() => vi.fn()); vi.mock("./client.js", () => ({ @@ -87,9 +89,9 @@ function firstWsCallbacks(): { onError?: (err: Error) => void } { return callbacks as { onError?: (err: Error) => void }; } -afterEach(async () => { +afterEach(() => { vi.useRealTimers(); - await stopFeishuMonitorState(); + cleanupFeishuMonitorStateForTests(); vi.clearAllMocks(); }); @@ -408,49 +410,6 @@ describe("feishu websocket cleanup", () => { ); }); - it("closes targeted websocket clients during stop cleanup", async () => { - const alphaClient = createWsClient(); - const betaClient = createWsClient(); - - wsClients.set("alpha", alphaClient as never); - wsClients.set("beta", betaClient as never); - botOpenIds.set("alpha", "ou_alpha"); - botOpenIds.set("beta", "ou_beta"); - botNames.set("alpha", "Alpha"); - botNames.set("beta", "Beta"); - - await stopFeishuMonitorState("alpha"); - - expect(alphaClient.close).toHaveBeenCalledTimes(1); - expect(betaClient.close).not.toHaveBeenCalled(); - expect(wsClients.has("alpha")).toBe(false); - expect(wsClients.has("beta")).toBe(true); - expect(botOpenIds.has("alpha")).toBe(false); - expect(botOpenIds.has("beta")).toBe(true); - expect(botNames.has("alpha")).toBe(false); - expect(botNames.has("beta")).toBe(true); - }); - - it("closes all websocket clients during global stop cleanup", async () => { - const alphaClient = createWsClient(); - const betaClient = createWsClient(); - - wsClients.set("alpha", alphaClient as never); - wsClients.set("beta", betaClient as never); - botOpenIds.set("alpha", "ou_alpha"); - botOpenIds.set("beta", "ou_beta"); - botNames.set("alpha", "Alpha"); - botNames.set("beta", "Beta"); - - await stopFeishuMonitorState(); - - expect(alphaClient.close).toHaveBeenCalledTimes(1); - expect(betaClient.close).toHaveBeenCalledTimes(1); - expect(wsClients.size).toBe(0); - expect(botOpenIds.size).toBe(0); - expect(botNames.size).toBe(0); - }); - it("keeps targeted HTTP server state until close completes", async () => { const { server, close, closeAllConnections, finishClose } = createHttpServerMock(); @@ -458,7 +417,7 @@ describe("feishu websocket cleanup", () => { botOpenIds.set("alpha", "ou_alpha"); botNames.set("alpha", "Alpha"); - const stopPromise = stopFeishuMonitorState("alpha"); + const stopPromise = closeTrackedFeishuHttpServer("alpha", server); await Promise.resolve(); expect(close).toHaveBeenCalledTimes(1); @@ -482,7 +441,7 @@ describe("feishu websocket cleanup", () => { httpServers.set("alpha", oldServer.server); setFeishuBotIdentityState("alpha", { botOpenId: "ou_old", botName: "Old" }); - const stopPromise = stopFeishuMonitorState("alpha"); + const stopPromise = closeTrackedFeishuHttpServer("alpha", oldServer.server); await Promise.resolve(); setFeishuBotIdentityState("alpha", { botOpenId: "ou_new", botName: "New" }); @@ -495,7 +454,7 @@ describe("feishu websocket cleanup", () => { expect(botOpenIds.get("alpha")).toBe("ou_new"); expect(botNames.get("alpha")).toBe("New"); - const cleanupPromise = stopFeishuMonitorState("alpha"); + const cleanupPromise = closeTrackedFeishuHttpServer("alpha", replacementServer.server); await Promise.resolve(); replacementServer.finishClose(); await cleanupPromise; @@ -507,7 +466,7 @@ describe("feishu websocket cleanup", () => { httpServers.set("alpha", oldServer.server); setFeishuBotIdentityState("alpha", { botOpenId: "ou_old", botName: "Old" }); - const stopPromise = stopFeishuMonitorState("alpha"); + const stopPromise = closeTrackedFeishuHttpServer("alpha", oldServer.server); await Promise.resolve(); setFeishuBotIdentityState("alpha", { botOpenId: "ou_new", botName: "New" }); @@ -518,8 +477,6 @@ describe("feishu websocket cleanup", () => { expect(httpServers.has("alpha")).toBe(false); expect(botOpenIds.get("alpha")).toBe("ou_new"); expect(botNames.get("alpha")).toBe("New"); - - await stopFeishuMonitorState("alpha"); }); it("forces targeted HTTP server cleanup after the close timeout", async () => { @@ -530,7 +487,7 @@ describe("feishu websocket cleanup", () => { botOpenIds.set("alpha", "ou_alpha"); botNames.set("alpha", "Alpha"); - const stopPromise = stopFeishuMonitorState("alpha"); + const stopPromise = closeTrackedFeishuHttpServer("alpha", server); await Promise.resolve(); expect(close).toHaveBeenCalledTimes(1); @@ -548,30 +505,4 @@ describe("feishu websocket cleanup", () => { expect(botOpenIds.has("alpha")).toBe(false); expect(botNames.has("alpha")).toBe(false); }); - - it("preserves replacement HTTP state after delayed global cleanup", async () => { - const oldServer = createHttpServerMock(); - const replacementServer = createHttpServerMock(); - - httpServers.set("alpha", oldServer.server); - setFeishuBotIdentityState("alpha", { botOpenId: "ou_old", botName: "Old" }); - - const stopPromise = stopFeishuMonitorState(); - await Promise.resolve(); - - setFeishuBotIdentityState("alpha", { botOpenId: "ou_new", botName: "New" }); - httpServers.set("alpha", replacementServer.server); - - oldServer.finishClose(); - await stopPromise; - - expect(httpServers.get("alpha")).toBe(replacementServer.server); - expect(botOpenIds.get("alpha")).toBe("ou_new"); - expect(botNames.get("alpha")).toBe("New"); - - const cleanupPromise = stopFeishuMonitorState("alpha"); - await Promise.resolve(); - replacementServer.finishClose(); - await cleanupPromise; - }); }); diff --git a/extensions/feishu/src/monitor.startup.test.ts b/extensions/feishu/src/monitor.startup.test.ts index f77a784e41d8..db5232fe0b03 100644 --- a/extensions/feishu/src/monitor.startup.test.ts +++ b/extensions/feishu/src/monitor.startup.test.ts @@ -2,8 +2,9 @@ import { createNonExitingRuntimeEnv } from "openclaw/plugin-sdk/plugin-test-runtime"; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import type { ClawdbotConfig } from "../runtime-api.js"; -import { monitorFeishuProvider, stopFeishuMonitor } from "./monitor.js"; -import { resolveStartupProbeTimeoutMs } from "./monitor.startup.js"; +import { resolveStartupProbeTimeoutMs } from "./monitor-startup-timeout.js"; +import { cleanupFeishuMonitorStateForTests } from "./monitor.cleanup.test-helpers.js"; +import { monitorFeishuProvider } from "./monitor.js"; const probeFeishuMock = vi.hoisted(() => vi.fn()); @@ -54,8 +55,8 @@ async function waitForStartedAccount(started: string[], accountId: string) { ); } -afterEach(async () => { - await stopFeishuMonitor(); +afterEach(() => { + cleanupFeishuMonitorStateForTests(); }); afterAll(() => { diff --git a/extensions/feishu/src/monitor.startup.ts b/extensions/feishu/src/monitor.startup.ts index 345bd4f5a534..b75a7f468c27 100644 --- a/extensions/feishu/src/monitor.startup.ts +++ b/extensions/feishu/src/monitor.startup.ts @@ -1,27 +1,10 @@ // Feishu plugin module implements monitor.startup behavior. -import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime"; import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { RuntimeEnv } from "../runtime-api.js"; +import { resolveStartupProbeTimeoutMs } from "./monitor-startup-timeout.js"; import { probeFeishu } from "./probe.js"; import type { ResolvedFeishuAccount } from "./types.js"; -const FEISHU_STARTUP_BOT_INFO_TIMEOUT_DEFAULT_MS = 30_000; -const FEISHU_STARTUP_BOT_INFO_TIMEOUT_ENV = "OPENCLAW_FEISHU_STARTUP_PROBE_TIMEOUT_MS"; - -export function resolveStartupProbeTimeoutMs(env: NodeJS.ProcessEnv = process.env): number { - const raw = env[FEISHU_STARTUP_BOT_INFO_TIMEOUT_ENV]; - if (raw) { - const parsed = parseStrictPositiveInteger(raw); - if (parsed !== undefined) { - return parsed; - } - console.warn( - `[feishu] ${FEISHU_STARTUP_BOT_INFO_TIMEOUT_ENV}="${raw}" is invalid; using default ${FEISHU_STARTUP_BOT_INFO_TIMEOUT_DEFAULT_MS}ms`, - ); - } - return FEISHU_STARTUP_BOT_INFO_TIMEOUT_DEFAULT_MS; -} - const FEISHU_STARTUP_BOT_INFO_TIMEOUT_MS = resolveStartupProbeTimeoutMs(); type FetchBotOpenIdOptions = { diff --git a/extensions/feishu/src/monitor.state.defaults.test.ts b/extensions/feishu/src/monitor.state.defaults.test.ts index 9593cc1bbe8d..a4e65ee8b9ff 100644 --- a/extensions/feishu/src/monitor.state.defaults.test.ts +++ b/extensions/feishu/src/monitor.state.defaults.test.ts @@ -1,18 +1,18 @@ // Feishu tests cover monitor.stateefaults plugin behavior. import { describe, expect, it } from "vitest"; import { - resolveFeishuWebhookAnomalyDefaultsForTest, - resolveFeishuWebhookRateLimitDefaultsForTest, -} from "./monitor.state.js"; + resolveFeishuWebhookAnomalyDefaults, + resolveFeishuWebhookRateLimitDefaults, +} from "./monitor-defaults.js"; describe("feishu monitor state defaults", () => { it("falls back to hard defaults when sdk defaults are missing", () => { - expect(resolveFeishuWebhookRateLimitDefaultsForTest(undefined)).toEqual({ + expect(resolveFeishuWebhookRateLimitDefaults(undefined)).toEqual({ windowMs: 60_000, maxRequests: 120, maxTrackedKeys: 4_096, }); - expect(resolveFeishuWebhookAnomalyDefaultsForTest(undefined)).toEqual({ + expect(resolveFeishuWebhookAnomalyDefaults(undefined)).toEqual({ maxTrackedKeys: 4_096, ttlMs: 21_600_000, logEvery: 25, @@ -21,7 +21,7 @@ describe("feishu monitor state defaults", () => { it("keeps valid sdk values and repairs invalid fields", () => { expect( - resolveFeishuWebhookRateLimitDefaultsForTest({ + resolveFeishuWebhookRateLimitDefaults({ windowMs: 45_000, maxRequests: 0, maxTrackedKeys: -1, @@ -33,7 +33,7 @@ describe("feishu monitor state defaults", () => { }); expect( - resolveFeishuWebhookAnomalyDefaultsForTest({ + resolveFeishuWebhookAnomalyDefaults({ maxTrackedKeys: 2048, ttlMs: Number.NaN, logEvery: 10, diff --git a/extensions/feishu/src/monitor.state.ts b/extensions/feishu/src/monitor.state.ts index 36795acca022..bc61daa5ff3e 100644 --- a/extensions/feishu/src/monitor.state.ts +++ b/extensions/feishu/src/monitor.state.ts @@ -1,6 +1,10 @@ // Feishu plugin module implements monitor.state behavior. import * as http from "node:http"; import type * as Lark from "@larksuiteoapi/node-sdk"; +import { + resolveFeishuWebhookAnomalyDefaults, + resolveFeishuWebhookRateLimitDefaults, +} from "./monitor-defaults.js"; import { createFixedWindowRateLimiter, createWebhookAnomalyTracker, @@ -20,85 +24,16 @@ const botIdentityRevisions = new Map(); export const FEISHU_WEBHOOK_MAX_BODY_BYTES = 64 * 1024; export const FEISHU_WEBHOOK_BODY_TIMEOUT_MS = 5_000; -export const FEISHU_HTTP_SERVER_CLOSE_TIMEOUT_MS = 5_000; - -type WebhookRateLimitDefaults = { - windowMs: number; - maxRequests: number; - maxTrackedKeys: number; -}; - -type WebhookAnomalyDefaults = { - maxTrackedKeys: number; - ttlMs: number; - logEvery: number; -}; +const FEISHU_HTTP_SERVER_CLOSE_TIMEOUT_MS = 5_000; type BotIdentitySnapshot = { revision: number; }; -const FEISHU_WEBHOOK_RATE_LIMIT_FALLBACK_DEFAULTS: WebhookRateLimitDefaults = { - windowMs: 60_000, - maxRequests: 120, - maxTrackedKeys: 4_096, -}; - -const FEISHU_WEBHOOK_ANOMALY_FALLBACK_DEFAULTS: WebhookAnomalyDefaults = { - maxTrackedKeys: 4_096, - ttlMs: 6 * 60 * 60_000, - logEvery: 25, -}; - -function coercePositiveInt(value: unknown, fallback: number): number { - if (typeof value !== "number" || !Number.isFinite(value)) { - return fallback; - } - const normalized = Math.floor(value); - return normalized > 0 ? normalized : fallback; -} - -export function resolveFeishuWebhookRateLimitDefaultsForTest( - defaults: unknown, -): WebhookRateLimitDefaults { - const resolved = defaults as Partial | null | undefined; - return { - windowMs: coercePositiveInt( - resolved?.windowMs, - FEISHU_WEBHOOK_RATE_LIMIT_FALLBACK_DEFAULTS.windowMs, - ), - maxRequests: coercePositiveInt( - resolved?.maxRequests, - FEISHU_WEBHOOK_RATE_LIMIT_FALLBACK_DEFAULTS.maxRequests, - ), - maxTrackedKeys: coercePositiveInt( - resolved?.maxTrackedKeys, - FEISHU_WEBHOOK_RATE_LIMIT_FALLBACK_DEFAULTS.maxTrackedKeys, - ), - }; -} - -export function resolveFeishuWebhookAnomalyDefaultsForTest( - defaults: unknown, -): WebhookAnomalyDefaults { - const resolved = defaults as Partial | null | undefined; - return { - maxTrackedKeys: coercePositiveInt( - resolved?.maxTrackedKeys, - FEISHU_WEBHOOK_ANOMALY_FALLBACK_DEFAULTS.maxTrackedKeys, - ), - ttlMs: coercePositiveInt(resolved?.ttlMs, FEISHU_WEBHOOK_ANOMALY_FALLBACK_DEFAULTS.ttlMs), - logEvery: coercePositiveInt( - resolved?.logEvery, - FEISHU_WEBHOOK_ANOMALY_FALLBACK_DEFAULTS.logEvery, - ), - }; -} - -const feishuWebhookRateLimitDefaults = resolveFeishuWebhookRateLimitDefaultsForTest( +const feishuWebhookRateLimitDefaults = resolveFeishuWebhookRateLimitDefaults( WEBHOOK_RATE_LIMIT_DEFAULTS_FROM_SDK, ); -const feishuWebhookAnomalyDefaults = resolveFeishuWebhookAnomalyDefaultsForTest( +const feishuWebhookAnomalyDefaults = resolveFeishuWebhookAnomalyDefaults( WEBHOOK_ANOMALY_COUNTER_DEFAULTS_FROM_SDK, ); @@ -114,17 +49,6 @@ const feishuWebhookAnomalyTracker = createWebhookAnomalyTracker({ logEvery: feishuWebhookAnomalyDefaults.logEvery, }); -function closeWsClient(client: Lark.WSClient | undefined): void { - if (!client) { - return; - } - try { - client.close(); - } catch { - /* Best-effort cleanup */ - } -} - function readBotIdentityRevision(accountId: string): number { return botIdentityRevisions.get(accountId) ?? 0; } @@ -137,14 +61,6 @@ function captureBotIdentitySnapshot(accountId: string): BotIdentitySnapshot { return { revision: readBotIdentityRevision(accountId) }; } -function captureBotIdentitySnapshots(): Array<[accountId: string, snapshot: BotIdentitySnapshot]> { - const accountIds = new Set([...botOpenIds.keys(), ...botNames.keys()]); - return Array.from(accountIds, (accountId): [string, BotIdentitySnapshot] => [ - accountId, - captureBotIdentitySnapshot(accountId), - ]); -} - function clearFeishuBotIdentityStateIfUnchanged( accountId: string, snapshot: BotIdentitySnapshot, @@ -229,33 +145,6 @@ export async function closeTrackedFeishuHttpServer( } } -async function closeTrackedHttpServers( - entries: Array<[accountId: string, server: http.Server]>, -): Promise { - const results = await Promise.allSettled( - entries.map(([accountId, server]) => closeTrackedFeishuHttpServer(accountId, server)), - ); - const rejected = results.find( - (result): result is PromiseRejectedResult => result.status === "rejected", - ); - if (rejected) { - throw rejected.reason; - } -} - -export function clearFeishuWebhookRateLimitStateForTest(): void { - feishuWebhookRateLimiter.clear(); - feishuWebhookAnomalyTracker.clear(); -} - -export function getFeishuWebhookRateLimitStateSizeForTest(): number { - return feishuWebhookRateLimiter.size(); -} - -export function isWebhookRateLimitedForTest(key: string, nowMs: number): boolean { - return feishuWebhookRateLimiter.isRateLimited(key, nowMs); -} - export function recordWebhookStatus( runtime: RuntimeEnv | undefined, accountId: string, @@ -270,32 +159,3 @@ export function recordWebhookStatus( `feishu[${accountId}]: webhook anomaly path=${path} status=${statusCode} count=${count}`, }); } - -export async function stopFeishuMonitorState(accountId?: string): Promise { - if (accountId) { - closeWsClient(wsClients.get(accountId)); - wsClients.delete(accountId); - const server = httpServers.get(accountId); - if (server) { - await closeTrackedFeishuHttpServer(accountId, server); - return; - } - clearFeishuBotIdentityState(accountId); - return; - } - - for (const client of wsClients.values()) { - closeWsClient(client); - } - wsClients.clear(); - const identitySnapshots = captureBotIdentitySnapshots(); - try { - await closeTrackedHttpServers([...httpServers.entries()]); - } finally { - for (const [identityAccountId, snapshot] of identitySnapshots) { - if (!httpServers.has(identityAccountId)) { - clearFeishuBotIdentityStateIfUnchanged(identityAccountId, snapshot); - } - } - } -} diff --git a/extensions/feishu/src/monitor.transport.ts b/extensions/feishu/src/monitor.transport.ts index 57e7d57b59ff..65b9cfc1cf0c 100644 --- a/extensions/feishu/src/monitor.transport.ts +++ b/extensions/feishu/src/monitor.transport.ts @@ -5,6 +5,7 @@ import * as Lark from "@larksuiteoapi/node-sdk"; import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import { waitForAbortableDelay } from "./async.js"; import { createFeishuWSClient } from "./client.js"; +import { buildFeishuWebhookRateLimitKey } from "./monitor-rate-limit-key.js"; import { applyBasicWebhookRequestGuards, installRequestBodyLimitGuard, @@ -100,28 +101,6 @@ function respondText(res: http.ServerResponse, statusCode: number, body: string) res.end(body); } -function normalizeFeishuWebhookRateLimitClient(clientIp: string | undefined): string { - if (!clientIp) { - return "unknown"; - } - if (clientIp === "::1" || clientIp.startsWith("127.")) { - return "loopback"; - } - return clientIp; -} - -function buildFeishuWebhookRateLimitKey(params: { - accountId: string; - path: string; - clientIp?: string; -}): string { - return `${params.accountId}:${params.path}:${normalizeFeishuWebhookRateLimitClient( - params.clientIp, - )}`; -} - -export { buildFeishuWebhookRateLimitKey as buildFeishuWebhookRateLimitKeyForTest }; - function getFeishuWsReconnectDelayMs(attempt: number): number { return Math.min( FEISHU_WS_RECONNECT_INITIAL_DELAY_MS * 2 ** Math.max(0, attempt - 1), diff --git a/extensions/feishu/src/monitor.ts b/extensions/feishu/src/monitor.ts index 625c2a9b7f49..7b8aae3e7d08 100644 --- a/extensions/feishu/src/monitor.ts +++ b/extensions/feishu/src/monitor.ts @@ -3,12 +3,6 @@ import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; import type { ClawdbotConfig, PluginRuntime, RuntimeEnv } from "../runtime-api.js"; import { listEnabledFeishuAccounts, resolveFeishuRuntimeAccount } from "./accounts.js"; import { fetchBotIdentityForMonitor } from "./monitor.startup.js"; -import { - clearFeishuWebhookRateLimitStateForTest, - getFeishuWebhookRateLimitStateSizeForTest, - isWebhookRateLimitedForTest, - stopFeishuMonitorState, -} from "./monitor.state.js"; type MonitorFeishuOpts = { config?: ClawdbotConfig; @@ -43,12 +37,6 @@ export type FeishuStatusSink = (patch: { const loadMonitorAccountRuntime = createLazyRuntimeModule(() => import("./monitor.account.js")); -export { - clearFeishuWebhookRateLimitStateForTest, - getFeishuWebhookRateLimitStateSizeForTest, - isWebhookRateLimitedForTest, -}; - export async function monitorFeishuProvider(opts: MonitorFeishuOpts = {}): Promise { const cfg = opts.config; if (!cfg) { @@ -119,7 +107,3 @@ export async function monitorFeishuProvider(opts: MonitorFeishuOpts = {}): Promi await Promise.all(monitorPromises); } - -export async function stopFeishuMonitor(accountId?: string): Promise { - await stopFeishuMonitorState(accountId); -} diff --git a/extensions/feishu/src/monitor.webhook-e2e.test.ts b/extensions/feishu/src/monitor.webhook-e2e.test.ts index 6a298da6d8d8..496ddb74849a 100644 --- a/extensions/feishu/src/monitor.webhook-e2e.test.ts +++ b/extensions/feishu/src/monitor.webhook-e2e.test.ts @@ -27,7 +27,8 @@ vi.mock("./client.js", async () => { vi.mock("./runtime.js", () => createFeishuRuntimeMockModule()); -import { monitorFeishuProvider, stopFeishuMonitor } from "./monitor.js"; +import { cleanupFeishuMonitorStateForTests } from "./monitor.cleanup.test-helpers.js"; +import { monitorFeishuProvider } from "./monitor.js"; import { httpServers } from "./monitor.state.js"; beforeAll(async () => { @@ -72,8 +73,8 @@ async function postSignedPayload(url: string, payload: Record) }); } -afterEach(async () => { - await stopFeishuMonitor(); +afterEach(() => { + cleanupFeishuMonitorStateForTests(); }); afterAll(() => { diff --git a/extensions/feishu/src/monitor.webhook-security.test.ts b/extensions/feishu/src/monitor.webhook-security.test.ts index afb5ab2bcddf..830c9b6df189 100644 --- a/extensions/feishu/src/monitor.webhook-security.test.ts +++ b/extensions/feishu/src/monitor.webhook-security.test.ts @@ -39,15 +39,12 @@ vi.mock("./monitor.state.js", async (importOriginal) => { }); import type { RuntimeEnv } from "../runtime-api.js"; +import { buildFeishuWebhookRateLimitKey } from "./monitor-rate-limit-key.js"; import { resolveRequestClientIp } from "./monitor-transport-runtime-api.js"; -import { - clearFeishuWebhookRateLimitStateForTest, - getFeishuWebhookRateLimitStateSizeForTest, - isWebhookRateLimitedForTest, - monitorFeishuProvider, - stopFeishuMonitor, -} from "./monitor.js"; -import { buildFeishuWebhookRateLimitKeyForTest, monitorWebhook } from "./monitor.transport.js"; +import { cleanupFeishuMonitorStateForTests } from "./monitor.cleanup.test-helpers.js"; +import { monitorFeishuProvider } from "./monitor.js"; +import { feishuWebhookRateLimiter } from "./monitor.state.js"; +import { monitorWebhook } from "./monitor.transport.js"; import type { ResolvedFeishuAccount } from "./types.js"; beforeAll(async () => { @@ -165,8 +162,8 @@ function resolveTestClientIp(remoteAddress: string | undefined): string | undefi } afterEach(async () => { - clearFeishuWebhookRateLimitStateForTest(); - await stopFeishuMonitor(); + feishuWebhookRateLimiter.clear(); + cleanupFeishuMonitorStateForTests(); }); afterAll(() => { @@ -335,19 +332,19 @@ describe("Feishu webhook security hardening", () => { }; expect([ - buildFeishuWebhookRateLimitKeyForTest({ + buildFeishuWebhookRateLimitKey({ ...base, clientIp: resolveTestClientIp("127.0.0.1"), }), - buildFeishuWebhookRateLimitKeyForTest({ + buildFeishuWebhookRateLimitKey({ ...base, clientIp: resolveTestClientIp("127.0.0.42"), }), - buildFeishuWebhookRateLimitKeyForTest({ + buildFeishuWebhookRateLimitKey({ ...base, clientIp: resolveTestClientIp("::ffff:127.0.0.1"), }), - buildFeishuWebhookRateLimitKeyForTest({ + buildFeishuWebhookRateLimitKey({ ...base, clientIp: resolveTestClientIp("::1"), }), @@ -365,10 +362,10 @@ describe("Feishu webhook security hardening", () => { path: "/hook-rate-limit-key", }; - expect(buildFeishuWebhookRateLimitKeyForTest({ ...base, clientIp: "10.0.0.1" })).toBe( + expect(buildFeishuWebhookRateLimitKey({ ...base, clientIp: "10.0.0.1" })).toBe( "rate-limit-key:/hook-rate-limit-key:10.0.0.1", ); - expect(buildFeishuWebhookRateLimitKeyForTest(base)).toBe( + expect(buildFeishuWebhookRateLimitKey(base)).toBe( "rate-limit-key:/hook-rate-limit-key:unknown", ); }); @@ -376,19 +373,19 @@ describe("Feishu webhook security hardening", () => { it("caps tracked webhook rate-limit keys to prevent unbounded growth", () => { const now = 1_000_000; for (let i = 0; i < 4_500; i += 1) { - isWebhookRateLimitedForTest(`/feishu-rate-limit:key-${i}`, now); + feishuWebhookRateLimiter.isRateLimited(`/feishu-rate-limit:key-${i}`, now); } - expect(getFeishuWebhookRateLimitStateSizeForTest()).toBeLessThanOrEqual(4_096); + expect(feishuWebhookRateLimiter.size()).toBeLessThanOrEqual(4_096); }); it("prunes stale webhook rate-limit state after window elapses", () => { const now = 2_000_000; for (let i = 0; i < 100; i += 1) { - isWebhookRateLimitedForTest(`/feishu-rate-limit-stale:key-${i}`, now); + feishuWebhookRateLimiter.isRateLimited(`/feishu-rate-limit-stale:key-${i}`, now); } - expect(getFeishuWebhookRateLimitStateSizeForTest()).toBe(100); + expect(feishuWebhookRateLimiter.size()).toBe(100); - isWebhookRateLimitedForTest("/feishu-rate-limit-stale:fresh", now + 60_001); - expect(getFeishuWebhookRateLimitStateSizeForTest()).toBe(1); + feishuWebhookRateLimiter.isRateLimited("/feishu-rate-limit-stale:fresh", now + 60_001); + expect(feishuWebhookRateLimiter.size()).toBe(1); }); }); diff --git a/extensions/feishu/src/probe.test.ts b/extensions/feishu/src/probe.test.ts index 384125418b0d..0f8a3773b53b 100644 --- a/extensions/feishu/src/probe.test.ts +++ b/extensions/feishu/src/probe.test.ts @@ -1,6 +1,6 @@ // Feishu tests cover probe plugin behavior. -import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { clearProbeCache, FEISHU_PROBE_REQUEST_TIMEOUT_MS, probeFeishu } from "./probe.js"; +import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { probeFeishu } from "./probe.js"; const createFeishuClientMock = vi.hoisted(() => vi.fn()); @@ -8,7 +8,9 @@ vi.mock("./client.js", () => ({ createFeishuClient: createFeishuClientMock, })); -const DEFAULT_CREDS = { appId: "cli_123", appSecret: "secret" } as const; // pragma: allowlist secret +const FEISHU_PROBE_REQUEST_TIMEOUT_MS = 10_000; +const DEFAULT_CREDS = { accountId: "probe-0", appId: "cli_123", appSecret: "secret" }; // pragma: allowlist secret +let defaultAccountSequence = 0; const DEFAULT_SUCCESS_RESPONSE = { code: 0, data: { pingBotInfo: { botName: "TestBot", botID: "ou_abc123" } }, @@ -105,14 +107,11 @@ async function readSequentialDefaultProbePair() { describe("probeFeishu", () => { beforeEach(() => { - clearProbeCache(); + defaultAccountSequence += 1; + DEFAULT_CREDS.accountId = `probe-${defaultAccountSequence}`; vi.restoreAllMocks(); }); - afterEach(() => { - clearProbeCache(); - }); - it("returns error when credentials are missing", async () => { const result = await probeFeishu(); expect(result).toEqual({ ok: false, error: "missing credentials (appId, appSecret)" }); @@ -287,14 +286,6 @@ describe("probeFeishu", () => { expect(requestFn).toHaveBeenCalledTimes(2); }); - it("clearProbeCache forces fresh API call", async () => { - const requestFn = setupSuccessClient(); - - await expectFreshDefaultProbeAfter(requestFn, () => { - clearProbeCache(); - }); - }); - it("handles response with pingBotInfo in data", async () => { setupClient({ code: 0, diff --git a/extensions/feishu/src/probe.ts b/extensions/feishu/src/probe.ts index f0aa6bb024c6..7fc061b03dd7 100644 --- a/extensions/feishu/src/probe.ts +++ b/extensions/feishu/src/probe.ts @@ -17,7 +17,7 @@ const probeCache = new Map(); diff --git a/extensions/feishu/src/reply-dispatcher.test.ts b/extensions/feishu/src/reply-dispatcher.test.ts index cf0d0c7eda2b..d87804b3224d 100644 --- a/extensions/feishu/src/reply-dispatcher.test.ts +++ b/extensions/feishu/src/reply-dispatcher.test.ts @@ -102,10 +102,8 @@ vi.mock("./streaming-card.js", () => { }; }); -import { - clearFeishuStreamingStartBackoffForTests, - createFeishuReplyDispatcher, -} from "./reply-dispatcher.js"; +import { streamingStartBackoffUntilByAccount } from "./reply-dispatcher-state.js"; +import { createFeishuReplyDispatcher } from "./reply-dispatcher.js"; afterAll(() => { vi.doUnmock("./accounts.js"); @@ -138,7 +136,7 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { beforeEach(() => { vi.clearAllMocks(); - clearFeishuStreamingStartBackoffForTests(); + streamingStartBackoffUntilByAccount.clear(); streamingInstances.length = 0; sendMediaFeishuMock.mockResolvedValue(undefined); sendStructuredCardFeishuMock.mockResolvedValue(undefined); diff --git a/extensions/feishu/src/reply-dispatcher.ts b/extensions/feishu/src/reply-dispatcher.ts index d31d9b52f45e..4362d9a251f0 100644 --- a/extensions/feishu/src/reply-dispatcher.ts +++ b/extensions/feishu/src/reply-dispatcher.ts @@ -27,6 +27,7 @@ import { type ReplyPayload, type RuntimeEnv, } from "./reply-dispatcher-runtime-api.js"; +import { streamingStartBackoffUntilByAccount } from "./reply-dispatcher-state.js"; import { getFeishuRuntime } from "./runtime.js"; import { sendMessageFeishu, sendStructuredCardFeishu, type CardHeaderConfig } from "./send.js"; import { FeishuStreamingSession, mergeStreamingText } from "./streaming-card.js"; @@ -62,7 +63,6 @@ const MS_EPOCH_MIN = 1_000_000_000_000; const STREAMING_START_FAILURE_BACKOFF_MS = 60_000; const NO_VISIBLE_REPLY_FALLBACK_TEXT = "⚠️ This reply completed without visible content. The turn may have been interrupted; please retry or ask me to recover from recent context."; -const streamingStartBackoffUntilByAccount = new Map(); function isStreamingStartBackedOff(accountId: string, now = Date.now()): boolean { const backoffUntil = streamingStartBackoffUntilByAccount.get(accountId); @@ -88,10 +88,6 @@ function formatMediaFallbackText(text: string | undefined, mediaUrl: string): st return trimmedText ? `${trimmedText}\n\n${attachmentText}` : attachmentText; } -export function clearFeishuStreamingStartBackoffForTests() { - streamingStartBackoffUntilByAccount.clear(); -} - function normalizeEpochMs(timestamp: number | undefined): number | undefined { if (!Number.isFinite(timestamp) || timestamp === undefined || timestamp <= 0) { return undefined; diff --git a/extensions/feishu/src/send-rate-limit.ts b/extensions/feishu/src/send-rate-limit.ts new file mode 100644 index 000000000000..3e1afdce58a1 --- /dev/null +++ b/extensions/feishu/src/send-rate-limit.ts @@ -0,0 +1,24 @@ +import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; + +const FEISHU_SEND_RATE_LIMIT_CODES = new Set([230020, 11232]); + +export function getFeishuSendRateLimitCode(error: unknown): number | undefined { + if (!isRecord(error)) { + return undefined; + } + const response = isRecord(error.response) ? error.response : undefined; + if (response?.status === 429) { + return 429; + } + const data = isRecord(response?.data) ? response.data : undefined; + const code = data?.code; + return typeof code === "number" && FEISHU_SEND_RATE_LIMIT_CODES.has(code) ? code : undefined; +} + +export function getFeishuSendRateLimitCodeFromResponse(response: unknown): number | undefined { + if (!isRecord(response)) { + return undefined; + } + const code = response.code; + return typeof code === "number" && FEISHU_SEND_RATE_LIMIT_CODES.has(code) ? code : undefined; +} diff --git a/extensions/feishu/src/send.retry.test.ts b/extensions/feishu/src/send.retry.test.ts index 65f6b7b9e38e..4c04fff27599 100644 --- a/extensions/feishu/src/send.retry.test.ts +++ b/extensions/feishu/src/send.retry.test.ts @@ -6,11 +6,11 @@ */ import { describe, expect, it, vi } from "vitest"; +import { requestFeishuApi } from "./comment-shared.js"; import { getFeishuSendRateLimitCode, getFeishuSendRateLimitCodeFromResponse, - requestFeishuApi, -} from "./comment-shared.js"; +} from "./send-rate-limit.js"; /** Build an AxiosError-shaped object for a given Feishu body error code (HTTP 400). */ function axiosError(code: number) { diff --git a/extensions/feishu/src/send.test.ts b/extensions/feishu/src/send.test.ts index f5ef931f0d54..bc5ec856b111 100644 --- a/extensions/feishu/src/send.test.ts +++ b/extensions/feishu/src/send.test.ts @@ -1,7 +1,6 @@ // Feishu tests cover send plugin behavior. import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { ClawdbotConfig } from "../runtime-api.js"; -import { buildFeishuPostMessagePayload, buildMarkdownCard } from "./send.js"; const { mockConvertMarkdownTables, @@ -57,65 +56,24 @@ vi.mock("./runtime.js", () => ({ }), })); -let buildStructuredCard: typeof import("./send.js").buildStructuredCard; let editMessageFeishu: typeof import("./send.js").editMessageFeishu; let getMessageFeishu: typeof import("./send.js").getMessageFeishu; let listFeishuThreadMessages: typeof import("./send.js").listFeishuThreadMessages; let resolveFeishuCardTemplate: typeof import("./send.js").resolveFeishuCardTemplate; +let sendMarkdownCardFeishu: typeof import("./send.js").sendMarkdownCardFeishu; let sendMessageFeishu: typeof import("./send.js").sendMessageFeishu; - -describe("buildFeishuPostMessagePayload", () => { - it("prepends structured mention targets as native post at elements", () => { - const payload = buildFeishuPostMessagePayload({ - messageText: "hello **world**", - mentions: [ - { openId: "ou_alice", name: "Alice", key: "@_user_1" }, - { openId: " ou_bob ", name: " Bob ", key: "@_user_2" }, - ], - }); - - expect(payload.msgType).toBe("post"); - expect(JSON.parse(payload.content)).toEqual({ - zh_cn: { - content: [ - [ - { tag: "at", user_id: "ou_alice", user_name: "Alice" }, - { tag: "at", user_id: "ou_bob", user_name: "Bob" }, - { tag: "md", text: "hello **world**" }, - ], - ], - }, - }); - }); - - it("leaves body-supplied at tags literal in the markdown element", () => { - const payload = buildFeishuPostMessagePayload({ - messageText: 'please keep Body User literal', - mentions: [{ openId: "ou_target", name: "Target User", key: "@_user_1" }], - }); - - expect(JSON.parse(payload.content)).toEqual({ - zh_cn: { - content: [ - [ - { tag: "at", user_id: "ou_target", user_name: "Target User" }, - { tag: "md", text: 'please keep Body User literal' }, - ], - ], - }, - }); - }); -}); +let sendStructuredCardFeishu: typeof import("./send.js").sendStructuredCardFeishu; describe("getMessageFeishu", () => { beforeAll(async () => { ({ - buildStructuredCard, editMessageFeishu, getMessageFeishu, listFeishuThreadMessages, resolveFeishuCardTemplate, + sendMarkdownCardFeishu, sendMessageFeishu, + sendStructuredCardFeishu, } = await import("./send.js")); }); @@ -293,6 +251,52 @@ describe("getMessageFeishu", () => { }); }); + it.each([ + { + name: "structured", + send: () => + sendStructuredCardFeishu({ + cfg: {} as ClawdbotConfig, + to: "oc_card", + text: "hello", + header: { title: "Agent", template: "space lobster" }, + }), + expectedHeader: { + title: { tag: "plain_text", content: "Agent" }, + template: "blue", + }, + }, + { + name: "markdown", + send: () => + sendMarkdownCardFeishu({ cfg: {} as ClawdbotConfig, to: "oc_card", text: "hello" }), + expectedHeader: undefined, + }, + ])("sends $name cards with schema-2.0 width config", async ({ send, expectedHeader }) => { + const create = vi.fn().mockResolvedValue({ code: 0, data: { message_id: "om_card" } }); + mockCreateFeishuClient.mockReturnValue({ + im: { + message: { + create, + reply: vi.fn(), + get: mockClientGet, + list: mockClientList, + patch: mockClientPatch, + }, + }, + }); + + await send(); + + const request = create.mock.calls[0]?.[0] as { data?: { content?: string } } | undefined; + expect(JSON.parse(request?.data?.content ?? "null")).toEqual({ + schema: "2.0", + config: { width_mode: "fill" }, + body: { elements: [{ tag: "markdown", content: "hello" }] }, + ...(expectedHeader ? { header: expectedHeader } : {}), + }); + }); + it("extracts text content from interactive card elements", async () => { mockClientGet.mockResolvedValueOnce({ code: 0, @@ -742,53 +746,3 @@ describe("resolveFeishuCardTemplate", () => { expect(resolveFeishuCardTemplate("space lobster")).toBeUndefined(); }); }); - -function expectSchema2WidthConfig(card: unknown) { - const typedCard = card as { - config: { - width_mode?: string; - enable_forward?: boolean; - wide_screen_mode?: boolean; - }; - }; - - expect(typedCard.config.width_mode).toBe("fill"); - expect(typedCard.config.enable_forward).toBeUndefined(); - expect(typedCard.config.wide_screen_mode).toBeUndefined(); -} - -describe("Feishu card schema config", () => { - it.each([ - { - name: "structured card", - build: () => buildStructuredCard("hello"), - }, - { - name: "markdown card", - build: () => buildMarkdownCard("hello"), - }, - ])("$name uses schema-2.0 width config instead of legacy wide screen mode", ({ build }) => { - expectSchema2WidthConfig(build()); - }); -}); - -describe("buildStructuredCard", () => { - it("falls back to blue when the header template is unsupported", () => { - const card = buildStructuredCard("hello", { - header: { - title: "Agent", - template: "space lobster", - }, - }); - - expect(card).toEqual({ - schema: "2.0", - config: { width_mode: "fill" }, - body: { elements: [{ tag: "markdown", content: "hello" }] }, - header: { - title: { tag: "plain_text", content: "Agent" }, - template: "blue", - }, - }); - }); -}); diff --git a/extensions/feishu/src/send.ts b/extensions/feishu/src/send.ts index 1f770b319af7..80fd0a12156d 100644 --- a/extensions/feishu/src/send.ts +++ b/extensions/feishu/src/send.ts @@ -557,7 +557,7 @@ function buildFeishuPostMentionElements(mentions?: MentionTarget[]): FeishuPostM return elements; } -export function buildFeishuPostMessagePayload(params: { +function buildFeishuPostMessagePayload(params: { messageText: string; mentions?: MentionTarget[]; }): { @@ -706,7 +706,7 @@ export async function editMessageFeishu(params: { * Cards render markdown properly (code blocks, tables, links, etc.) * Uses schema 2.0 format for proper markdown rendering. */ -export function buildMarkdownCard(text: string): Record { +function buildMarkdownCard(text: string): Record { return { schema: "2.0", config: { @@ -735,7 +735,7 @@ export type CardHeaderConfig = { * Build a Feishu interactive card with optional header and note footer. * When header/note are omitted, behaves identically to buildMarkdownCard. */ -export function buildStructuredCard( +function buildStructuredCard( text: string, options?: { header?: CardHeaderConfig; diff --git a/extensions/feishu/src/streaming-card-send-mode.ts b/extensions/feishu/src/streaming-card-send-mode.ts new file mode 100644 index 000000000000..f2ee59426fc0 --- /dev/null +++ b/extensions/feishu/src/streaming-card-send-mode.ts @@ -0,0 +1,12 @@ +export function resolveStreamingCardSendMode(options?: { + replyToMessageId?: string; + rootId?: string; +}): "reply" | "root_create" | "create" { + if (options?.replyToMessageId) { + return "reply"; + } + if (options?.rootId) { + return "root_create"; + } + return "create"; +} diff --git a/extensions/feishu/src/streaming-card.test.ts b/extensions/feishu/src/streaming-card.test.ts index f471d199863e..2f025475cd26 100644 --- a/extensions/feishu/src/streaming-card.test.ts +++ b/extensions/feishu/src/streaming-card.test.ts @@ -3,13 +3,11 @@ import { createServer, type IncomingMessage, type ServerResponse } from "node:ht import type { LookupFn } from "openclaw/plugin-sdk/ssrf-runtime"; import { withFetchPreconnect } from "openclaw/plugin-sdk/test-env"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { FEISHU_JSON_MAX_BYTES } from "./json-response.js"; -import { - FeishuStreamingSession, - type FeishuStreamingFetch, - mergeStreamingText, - resolveStreamingCardSendMode, -} from "./streaming-card.js"; +import { resolveStreamingCardSendMode } from "./streaming-card-send-mode.js"; +import { FeishuStreamingSession, mergeStreamingText } from "./streaming-card.js"; + +const FEISHU_JSON_MAX_BYTES = 16 * 1024 * 1024; +type FeishuStreamingFetch = typeof fetch; type StreamingSessionState = { cardId: string; @@ -1188,10 +1186,5 @@ describe("resolveStreamingCardSendMode", () => { it("uses create mode when no reply routing fields are provided", () => { expect(resolveStreamingCardSendMode()).toBe("create"); - expect( - resolveStreamingCardSendMode({ - replyInThread: true, - }), - ).toBe("create"); }); }); diff --git a/extensions/feishu/src/streaming-card.ts b/extensions/feishu/src/streaming-card.ts index cd885a94a236..9c7674e97bea 100644 --- a/extensions/feishu/src/streaming-card.ts +++ b/extensions/feishu/src/streaming-card.ts @@ -15,6 +15,7 @@ import { getFeishuUserAgent } from "./client.js"; import { requestFeishuApi } from "./comment-shared.js"; import { readFeishuJsonResponse } from "./json-response.js"; import { resolveFeishuCardTemplate, type CardHeaderConfig } from "./send.js"; +import { resolveStreamingCardSendMode } from "./streaming-card-send-mode.js"; import type { FeishuDomain } from "./types.js"; type Credentials = { @@ -32,7 +33,7 @@ type CardState = { hasNote: boolean; }; -export type FeishuStreamingFetch = typeof fetch; +type FeishuStreamingFetch = typeof fetch; type FeishuStreamingDeps = { /** Override fetch for tests while preserving the real SSRF guard path. */ @@ -233,16 +234,6 @@ export function mergeStreamingText( return `${previous}${next}`; } -export function resolveStreamingCardSendMode(options?: StreamingStartOptions) { - if (options?.replyToMessageId) { - return "reply"; - } - if (options?.rootId) { - return "root_create"; - } - return "create"; -} - /** Streaming card session manager */ export class FeishuStreamingSession { private client: Client; diff --git a/extensions/feishu/src/typing-backoff.ts b/extensions/feishu/src/typing-backoff.ts new file mode 100644 index 000000000000..a40300bb2ab2 --- /dev/null +++ b/extensions/feishu/src/typing-backoff.ts @@ -0,0 +1,39 @@ +/** Feishu API codes that should trip the typing circuit breaker. */ +const FEISHU_BACKOFF_CODES = new Set([99991400, 99991403, 429]); + +export class FeishuBackoffError extends Error { + code: number; + + constructor(code: number) { + super(`Feishu API backoff: code ${code}`); + this.name = "FeishuBackoffError"; + this.code = code; + } +} + +export function isFeishuBackoffError(err: unknown): boolean { + if (typeof err !== "object" || err === null) { + return false; + } + + const response = (err as { response?: { status?: number; data?: { code?: number } } }).response; + if (response) { + if (response.status === 429) { + return true; + } + if (typeof response.data?.code === "number" && FEISHU_BACKOFF_CODES.has(response.data.code)) { + return true; + } + } + + const code = (err as { code?: number }).code; + return typeof code === "number" && FEISHU_BACKOFF_CODES.has(code); +} + +export function getBackoffCodeFromResponse(response: unknown): number | undefined { + if (typeof response !== "object" || response === null) { + return undefined; + } + const code = (response as { code?: number }).code; + return typeof code === "number" && FEISHU_BACKOFF_CODES.has(code) ? code : undefined; +} diff --git a/extensions/feishu/src/typing.test.ts b/extensions/feishu/src/typing.test.ts index 31b8da8182b9..9a5856730dce 100644 --- a/extensions/feishu/src/typing.test.ts +++ b/extensions/feishu/src/typing.test.ts @@ -1,6 +1,10 @@ // Feishu tests cover typing plugin behavior. import { describe, expect, it } from "vitest"; -import { isFeishuBackoffError, getBackoffCodeFromResponse, FeishuBackoffError } from "./typing.js"; +import { + FeishuBackoffError, + getBackoffCodeFromResponse, + isFeishuBackoffError, +} from "./typing-backoff.js"; describe("isFeishuBackoffError", () => { it("returns true for HTTP 429 (AxiosError shape)", () => { diff --git a/extensions/feishu/src/typing.ts b/extensions/feishu/src/typing.ts index 87b74f19e8c9..aa442b2af0b4 100644 --- a/extensions/feishu/src/typing.ts +++ b/extensions/feishu/src/typing.ts @@ -3,39 +3,17 @@ import type { ClawdbotConfig, RuntimeEnv } from "../runtime-api.js"; import { resolveFeishuRuntimeAccount } from "./accounts.js"; import { createFeishuClient } from "./client.js"; import { getFeishuRuntime } from "./runtime.js"; +import { + FeishuBackoffError, + getBackoffCodeFromResponse, + isFeishuBackoffError, +} from "./typing-backoff.js"; // Feishu emoji types for typing indicator // See: https://open.feishu.cn/document/server-docs/im-v1/message-reaction/emojis-introduce // Full list: https://github.com/go-lark/lark/blob/main/emoji.go const TYPING_EMOJI = "Typing"; // Typing indicator emoji -/** - * Feishu API error codes that indicate the caller should back off. - * These must propagate to the typing circuit breaker so the keepalive loop - * can trip and stop retrying. - * - * - 99991400: Rate limit (too many requests per second) - * - 99991403: Monthly API call quota exceeded - * - 429: Standard HTTP 429 returned as a Feishu SDK error code - * - * @see https://open.feishu.cn/document/server-docs/api-call-guide/generic-error-code - */ -const FEISHU_BACKOFF_CODES = new Set([99991400, 99991403, 429]); - -/** - * Custom error class for Feishu backoff conditions detected from non-throwing - * SDK responses. Carries a numeric `.code` so that `isFeishuBackoffError()` - * recognises it when the error is caught downstream. - */ -export class FeishuBackoffError extends Error { - code: number; - constructor(code: number) { - super(`Feishu API backoff: code ${code}`); - this.name = "FeishuBackoffError"; - this.code = code; - } -} - export type TypingIndicatorState = { messageId: string; reactionId: string | null; @@ -45,57 +23,6 @@ type FeishuMessageReactionCreateResponse = Awaited< ReturnType["im"]["messageReaction"]["create"]> >; -/** - * Check whether an error represents a rate-limit or quota-exceeded condition - * from the Feishu API that should stop the typing keepalive loop. - * - * Handles two shapes: - * 1. AxiosError with `response.status` and `response.data.code` - * 2. Feishu SDK error with a top-level `code` property - */ -export function isFeishuBackoffError(err: unknown): boolean { - if (typeof err !== "object" || err === null) { - return false; - } - - // AxiosError shape: err.response.status / err.response.data.code - const response = (err as { response?: { status?: number; data?: { code?: number } } }).response; - if (response) { - if (response.status === 429) { - return true; - } - if (typeof response.data?.code === "number" && FEISHU_BACKOFF_CODES.has(response.data.code)) { - return true; - } - } - - // Feishu SDK error shape: err.code - const code = (err as { code?: number }).code; - if (typeof code === "number" && FEISHU_BACKOFF_CODES.has(code)) { - return true; - } - - return false; -} - -/** - * Check whether a Feishu SDK response object contains a backoff error code. - * - * The Feishu SDK sometimes returns a normal response (no throw) with an - * API-level error code in the response body. This must be detected so the - * circuit breaker can trip. See codex review on #28157. - */ -export function getBackoffCodeFromResponse(response: unknown): number | undefined { - if (typeof response !== "object" || response === null) { - return undefined; - } - const code = (response as { code?: number }).code; - if (typeof code === "number" && FEISHU_BACKOFF_CODES.has(code)) { - return code; - } - return undefined; -} - /** * Add a typing indicator (reaction) to a message. * diff --git a/scripts/deadcode-exports.baseline.mjs b/scripts/deadcode-exports.baseline.mjs index d47615ed2a66..bee7fd7f193c 100644 --- a/scripts/deadcode-exports.baseline.mjs +++ b/scripts/deadcode-exports.baseline.mjs @@ -95,55 +95,6 @@ export const KNIP_UNUSED_EXPORT_BASELINE = [ "extensions/discord/src/voice-message.ts: VoiceMessageMetadata", "extensions/discord/src/voice/prompt.ts: DISCORD_VOICE_SPOKEN_OUTPUT_CONTRACT", "extensions/duckduckgo/src/config.ts: DEFAULT_DDG_SAFE_SEARCH", - "extensions/feishu/src/bot-content.ts: toMessageResourceType", - "extensions/feishu/src/bot-runtime-api.ts: buildAgentMediaPayload", - "extensions/feishu/src/bot-runtime-api.ts: filterSupplementalContextItems", - "extensions/feishu/src/bot.ts: buildBroadcastSessionKey", - "extensions/feishu/src/bot.ts: buildFeishuAgentBody", - "extensions/feishu/src/bot.ts: clearGroupNameCache", - "extensions/feishu/src/bot.ts: resolveBroadcastAgents", - "extensions/feishu/src/bot.ts: resolveGroupName", - "extensions/feishu/src/bot.ts: toMessageResourceType", - "extensions/feishu/src/card-action.ts: FeishuRetryableCardActionError", - "extensions/feishu/src/card-action.ts: resetProcessedFeishuCardActionTokensForTests", - "extensions/feishu/src/card-ux-launcher.ts: createQuickActionLauncherCard", - "extensions/feishu/src/card-ux-launcher.ts: isFeishuQuickActionMenuEventKey", - "extensions/feishu/src/client.ts: clearClientCache", - "extensions/feishu/src/client.ts: FEISHU_HTTP_TIMEOUT_ENV_VAR", - "extensions/feishu/src/client.ts: FEISHU_HTTP_TIMEOUT_MAX_MS", - "extensions/feishu/src/client.ts: FEISHU_HTTP_TIMEOUT_MS", - "extensions/feishu/src/client.ts: FEISHU_USER_AGENT", - "extensions/feishu/src/client.ts: pluginVersion", - "extensions/feishu/src/client.ts: setFeishuClientRuntimeForTest", - "extensions/feishu/src/comment-shared.ts: getFeishuSendRateLimitCode", - "extensions/feishu/src/comment-shared.ts: getFeishuSendRateLimitCodeFromResponse", - "extensions/feishu/src/comment-shared.ts: resolveCommentLinkedDocumentFromUrl", - "extensions/feishu/src/config-schema.ts: FeishuGroupSchema", - "extensions/feishu/src/dedup.ts: testingHooks", - "extensions/feishu/src/doctor.ts: isFeishuSessionStoreKey", - "extensions/feishu/src/doctor.ts: runFeishuDoctorSequence", - "extensions/feishu/src/json-response.ts: FEISHU_JSON_MAX_BYTES", - "extensions/feishu/src/media.ts: sanitizeFileNameForUpload", - "extensions/feishu/src/monitor.startup.ts: resolveStartupProbeTimeoutMs", - "extensions/feishu/src/monitor.state.ts: FEISHU_HTTP_SERVER_CLOSE_TIMEOUT_MS", - "extensions/feishu/src/monitor.state.ts: resolveFeishuWebhookAnomalyDefaultsForTest", - "extensions/feishu/src/monitor.state.ts: resolveFeishuWebhookRateLimitDefaultsForTest", - "extensions/feishu/src/monitor.transport.ts: buildFeishuWebhookRateLimitKeyForTest", - "extensions/feishu/src/monitor.ts: clearFeishuWebhookRateLimitStateForTest", - "extensions/feishu/src/monitor.ts: getFeishuWebhookRateLimitStateSizeForTest", - "extensions/feishu/src/monitor.ts: isWebhookRateLimitedForTest", - "extensions/feishu/src/monitor.ts: stopFeishuMonitor", - "extensions/feishu/src/probe.ts: clearProbeCache", - "extensions/feishu/src/probe.ts: FEISHU_PROBE_REQUEST_TIMEOUT_MS", - "extensions/feishu/src/reply-dispatcher.ts: clearFeishuStreamingStartBackoffForTests", - "extensions/feishu/src/send.ts: buildFeishuPostMessagePayload", - "extensions/feishu/src/send.ts: buildMarkdownCard", - "extensions/feishu/src/send.ts: buildStructuredCard", - "extensions/feishu/src/streaming-card.ts: FeishuStreamingFetch", - "extensions/feishu/src/streaming-card.ts: resolveStreamingCardSendMode", - "extensions/feishu/src/typing.ts: FeishuBackoffError", - "extensions/feishu/src/typing.ts: getBackoffCodeFromResponse", - "extensions/feishu/src/typing.ts: isFeishuBackoffError", "extensions/file-transfer/src/node-host/dir-fetch.ts: testing", "extensions/file-transfer/src/node-host/dir-list.ts: DIR_LIST_DEFAULT_MAX_ENTRIES", "extensions/file-transfer/src/node-host/dir-list.ts: DIR_LIST_HARD_MAX_ENTRIES",