From 64c29c3755b7043db1fa30803c61854f6cf2224c Mon Sep 17 00:00:00 2001 From: Shadow Date: Fri, 20 Feb 2026 16:37:06 -0600 Subject: [PATCH] Discord: avoid reply spam on chunked sends --- CHANGELOG.md | 1 + src/discord/monitor/agent-components.ts | 1 + .../monitor/message-handler.process.ts | 1 + src/discord/monitor/reply-delivery.test.ts | 20 +++++++++++++ src/discord/monitor/reply-delivery.ts | 29 +++++++++++++++++-- 5 files changed, 49 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b4c811d2324..c652cf7dfcc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,7 @@ Docs: https://docs.openclaw.ai - Telegram/Streaming: always clean up draft previews even when dispatch throws before fallback handling, preventing orphaned preview messages during failed runs. (#19041) thanks @mudrii. - Telegram/Streaming: split reasoning and answer draft preview lanes to prevent cross-lane overwrites, and ignore literal `` tags inside inline/fenced code snippets so sample markup is not misrouted as reasoning. (#20774) Thanks @obviyus. - Telegram/Status reactions: refresh stall timers on repeated phase updates and honor ack-reaction scope when lifecycle reactions are enabled, preventing false stall emojis and unwanted group reactions. Thanks @wolly-tundracube and @thewilloftheshadow. +- Discord/Streaming: apply `replyToMode: first` only to the first Discord chunk so block-streamed replies do not spam mention pings. (#20726) Thanks @thewilloftheshadow for the report. - Discord/Gateway: handle close code 4014 (missing privileged gateway intents) without crashing the gateway. Thanks @thewilloftheshadow. - Security/Net: strip sensitive headers (`Authorization`, `Proxy-Authorization`, `Cookie`, `Cookie2`) on cross-origin redirects in `fetchWithSsrFGuard` to prevent credential forwarding across origin boundaries. (#20313) Thanks @afurm. diff --git a/src/discord/monitor/agent-components.ts b/src/discord/monitor/agent-components.ts index 0476b8fcd1e..ed0bb8824fe 100644 --- a/src/discord/monitor/agent-components.ts +++ b/src/discord/monitor/agent-components.ts @@ -887,6 +887,7 @@ async function dispatchDiscordComponentEvent(params: { rest: interaction.client.rest, runtime, replyToId, + replyToMode, textLimit, maxLinesPerMessage: ctx.discordConfig?.maxLinesPerMessage, tableMode, diff --git a/src/discord/monitor/message-handler.process.ts b/src/discord/monitor/message-handler.process.ts index e5b862283e0..0badfe48369 100644 --- a/src/discord/monitor/message-handler.process.ts +++ b/src/discord/monitor/message-handler.process.ts @@ -628,6 +628,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) rest: client.rest, runtime, replyToId, + replyToMode, textLimit, maxLinesPerMessage: discordConfig?.maxLinesPerMessage, tableMode, diff --git a/src/discord/monitor/reply-delivery.test.ts b/src/discord/monitor/reply-delivery.test.ts index 9093da63a57..ba47655c2b1 100644 --- a/src/discord/monitor/reply-delivery.test.ts +++ b/src/discord/monitor/reply-delivery.test.ts @@ -84,4 +84,24 @@ describe("deliverDiscordReply", () => { expect(sendVoiceMessageDiscordMock).toHaveBeenCalledTimes(1); expect(sendMessageDiscordMock).not.toHaveBeenCalled(); }); + + it("uses replyToId only for the first chunk when replyToMode is first", async () => { + await deliverDiscordReply({ + replies: [ + { + text: "1234567890", + }, + ], + target: "channel:789", + token: "token", + runtime, + textLimit: 5, + replyToId: "reply-1", + replyToMode: "first", + }); + + expect(sendMessageDiscordMock).toHaveBeenCalledTimes(2); + expect(sendMessageDiscordMock.mock.calls[0]?.[2]?.replyTo).toBe("reply-1"); + expect(sendMessageDiscordMock.mock.calls[1]?.[2]?.replyTo).toBeUndefined(); + }); }); diff --git a/src/discord/monitor/reply-delivery.ts b/src/discord/monitor/reply-delivery.ts index ae22e708665..0129dd63990 100644 --- a/src/discord/monitor/reply-delivery.ts +++ b/src/discord/monitor/reply-delivery.ts @@ -1,7 +1,7 @@ import type { RequestClient } from "@buape/carbon"; import type { ChunkMode } from "../../auto-reply/chunk.js"; import type { ReplyPayload } from "../../auto-reply/types.js"; -import type { MarkdownTableMode } from "../../config/types.base.js"; +import type { MarkdownTableMode, ReplyToMode } from "../../config/types.base.js"; import { convertMarkdownTables } from "../../markdown/tables.js"; import type { RuntimeEnv } from "../../runtime.js"; import { chunkDiscordTextWithMode } from "../chunk.js"; @@ -17,10 +17,29 @@ export async function deliverDiscordReply(params: { textLimit: number; maxLinesPerMessage?: number; replyToId?: string; + replyToMode?: ReplyToMode; tableMode?: MarkdownTableMode; chunkMode?: ChunkMode; }) { const chunkLimit = Math.min(params.textLimit, 2000); + const replyTo = params.replyToId?.trim() || undefined; + const replyToMode = params.replyToMode ?? "all"; + // replyToMode=first should only apply to the first physical send. + const replyOnce = replyToMode === "first"; + let replyUsed = false; + const resolveReplyTo = () => { + if (!replyTo) { + return undefined; + } + if (!replyOnce) { + return replyTo; + } + if (replyUsed) { + return undefined; + } + replyUsed = true; + return replyTo; + }; for (const payload of params.replies) { const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); const rawText = payload.text ?? ""; @@ -29,8 +48,6 @@ export async function deliverDiscordReply(params: { if (!text && mediaList.length === 0) { continue; } - const replyTo = params.replyToId?.trim() || undefined; - if (mediaList.length === 0) { const mode = params.chunkMode ?? "length"; const chunks = chunkDiscordTextWithMode(text, { @@ -46,6 +63,7 @@ export async function deliverDiscordReply(params: { if (!trimmed) { continue; } + const replyTo = resolveReplyTo(); await sendMessageDiscord(params.target, trimmed, { token: params.token, rest: params.rest, @@ -63,6 +81,7 @@ export async function deliverDiscordReply(params: { // Voice message path: audioAsVoice flag routes through sendVoiceMessageDiscord if (payload.audioAsVoice) { + const replyTo = resolveReplyTo(); await sendVoiceMessageDiscord(params.target, firstMedia, { token: params.token, rest: params.rest, @@ -71,6 +90,7 @@ export async function deliverDiscordReply(params: { }); // Voice messages cannot include text; send remaining text separately if present if (text.trim()) { + const replyTo = resolveReplyTo(); await sendMessageDiscord(params.target, text, { token: params.token, rest: params.rest, @@ -80,6 +100,7 @@ export async function deliverDiscordReply(params: { } // Additional media items are sent as regular attachments (voice is single-file only) for (const extra of mediaList.slice(1)) { + const replyTo = resolveReplyTo(); await sendMessageDiscord(params.target, "", { token: params.token, rest: params.rest, @@ -91,6 +112,7 @@ export async function deliverDiscordReply(params: { continue; } + const replyTo = resolveReplyTo(); await sendMessageDiscord(params.target, text, { token: params.token, rest: params.rest, @@ -99,6 +121,7 @@ export async function deliverDiscordReply(params: { replyTo, }); for (const extra of mediaList.slice(1)) { + const replyTo = resolveReplyTo(); await sendMessageDiscord(params.target, "", { token: params.token, rest: params.rest,