Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions plugins/platforms/discord/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -833,6 +833,13 @@ def __init__(self, config: PlatformConfig):
# 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.
Expand Down Expand Up @@ -2153,6 +2160,13 @@ async def edit_message(
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:
Expand All @@ -2163,9 +2177,24 @@ async def edit_message(
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
Expand All @@ -2180,7 +2209,11 @@ async def edit_message(
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)
Expand Down
77 changes: 77 additions & 0 deletions tests/gateway/test_discord_edit_message_overflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,83 @@ async def test_oversized_streaming_edit_truncates_in_place(self):
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
# --------------------------------------------------------------------------- #
Expand Down
Loading