diff --git a/CHANGELOG.md b/CHANGELOG.md index 67eaf9df997..fa651fe784d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ Docs: https://docs.openclaw.ai - Feishu/Group sender allowlist fallback: add global `channels.feishu.groupSenderAllowFrom` sender authorization for group chats, with per-group `groups..allowFrom` precedence and regression coverage for allow/block/precedence behavior. (#29174) Thanks @1MoreBuild. - Feishu/Docx append/write ordering: insert converted Docx blocks sequentially (single-block creates) so Feishu append/write preserves markdown block order instead of returning shuffled sections in asynchronous batch inserts. (#26172, #26022) Thanks @echoVic. - Feishu/Inbound media regression coverage: add explicit tests for message resource type mapping (`image` stays `image`, non-image maps to `file`) to prevent reintroducing unsupported Feishu `type=audio` fetches. (#16311, #8746) Thanks @Yaxuan42. +- Feishu/API quota controls: add `typingIndicator` and `resolveSenderNames` config flags (top-level and per-account) so operators can disable typing reactions and sender-name lookup requests while keeping default behavior unchanged. (#10513) Thanks @BigUncle. - Security/Feishu webhook ingress: bound unauthenticated webhook rate-limit state with stale-window pruning and a hard key cap to prevent unbounded pre-auth memory growth from rotating source keys. (#26050) Thanks @bmendonca3. - Security/Compaction audit: remove the post-compaction audit injection message. (#28507) Thanks @fuller-stack-dev and @vincentkoc. - Telegram/Reply media context: include replied media files in inbound context when replying to media, defer reply-media downloads to debounce flush, gate reply-media fetch behind DM authorization, and preserve replied media when non-vision sticker fallback runs (including cached-sticker paths). (#28488) Thanks @obviyus. diff --git a/docs/channels/feishu.md b/docs/channels/feishu.md index 0e7b79849df..077ddcf723a 100644 --- a/docs/channels/feishu.md +++ b/docs/channels/feishu.md @@ -224,6 +224,34 @@ If your tenant is on Lark (international), set the domain to `lark` (or a full d } ``` +### Quota optimization flags + +You can reduce Feishu API usage with two optional flags: + +- `typingIndicator` (default `true`): when `false`, skip typing reaction calls. +- `resolveSenderNames` (default `true`): when `false`, skip sender profile lookup calls. + +Set them at top level or per account: + +```json5 +{ + channels: { + feishu: { + typingIndicator: false, + resolveSenderNames: false, + accounts: { + main: { + appId: "cli_xxx", + appSecret: "xxx", + typingIndicator: true, + resolveSenderNames: false, + }, + }, + }, + }, +} +``` + --- ## Step 3: Start + test diff --git a/extensions/feishu/src/bot.test.ts b/extensions/feishu/src/bot.test.ts index 26164d4ecf2..3fb679cc8da 100644 --- a/extensions/feishu/src/bot.test.ts +++ b/extensions/feishu/src/bot.test.ts @@ -256,6 +256,37 @@ describe("handleFeishuMessage command authorization", () => { expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(1); }); + it("skips sender-name lookup when resolveSenderNames is false", async () => { + const cfg: ClawdbotConfig = { + channels: { + feishu: { + dmPolicy: "open", + allowFrom: ["*"], + resolveSenderNames: false, + }, + }, + } as ClawdbotConfig; + + const event: FeishuMessageEvent = { + sender: { + sender_id: { + open_id: "ou-attacker", + }, + }, + message: { + message_id: "msg-skip-sender-lookup", + chat_id: "oc-dm", + chat_type: "p2p", + message_type: "text", + content: JSON.stringify({ text: "hello" }), + }, + }; + + await dispatchMessage({ cfg, event }); + + expect(mockCreateFeishuClient).not.toHaveBeenCalled(); + }); + it("creates pairing request and drops unauthorized DMs in pairing mode", async () => { mockShouldComputeCommandAuthorized.mockReturnValue(false); mockReadAllowFromStore.mockResolvedValue([]); diff --git a/extensions/feishu/src/bot.ts b/extensions/feishu/src/bot.ts index 13c50b69042..8ce16d28a92 100644 --- a/extensions/feishu/src/bot.ts +++ b/extensions/feishu/src/bot.ts @@ -771,23 +771,26 @@ export async function handleFeishuMessage(params: { } // Resolve sender display name (best-effort) so the agent can attribute messages correctly. - const senderResult = await resolveFeishuSenderName({ - account, - senderId: ctx.senderOpenId, - log, - }); - if (senderResult.name) ctx = { ...ctx, senderName: senderResult.name }; - - // Track permission error to inform agent later (with cooldown to avoid repetition) + // Optimization: skip if disabled to save API quota (Feishu free tier limit). let permissionErrorForAgent: PermissionError | undefined; - if (senderResult.permissionError) { - const appKey = account.appId ?? "default"; - const now = Date.now(); - const lastNotified = permissionErrorNotifiedAt.get(appKey) ?? 0; + if (feishuCfg?.resolveSenderNames ?? true) { + const senderResult = await resolveFeishuSenderName({ + account, + senderId: ctx.senderOpenId, + log, + }); + if (senderResult.name) ctx = { ...ctx, senderName: senderResult.name }; - if (now - lastNotified > PERMISSION_ERROR_COOLDOWN_MS) { - permissionErrorNotifiedAt.set(appKey, now); - permissionErrorForAgent = senderResult.permissionError; + // Track permission error to inform agent later (with cooldown to avoid repetition) + if (senderResult.permissionError) { + const appKey = account.appId ?? "default"; + const now = Date.now(); + const lastNotified = permissionErrorNotifiedAt.get(appKey) ?? 0; + + if (now - lastNotified > PERMISSION_ERROR_COOLDOWN_MS) { + permissionErrorNotifiedAt.set(appKey, now); + permissionErrorForAgent = senderResult.permissionError; + } } } diff --git a/extensions/feishu/src/config-schema.test.ts b/extensions/feishu/src/config-schema.test.ts index ab8f09612e4..73f41614a9f 100644 --- a/extensions/feishu/src/config-schema.test.ts +++ b/extensions/feishu/src/config-schema.test.ts @@ -117,3 +117,24 @@ describe("FeishuConfigSchema replyInThread", () => { expect(result.accounts?.main?.replyInThread).toBe("enabled"); }); }); + +describe("FeishuConfigSchema optimization flags", () => { + it("defaults top-level typingIndicator and resolveSenderNames to true", () => { + const result = FeishuConfigSchema.parse({}); + expect(result.typingIndicator).toBe(true); + expect(result.resolveSenderNames).toBe(true); + }); + + it("accepts account-level optimization flags", () => { + const result = FeishuConfigSchema.parse({ + accounts: { + main: { + typingIndicator: false, + resolveSenderNames: false, + }, + }, + }); + expect(result.accounts?.main?.typingIndicator).toBe(false); + expect(result.accounts?.main?.resolveSenderNames).toBe(false); + }); +}); diff --git a/extensions/feishu/src/config-schema.ts b/extensions/feishu/src/config-schema.ts index 6fd60c179e1..8fd19a1ae50 100644 --- a/extensions/feishu/src/config-schema.ts +++ b/extensions/feishu/src/config-schema.ts @@ -162,6 +162,8 @@ const FeishuSharedConfigShape = { tools: FeishuToolsConfigSchema, replyInThread: ReplyInThreadSchema, reactionNotifications: ReactionNotificationModeSchema, + typingIndicator: z.boolean().optional(), + resolveSenderNames: z.boolean().optional(), }; /** @@ -205,6 +207,9 @@ export const FeishuConfigSchema = z topicSessionMode: TopicSessionModeSchema, // Dynamic agent creation for DM users dynamicAgentCreation: DynamicAgentCreationSchema, + // Optimization flags + typingIndicator: z.boolean().optional().default(true), + resolveSenderNames: z.boolean().optional().default(true), // Multi-account configuration accounts: z.record(z.string(), FeishuAccountConfigSchema.optional()).optional(), }) diff --git a/extensions/feishu/src/reply-dispatcher.test.ts b/extensions/feishu/src/reply-dispatcher.test.ts index b1da983054b..7807168cc64 100644 --- a/extensions/feishu/src/reply-dispatcher.test.ts +++ b/extensions/feishu/src/reply-dispatcher.test.ts @@ -8,6 +8,8 @@ const sendMediaFeishuMock = vi.hoisted(() => vi.fn()); const createFeishuClientMock = vi.hoisted(() => vi.fn()); const resolveReceiveIdTypeMock = vi.hoisted(() => vi.fn()); const createReplyDispatcherWithTypingMock = vi.hoisted(() => vi.fn()); +const addTypingIndicatorMock = vi.hoisted(() => vi.fn(async () => ({ messageId: "om_msg" }))); +const removeTypingIndicatorMock = vi.hoisted(() => vi.fn(async () => {})); const streamingInstances = vi.hoisted(() => [] as any[]); vi.mock("./accounts.js", () => ({ resolveFeishuAccount: resolveFeishuAccountMock })); @@ -19,6 +21,10 @@ vi.mock("./send.js", () => ({ vi.mock("./media.js", () => ({ sendMediaFeishu: sendMediaFeishuMock })); vi.mock("./client.js", () => ({ createFeishuClient: createFeishuClientMock })); vi.mock("./targets.js", () => ({ resolveReceiveIdType: resolveReceiveIdTypeMock })); +vi.mock("./typing.js", () => ({ + addTypingIndicator: addTypingIndicatorMock, + removeTypingIndicator: removeTypingIndicatorMock, +})); vi.mock("./streaming-card.js", () => ({ FeishuStreamingSession: class { active = false; @@ -83,6 +89,33 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { }); }); + it("skips typing indicator when account typingIndicator is disabled", async () => { + resolveFeishuAccountMock.mockReturnValue({ + accountId: "main", + appId: "app_id", + appSecret: "app_secret", + domain: "feishu", + config: { + renderMode: "auto", + streaming: true, + typingIndicator: false, + }, + }); + + createFeishuReplyDispatcher({ + cfg: {} as never, + agentId: "agent", + runtime: {} as never, + chatId: "oc_chat", + replyToMessageId: "om_parent", + }); + + const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0]; + await options.onReplyStart?.(); + + expect(addTypingIndicatorMock).not.toHaveBeenCalled(); + }); + it("keeps auto mode plain text on non-streaming send path", async () => { createFeishuReplyDispatcher({ cfg: {} as never, diff --git a/extensions/feishu/src/reply-dispatcher.ts b/extensions/feishu/src/reply-dispatcher.ts index faff6b25ff6..bd59003aad9 100644 --- a/extensions/feishu/src/reply-dispatcher.ts +++ b/extensions/feishu/src/reply-dispatcher.ts @@ -56,6 +56,10 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP let typingState: TypingIndicatorState | null = null; const typingCallbacks = createTypingCallbacks({ start: async () => { + // Check if typing indicator is enabled (default: true) + if (!(account.config.typingIndicator ?? true)) { + return; + } if (!replyToMessageId) { return; }