refactor(feishu): remove unused exports

This commit is contained in:
Peter Steinberger
2026-07-13 19:02:54 +01:00
parent 35a02653c8
commit eb60b2208c
63 changed files with 672 additions and 1214 deletions
@@ -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"),
+60
View File
@@ -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 <at user_id="...">name</at>. ` +
`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;
}
+21
View File
@@ -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<string, unknown>).broadcast;
if (!broadcast || typeof broadcast !== "object") {
return null;
}
const agents = (broadcast as Record<string, unknown>)[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;
}
+1 -1
View File
@@ -373,7 +373,7 @@ function parseMediaKeys(
}
}
export function toMessageResourceType(messageType: string): "image" | "file" {
function toMessageResourceType(messageType: string): "image" | "file" {
return messageType === "image" ? "image" : "file";
}
@@ -0,0 +1 @@
export const feishuGroupNameCache = new Map<string, { name: string; expiresAt: number }>();
+3 -2
View File
@@ -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 () => {
+87
View File
@@ -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<string | undefined> {
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;
}
+1 -6
View File
@@ -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";
+3 -2
View File
@@ -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",
+4 -23
View File
@@ -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 = {
+2 -22
View File
@@ -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(
+5 -186
View File
@@ -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<string, number>();
const PERMISSION_ERROR_COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes
const groupNameCache = new Map<string, { name: string; expiresAt: number }>();
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<string | undefined> {
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<string, unknown>).broadcast;
if (!broadcast || typeof broadcast !== "object") {
return null;
}
const agents = (broadcast as Record<string, unknown>)[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:<agentId>:<channel>:<peerKind>:<peerId>
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 <at user_id="...">name</at>. ` +
`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;
@@ -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 }
>();
+24 -57
View File
@@ -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<string, { value: "p2p" | "group"; expiresAt: number }>();
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;
}
}
+10 -38
View File
@@ -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 () => {
+2 -2
View File
@@ -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;
+2 -2
View File
@@ -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;
+9 -61
View File
@@ -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<Record<ProxyEnvKey, string | undefined>> = {};
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", () => {
+2 -34
View File
@@ -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<FeishuClientSdk>;
}): void {
feishuClientSdk = overrides?.sdk
? { ...defaultFeishuClientSdk, ...overrides.sdk }
: defaultFeishuClientSdk;
clearClientCache();
}
+12 -4
View File
@@ -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<typeof parseCommentContentElements>[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";
+5 -45
View File
@@ -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, string | undefined>): 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<T>(
request: () => Promise<T>,
errorPrefix: string,
@@ -329,7 +289,7 @@ function hasResolvedLinkedDocumentReference(link: ParsedCommentLinkedDocument):
);
}
export function resolveCommentLinkedDocumentFromUrl(params: {
function resolveCommentLinkedDocumentFromUrl(params: {
rawUrl: string;
currentDocument?: ParsedCommentDocumentRef;
}): ParsedCommentLinkedDocument {
+5 -3
View File
@@ -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", () => {
+1 -1
View File
@@ -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,
+23
View File
@@ -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();
},
};
+9 -10
View File
@@ -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<void> {
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);
+10 -38
View File
@@ -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.<namespace hash>` 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<number> {
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();
},
};
+3 -5
View File
@@ -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<WireRecorder["recordWireCall"]>[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<string, string>): Response {
+3 -8
View File
@@ -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 });
+2 -2
View File
@@ -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;
+1 -1
View File
@@ -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<T>(
response: Response,
@@ -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();
}
+2 -51
View File
@@ -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<T>(run: () => Promise<T>): Promise<T> {
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}`), {
+1 -1
View File
@@ -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, "_");
}
+64
View File
@@ -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<WebhookRateLimitDefaults> | 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<WebhookAnomalyDefaults> | 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,
),
};
}
@@ -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,
)}`;
}
@@ -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;
}
@@ -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);
});
@@ -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();
}
+11 -80
View File
@@ -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;
});
});
@@ -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(() => {
+1 -18
View File
@@ -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 = {
@@ -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,
+7 -147
View File
@@ -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<string, number>();
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<WebhookRateLimitDefaults> | 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<WebhookAnomalyDefaults> | 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<void> {
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<void> {
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);
}
}
}
}
+1 -22
View File
@@ -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),
-16
View File
@@ -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<void> {
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<void> {
await stopFeishuMonitorState(accountId);
}
@@ -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<string, unknown>)
});
}
afterEach(async () => {
await stopFeishuMonitor();
afterEach(() => {
cleanupFeishuMonitorStateForTests();
});
afterAll(() => {
@@ -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);
});
});
+7 -16
View File
@@ -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,
+1 -6
View File
@@ -17,7 +17,7 @@ const probeCache = new Map<string, { result: FeishuProbeResult; expiresAt: numbe
const PROBE_SUCCESS_TTL_MS = 10 * 60 * 1000; // 10 minutes
const PROBE_ERROR_TTL_MS = 60 * 1000; // 1 minute
const MAX_PROBE_CACHE_SIZE = 64;
export const FEISHU_PROBE_REQUEST_TIMEOUT_MS = 10_000;
const FEISHU_PROBE_REQUEST_TIMEOUT_MS = 10_000;
type ProbeFeishuOptions = {
timeoutMs?: number;
abortSignal?: AbortSignal;
@@ -174,8 +174,3 @@ export async function probeFeishu(
);
}
}
/** Clear the probe cache (for testing). */
export function clearProbeCache(): void {
probeCache.clear();
}
@@ -0,0 +1 @@
export const streamingStartBackoffUntilByAccount = new Map<string, number>();
@@ -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);
+1 -5
View File
@@ -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<string, number>();
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;
+24
View File
@@ -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;
}
+2 -2
View File
@@ -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) {
+50 -96
View File
@@ -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 <at user_id="ou_body">Body User</at> 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 <at user_id="ou_body">Body User</at> 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",
},
});
});
});
+3 -3
View File
@@ -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<string, unknown> {
function buildMarkdownCard(text: string): Record<string, unknown> {
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;
@@ -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";
}
+5 -12
View File
@@ -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");
});
});
+2 -11
View File
@@ -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;
+39
View File
@@ -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;
}
+5 -1
View File
@@ -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)", () => {
+5 -78
View File
@@ -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<ReturnType<typeof createFeishuClient>["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.
*
-49
View File
@@ -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",