fix(discord): dedup saturated mid-stream overflow previews to stop edit-rate-limit storms

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.
This commit is contained in:
srojk34 2026-07-05 17:38:50 +03:00 committed by Teknium
parent cdcbc3a31d
commit 2e2212be1b
2 changed files with 110 additions and 0 deletions

View file

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

View file

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