From 2e2212be1baff7be4ee3609e9cac619e1c447f45 Mon Sep 17 00:00:00 2001 From: srojk34 <286497132+srojk34@users.noreply.github.com> Date: Sun, 5 Jul 2026 17:38:50 +0300 Subject: [PATCH] fix(discord): dedup saturated mid-stream overflow previews to stop edit-rate-limit storms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit a0a3c716f fixed the exact same failure mode for Telegram (#58563): post-#48648, oversized mid-stream edits truncate to a one-message preview instead of splitting. Once a long streamed reply grows past that cap, every subsequent progressive edit truncates to the SAME preview text — re-sending an identical edit every tick still counts against the platform's edit rate limit for the rest of the stream. Discord's edit_message() has the identical architecture (mid-stream truncate-in-place, both pre-flight and reactive-after-50035 truncation paths) and this file's own docstring already calls out "the Telegram #48648 lesson" it's built on — but the saturated-preview dedup fix itself was never ported over. Fix: track the last truncated preview per (chat_id, message_id), mirroring a0a3c716f exactly. Skip the edit call when the new truncation is identical; still deliver when the visible content actually changes (e.g. the chunk-count marker crosses (1/2) -> (1/3) as the stream grows). State clears on finalize and when content shrinks back under the cap, so dedup can never mask a real edit. --- plugins/platforms/discord/adapter.py | 33 ++++++++ .../test_discord_edit_message_overflow.py | 77 +++++++++++++++++++ 2 files changed, 110 insertions(+) diff --git a/plugins/platforms/discord/adapter.py b/plugins/platforms/discord/adapter.py index 007992a3203..92955e631a0 100644 --- a/plugins/platforms/discord/adapter.py +++ b/plugins/platforms/discord/adapter.py @@ -833,6 +833,13 @@ class DiscordAdapter(BasePlatformAdapter): # Persistent set of bot-authored lifecycle/status message IDs that # should not act as conversational history boundaries after restart. self._nonconversational_messages = _DiscordNonConversationalMessageTracker() + # Last truncated mid-stream preview delivered per (chat_id, message_id). + # Once an oversized streaming edit saturates at the 2000-char preview + # cap, every subsequent progressive edit truncates to the SAME text; + # re-sending it is a no-op that still counts against Discord's edit + # rate limit (~1 edit per stream tick for the rest of a long reply). + # Mirrors the Telegram #58563 fix. Entries are dropped on finalize. + self._last_overflow_preview: Dict[tuple, str] = {} def _handle_bot_task_done(self, task: asyncio.Task) -> None: """Surface post-startup discord.py task exits to the gateway supervisor. @@ -2153,6 +2160,13 @@ class DiscordAdapter(BasePlatformAdapter): msg = await channel.fetch_message(int(message_id)) formatted = self.format_message(content) + _preview_key = (str(chat_id), str(message_id)) + _saturated_preview = False + if finalize: + # Any saturation state for this message is finished with — + # the final edit always delivers real (full) content. + self._last_overflow_preview.pop(_preview_key, None) + # Pre-flight: oversized payload. Final edits split-and-deliver; # streaming edits truncate a one-message preview in place. if len(formatted) > self.MAX_MESSAGE_LENGTH: @@ -2163,9 +2177,24 @@ class DiscordAdapter(BasePlatformAdapter): formatted = self.truncate_message( formatted, self.MAX_MESSAGE_LENGTH, )[0] + _saturated_preview = True + # Saturated-preview dedup: past the cap, every progressive + # edit truncates to the same text. Re-sending it is a visual + # no-op that still counts against Discord's edit rate limit — + # skip silently until finalize (mirrors the Telegram #58563 + # fix). + if self._last_overflow_preview.get(_preview_key) == formatted: + return SendResult(success=True, message_id=message_id) + elif not finalize: + # Content shrank back under the cap (segment break / new + # message id) — clear stale saturation state so dedup can't + # mask a real edit later. + self._last_overflow_preview.pop(_preview_key, None) try: await msg.edit(content=formatted) + if _saturated_preview: + self._last_overflow_preview[_preview_key] = formatted except Exception as edit_err: # Reactive split-and-deliver: format_message inflation (or a # server-side rule change) can push the payload past 2,000 @@ -2180,7 +2209,11 @@ class DiscordAdapter(BasePlatformAdapter): truncated = self.truncate_message( formatted, self.MAX_MESSAGE_LENGTH, )[0] + if self._last_overflow_preview.get(_preview_key) == truncated: + # Saturated-preview dedup (see pre-flight path above). + return SendResult(success=True, message_id=message_id) await msg.edit(content=truncated) + self._last_overflow_preview[_preview_key] = truncated else: raise return SendResult(success=True, message_id=message_id) diff --git a/tests/gateway/test_discord_edit_message_overflow.py b/tests/gateway/test_discord_edit_message_overflow.py index e49717a2317..98f87ff4ce2 100644 --- a/tests/gateway/test_discord_edit_message_overflow.py +++ b/tests/gateway/test_discord_edit_message_overflow.py @@ -144,6 +144,83 @@ class TestMidStreamOverflowTruncates: assert not edits[0].endswith("...") +# --------------------------------------------------------------------------- # +# Saturated-preview dedup — stop flood-control edit storms (mirrors the +# Telegram #58563 fix) +# --------------------------------------------------------------------------- # + + +class TestSaturatedPreviewDedup: + @pytest.mark.asyncio + async def test_saturated_preview_dedups_repeat_oversized_edits(self): + """Once a mid-stream preview saturates at the truncation cap, further + oversized edits truncate to the SAME text — re-sending them is a + visual no-op that still counts against Discord's edit rate limit + (the exact "Telegram #48648 lesson" this file's own docstring + already references). The adapter must skip identical saturated + previews without an API call.""" + adapter = _make_adapter() + edits = [] + msg = SimpleNamespace( + id=42, + edit=AsyncMock(side_effect=lambda *, content: edits.append(content)), + ) + channel, sends = _wire_channel(adapter, original_msg=msg) + + # First oversized edit: delivers the truncated preview (1 API call). + r1 = await adapter.edit_message("555", "42", "x" * 2500, finalize=False) + assert r1.success is True + assert len(edits) == 1 + + # Stream keeps growing within the same chunk count (2500-3500 chars + # all truncate to the same "...(1/2)" chunk-1 preview) — no API calls. + for grow in (3000, 3500): + r = await adapter.edit_message("555", "42", "x" * grow, finalize=False) + assert r.success is True + assert r.message_id == "42" + assert len(edits) == 1, "identical saturated previews must not be re-sent" + + # Chunk-count boundary: 4000+ chars cross into "(1/3)" — a real + # change that SHOULD be delivered. + await adapter.edit_message("555", "42", "x" * 4000, finalize=False) + assert len(edits) == 2 + # ...and saturates again at the new marker. + await adapter.edit_message("555", "42", "x" * 4500, finalize=False) + assert len(edits) == 2 + + # Finalize always delivers real content, even if identical to the + # last saturated preview (full split-and-deliver, not a dedup skip). + result = await adapter.edit_message("555", "42", "x" * 4500, finalize=True) + assert result.success is True + assert len(edits) == 3 + + @pytest.mark.asyncio + async def test_content_shrinking_back_under_cap_clears_dedup_state(self): + """If mid-stream content shrinks back under the cap (e.g. a fresh + segment), stale saturation state must not mask the next real + oversized edit on this message id.""" + adapter = _make_adapter() + edits = [] + msg = SimpleNamespace( + id=42, + edit=AsyncMock(side_effect=lambda *, content: edits.append(content)), + ) + channel, sends = _wire_channel(adapter, original_msg=msg) + + await adapter.edit_message("555", "42", "x" * 2500, finalize=False) + assert len(edits) == 1 + + # Shrinks back under the cap — delivered in full, clears saturation. + await adapter.edit_message("555", "42", "short", finalize=False) + assert len(edits) == 2 + assert edits[-1] == "short" + + # Grows past the cap again with the SAME truncated text as before — + # must be delivered again since the dedup state was cleared. + await adapter.edit_message("555", "42", "x" * 2500, finalize=False) + assert len(edits) == 3 + + # --------------------------------------------------------------------------- # # Final overflow — SPLIT and deliver every chunk # --------------------------------------------------------------------------- #