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
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: 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/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. - 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. - 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. - 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, rest: interaction.client.rest,
runtime, runtime,
replyToId, replyToId,
replyToMode,
textLimit, textLimit,
maxLinesPerMessage: ctx.discordConfig?.maxLinesPerMessage, maxLinesPerMessage: ctx.discordConfig?.maxLinesPerMessage,
tableMode, tableMode,

View File

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

View File

@@ -84,4 +84,24 @@ describe("deliverDiscordReply", () => {
expect(sendVoiceMessageDiscordMock).toHaveBeenCalledTimes(1); expect(sendVoiceMessageDiscordMock).toHaveBeenCalledTimes(1);
expect(sendMessageDiscordMock).not.toHaveBeenCalled(); 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 { RequestClient } from "@buape/carbon";
import type { ChunkMode } from "../../auto-reply/chunk.js"; import type { ChunkMode } from "../../auto-reply/chunk.js";
import type { ReplyPayload } from "../../auto-reply/types.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 { convertMarkdownTables } from "../../markdown/tables.js";
import type { RuntimeEnv } from "../../runtime.js"; import type { RuntimeEnv } from "../../runtime.js";
import { chunkDiscordTextWithMode } from "../chunk.js"; import { chunkDiscordTextWithMode } from "../chunk.js";
@@ -17,10 +17,29 @@ export async function deliverDiscordReply(params: {
textLimit: number; textLimit: number;
maxLinesPerMessage?: number; maxLinesPerMessage?: number;
replyToId?: string; replyToId?: string;
replyToMode?: ReplyToMode;
tableMode?: MarkdownTableMode; tableMode?: MarkdownTableMode;
chunkMode?: ChunkMode; chunkMode?: ChunkMode;
}) { }) {
const chunkLimit = Math.min(params.textLimit, 2000); 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) { for (const payload of params.replies) {
const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
const rawText = payload.text ?? ""; const rawText = payload.text ?? "";
@@ -29,8 +48,6 @@ export async function deliverDiscordReply(params: {
if (!text && mediaList.length === 0) { if (!text && mediaList.length === 0) {
continue; continue;
} }
const replyTo = params.replyToId?.trim() || undefined;
if (mediaList.length === 0) { if (mediaList.length === 0) {
const mode = params.chunkMode ?? "length"; const mode = params.chunkMode ?? "length";
const chunks = chunkDiscordTextWithMode(text, { const chunks = chunkDiscordTextWithMode(text, {
@@ -46,6 +63,7 @@ export async function deliverDiscordReply(params: {
if (!trimmed) { if (!trimmed) {
continue; continue;
} }
const replyTo = resolveReplyTo();
await sendMessageDiscord(params.target, trimmed, { await sendMessageDiscord(params.target, trimmed, {
token: params.token, token: params.token,
rest: params.rest, rest: params.rest,
@@ -63,6 +81,7 @@ export async function deliverDiscordReply(params: {
// Voice message path: audioAsVoice flag routes through sendVoiceMessageDiscord // Voice message path: audioAsVoice flag routes through sendVoiceMessageDiscord
if (payload.audioAsVoice) { if (payload.audioAsVoice) {
const replyTo = resolveReplyTo();
await sendVoiceMessageDiscord(params.target, firstMedia, { await sendVoiceMessageDiscord(params.target, firstMedia, {
token: params.token, token: params.token,
rest: params.rest, rest: params.rest,
@@ -71,6 +90,7 @@ export async function deliverDiscordReply(params: {
}); });
// Voice messages cannot include text; send remaining text separately if present // Voice messages cannot include text; send remaining text separately if present
if (text.trim()) { if (text.trim()) {
const replyTo = resolveReplyTo();
await sendMessageDiscord(params.target, text, { await sendMessageDiscord(params.target, text, {
token: params.token, token: params.token,
rest: params.rest, 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) // Additional media items are sent as regular attachments (voice is single-file only)
for (const extra of mediaList.slice(1)) { for (const extra of mediaList.slice(1)) {
const replyTo = resolveReplyTo();
await sendMessageDiscord(params.target, "", { await sendMessageDiscord(params.target, "", {
token: params.token, token: params.token,
rest: params.rest, rest: params.rest,
@@ -91,6 +112,7 @@ export async function deliverDiscordReply(params: {
continue; continue;
} }
const replyTo = resolveReplyTo();
await sendMessageDiscord(params.target, text, { await sendMessageDiscord(params.target, text, {
token: params.token, token: params.token,
rest: params.rest, rest: params.rest,
@@ -99,6 +121,7 @@ export async function deliverDiscordReply(params: {
replyTo, replyTo,
}); });
for (const extra of mediaList.slice(1)) { for (const extra of mediaList.slice(1)) {
const replyTo = resolveReplyTo();
await sendMessageDiscord(params.target, "", { await sendMessageDiscord(params.target, "", {
token: params.token, token: params.token,
rest: params.rest, rest: params.rest,