mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
refactor(feishu): remove unused exports
This commit is contained in:
@@ -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"),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 }>();
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 }
|
||||
>();
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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}`), {
|
||||
|
||||
@@ -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, "_");
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)", () => {
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user