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",