diff --git a/CHANGELOG.md b/CHANGELOG.md index c7160360e6a..92536147b30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -117,6 +117,7 @@ Docs: https://docs.openclaw.ai - Telegram/Voice fallback reply chunking: apply reply reference, quote text, and inline buttons only to the first fallback text chunk when voice delivery is blocked, preventing over-quoted multi-chunk replies. Landed from contributor PR #31067 by @xdanger. Thanks @xdanger. - Feishu/System preview prompt leakage: stop enqueuing inbound Feishu message previews as system events so user preview text is not injected into later turns as trusted `System:` context. Landed from contributor PR #31209 by @stakeswky. Thanks @stakeswky. - Feishu/Multi-account + reply reliability: add `channels.feishu.defaultAccount` outbound routing support with schema validation, keep quoted-message extraction text-first (post/interactive/file placeholders instead of raw JSON), route Feishu video sends as `msg_type: "file"`, and avoid websocket event blocking by using non-blocking event handling in monitor dispatch. Landed from contributor PRs #29610, #30432, #30331, and #29501. Thanks @hclsys, @bmendonca3, @patrick-yingxi-pan, and @zwffff. +- Feishu/Typing replay suppression: skip typing indicators for stale replayed inbound messages after compaction using message-age checks with second/millisecond timestamp normalization, preventing old-message reaction floods while preserving typing for fresh messages. Landed from contributor PR #30709 by @arkyu2077. Thanks @arkyu2077. ## Unreleased diff --git a/extensions/feishu/src/reply-dispatcher.test.ts b/extensions/feishu/src/reply-dispatcher.test.ts index 7807168cc64..d4527cc2694 100644 --- a/extensions/feishu/src/reply-dispatcher.test.ts +++ b/extensions/feishu/src/reply-dispatcher.test.ts @@ -116,6 +116,59 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { expect(addTypingIndicatorMock).not.toHaveBeenCalled(); }); + it("skips typing indicator for stale replayed messages", async () => { + createFeishuReplyDispatcher({ + cfg: {} as never, + agentId: "agent", + runtime: {} as never, + chatId: "oc_chat", + replyToMessageId: "om_parent", + messageCreateTimeMs: Date.now() - 3 * 60_000, + }); + + const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0]; + await options.onReplyStart?.(); + + expect(addTypingIndicatorMock).not.toHaveBeenCalled(); + }); + + it("treats second-based timestamps as stale for typing suppression", async () => { + createFeishuReplyDispatcher({ + cfg: {} as never, + agentId: "agent", + runtime: {} as never, + chatId: "oc_chat", + replyToMessageId: "om_parent", + messageCreateTimeMs: Math.floor((Date.now() - 3 * 60_000) / 1000), + }); + + const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0]; + await options.onReplyStart?.(); + + expect(addTypingIndicatorMock).not.toHaveBeenCalled(); + }); + + it("keeps typing indicator for fresh messages", async () => { + createFeishuReplyDispatcher({ + cfg: {} as never, + agentId: "agent", + runtime: {} as never, + chatId: "oc_chat", + replyToMessageId: "om_parent", + messageCreateTimeMs: Date.now() - 30_000, + }); + + const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0]; + await options.onReplyStart?.(); + + expect(addTypingIndicatorMock).toHaveBeenCalledTimes(1); + expect(addTypingIndicatorMock).toHaveBeenCalledWith( + expect.objectContaining({ + messageId: "om_parent", + }), + ); + }); + 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 d508459f102..35440396c5a 100644 --- a/extensions/feishu/src/reply-dispatcher.ts +++ b/extensions/feishu/src/reply-dispatcher.ts @@ -25,6 +25,16 @@ function shouldUseCard(text: string): boolean { /** Maximum age (ms) for a message to receive a typing indicator reaction. * Messages older than this are likely replays after context compaction (#30418). */ const TYPING_INDICATOR_MAX_AGE_MS = 2 * 60_000; +const MS_EPOCH_MIN = 1_000_000_000_000; + +function normalizeEpochMs(timestamp: number | undefined): number | undefined { + if (!Number.isFinite(timestamp) || timestamp === undefined || timestamp <= 0) { + return undefined; + } + // Defensive normalization: some payloads use seconds, others milliseconds. + // Values below 1e12 are treated as epoch-seconds. + return timestamp < MS_EPOCH_MIN ? timestamp * 1000 : timestamp; +} export type CreateFeishuReplyDispatcherParams = { cfg: ClawdbotConfig; @@ -72,9 +82,10 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP } // Skip typing indicator for old messages — likely replays after context // compaction that would flood users with stale notifications (#30418). + const messageCreateTimeMs = normalizeEpochMs(params.messageCreateTimeMs); if ( - params.messageCreateTimeMs && - Date.now() - params.messageCreateTimeMs > TYPING_INDICATOR_MAX_AGE_MS + messageCreateTimeMs !== undefined && + Date.now() - messageCreateTimeMs > TYPING_INDICATOR_MAX_AGE_MS ) { return; }