Discord: avoid reply spam on chunked sends

This commit is contained in:
Shadow 2026-02-20 16:37:06 -06:00
parent df002ef840
commit 64c29c3755
No known key found for this signature in database
5 changed files with 49 additions and 3 deletions

View File

@ -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 `<think>` 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.

View File

@ -887,6 +887,7 @@ async function dispatchDiscordComponentEvent(params: {
rest: interaction.client.rest,
runtime,
replyToId,
replyToMode,
textLimit,
maxLinesPerMessage: ctx.discordConfig?.maxLinesPerMessage,
tableMode,

View File

@ -628,6 +628,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
rest: client.rest,
runtime,
replyToId,
replyToMode,
textLimit,
maxLinesPerMessage: discordConfig?.maxLinesPerMessage,
tableMode,

View File

@ -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();
});
});

View File

@ -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,