diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c761bac4a5..ff3cb120efd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Feishu/Duplicate replies: suppress same-target reply dispatch when message-tool sends use generic provider metadata (`provider: "message"`) and normalize `lark`/`feishu` provider aliases during duplicate-target checks, preventing double-delivery in Feishu sessions. (#31526) - Pairing/AllowFrom account fallback: handle omitted `accountId` values in `readChannelAllowFromStore` and `readChannelAllowFromStoreSync` as `default`, while preserving legacy unscoped allowFrom merges for default-account flows. Thanks @Sid-Qin and @vincentkoc. - Agents/Subagent announce cleanup: keep completion-message runs pending while descendants settle, add a 30 minute hard-expiry backstop to avoid indefinite pending state, and keep retry bookkeeping resumable across deferred wakes. (#23970) Thanks @tyler6204. - BlueBubbles/Message metadata: harden send response ID extraction, include sender identity in DM context, and normalize inbound `message_id` selection to avoid duplicate ID metadata. (#23970) Thanks @tyler6204. diff --git a/src/auto-reply/reply/agent-runner-payloads.test.ts b/src/auto-reply/reply/agent-runner-payloads.test.ts index 9b62db984e8..138efd8e49d 100644 --- a/src/auto-reply/reply/agent-runner-payloads.test.ts +++ b/src/auto-reply/reply/agent-runner-payloads.test.ts @@ -86,6 +86,34 @@ describe("buildReplyPayloads media filter integration", () => { expect(replyPayloads).toHaveLength(0); }); + it("suppresses same-target replies when message tool target provider is generic", () => { + const { replyPayloads } = buildReplyPayloads({ + ...baseParams, + payloads: [{ text: "hello world!" }], + messageProvider: "heartbeat", + originatingChannel: "feishu", + originatingTo: "ou_abc123", + messagingToolSentTexts: ["different message"], + messagingToolSentTargets: [{ tool: "message", provider: "message", to: "ou_abc123" }], + }); + + expect(replyPayloads).toHaveLength(0); + }); + + it("suppresses same-target replies when target provider is channel alias", () => { + const { replyPayloads } = buildReplyPayloads({ + ...baseParams, + payloads: [{ text: "hello world!" }], + messageProvider: "heartbeat", + originatingChannel: "feishu", + originatingTo: "ou_abc123", + messagingToolSentTexts: ["different message"], + messagingToolSentTargets: [{ tool: "message", provider: "lark", to: "ou_abc123" }], + }); + + expect(replyPayloads).toHaveLength(0); + }); + it("does not suppress same-target replies when accountId differs", () => { const { replyPayloads } = buildReplyPayloads({ ...baseParams, diff --git a/src/auto-reply/reply/reply-payloads.ts b/src/auto-reply/reply/reply-payloads.ts index a408e942a2d..2c620e7320c 100644 --- a/src/auto-reply/reply/reply-payloads.ts +++ b/src/auto-reply/reply/reply-payloads.ts @@ -1,5 +1,6 @@ import { isMessagingToolDuplicate } from "../../agents/pi-embedded-helpers.js"; import type { MessagingToolSend } from "../../agents/pi-embedded-runner.js"; +import { normalizeChannelId } from "../../channels/plugins/index.js"; import type { ReplyToMode } from "../../config/types.js"; import { normalizeTargetForProvider } from "../../infra/outbound/target-normalization.js"; import { normalizeOptionalAccountId } from "../../routing/account-id.js"; @@ -144,13 +145,30 @@ export function filterMessagingToolMediaDuplicates(params: { }); } +const PROVIDER_ALIAS_MAP: Record = { + lark: "feishu", +}; + +function normalizeProviderForComparison(value?: string): string | undefined { + const trimmed = value?.trim(); + if (!trimmed) { + return undefined; + } + const lowered = trimmed.toLowerCase(); + const normalizedChannel = normalizeChannelId(trimmed); + if (normalizedChannel) { + return normalizedChannel; + } + return PROVIDER_ALIAS_MAP[lowered] ?? lowered; +} + export function shouldSuppressMessagingToolReplies(params: { messageProvider?: string; messagingToolSentTargets?: MessagingToolSend[]; originatingTo?: string; accountId?: string; }): boolean { - const provider = params.messageProvider?.trim().toLowerCase(); + const provider = normalizeProviderForComparison(params.messageProvider); if (!provider) { return false; } @@ -164,13 +182,16 @@ export function shouldSuppressMessagingToolReplies(params: { return false; } return sentTargets.some((target) => { - if (!target?.provider) { + const targetProvider = normalizeProviderForComparison(target?.provider); + if (!targetProvider) { return false; } - if (target.provider.trim().toLowerCase() !== provider) { + const isGenericMessageProvider = targetProvider === "message"; + if (!isGenericMessageProvider && targetProvider !== provider) { return false; } - const targetKey = normalizeTargetForProvider(provider, target.to); + const targetNormalizationProvider = isGenericMessageProvider ? provider : targetProvider; + const targetKey = normalizeTargetForProvider(targetNormalizationProvider, target.to); if (!targetKey) { return false; }