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
63 changes: 41 additions & 22 deletions plugins/platforms/discord/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -2952,6 +2952,36 @@ async def on_processing_complete(self, event: MessageEvent, outcome: ProcessingO
elif outcome == ProcessingOutcome.FAILURE:
await self._add_reaction(message, "❌")

@staticmethod
def _message_reference_from_ids(message_id, channel) -> "discord.MessageReference":
"""ids-built reply reference — no fetch_message round trip.

Discord resolves message_reference from the ids alone, and
fail_if_not_exists=False keeps sends to deleted targets working
exactly as the fetched form did (a dead target degrades to the
send-side 10008 retry instead of a fetch failure).
"""
return discord.MessageReference(
message_id=int(message_id),
channel_id=getattr(channel, "id", None),
guild_id=getattr(getattr(channel, "guild", None), "id", None),
fail_if_not_exists=False,
)

def _reply_reference_for_send(self, reply_to, channel):
"""Reply anchor for send paths, honoring reply_to_mode.

Mirrors telegram's _reply_to_message_id_for_send: raw reply_to in,
platform send-time anchor out, ``off`` mode suppressed.
"""
if not reply_to or self._reply_to_mode == "off":
return None
try:
return self._message_reference_from_ids(reply_to, channel)
except (ValueError, TypeError) as e:
logger.debug("Could not build reply-to reference: %s", e)
return None

async def send(
self,
chat_id: str,
Expand Down Expand Up @@ -3010,17 +3040,8 @@ async def send(
chunks = self.truncate_message(formatted, self.MAX_MESSAGE_LENGTH)

message_ids = []
reference = None

if reply_to and self._reply_to_mode != "off":
try:
ref_msg = await channel.fetch_message(int(reply_to))
if hasattr(ref_msg, "to_reference"):
reference = ref_msg.to_reference(fail_if_not_exists=False)
else:
reference = ref_msg
except Exception as e:
logger.debug("Could not fetch reply-to message: %s", e)
# Build the reference from ids — no fetch_message round trip.
reference = self._reply_reference_for_send(reply_to, channel)

for i, chunk in enumerate(chunks):
if self._reply_to_mode == "all":
Expand Down Expand Up @@ -3261,7 +3282,7 @@ async def edit_message(
channel = self._client.get_channel(int(chat_id))
if not channel:
channel = await self._client.fetch_channel(int(chat_id))
msg = await channel.fetch_message(int(message_id))
msg = channel.get_partial_message(int(message_id))
formatted = self.format_message(content)

_preview_key = (str(chat_id), str(message_id))
Expand Down Expand Up @@ -3401,6 +3422,12 @@ async def _edit_overflow_split(
reference = prev_msg.to_reference(fail_if_not_exists=False)
except Exception:
reference = None
elif getattr(prev_msg, "id", None):
# Duck-typed prior message without to_reference (real
# discord.py PartialMessage HAS to_reference, so this is
# belt-and-suspenders): build the reference from ids so
# overflow continuations stay threaded.
reference = self._message_reference_from_ids(prev_msg.id, channel)
try:
sent = await channel.send(content=chunk, reference=reference)
except Exception as send_err:
Expand Down Expand Up @@ -3696,16 +3723,8 @@ async def send_voice(

filename = os.path.basename(audio_path)

reference = None
if reply_to and self._reply_to_mode != "off":
try:
ref_msg = await channel.fetch_message(int(reply_to))
if hasattr(ref_msg, "to_reference"):
reference = ref_msg.to_reference(fail_if_not_exists=False)
else:
reference = ref_msg
except Exception as e:
logger.debug("Could not fetch voice reply-to message: %s", e)
# ids-only reference — same no-fetch rationale as the text path.
reference = self._reply_reference_for_send(reply_to, channel)

with open(audio_path, "rb") as f:
file_data = f.read()
Expand Down
24 changes: 23 additions & 1 deletion tests/gateway/test_discord_edit_message_overflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ async def fake_send(*, content, reference=None):

channel = SimpleNamespace(
id=555,
fetch_message=AsyncMock(return_value=original_msg),
get_partial_message=MagicMock(return_value=original_msg),
send=AsyncMock(side_effect=fake_send),
)
adapter._client = SimpleNamespace(
Expand Down Expand Up @@ -290,3 +290,25 @@ def test_ignores_non_length_50035(self):
err = RuntimeError("error code: 50035: Cannot reply to a system message")
assert DiscordAdapter._is_length_overflow_error(err) is False



class TestPartialMessageContinuationReferences:
"""When the edit target is a PartialMessage (no to_reference — the
no-fetch edit path), overflow continuations must still thread: the
adapter builds the reference from ids instead of silently dropping it."""

@pytest.mark.asyncio
async def test_continuations_threaded_with_ids_built_reference(self):
adapter = _make_adapter()
partial = SimpleNamespace(id=42, edit=AsyncMock()) # no to_reference
channel, sends = _wire_channel(adapter, original_msg=partial)

long_text = "chunk alpha " * 600 # > MAX_MESSAGE_LENGTH
result = await adapter.edit_message("555", "42", long_text, finalize=True)

assert result.success is True
assert len(sends) >= 1, "overflow should send continuations"
for call in sends:
assert call["reference"] is not None, (
"continuation lost its reply reference — the ids-built "
"fallback for PartialMessage regressed")
50 changes: 42 additions & 8 deletions tests/gateway/test_discord_reply_mode.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,15 +82,12 @@ def _make_discord_adapter(reply_to_mode: str = "first"):
config = PlatformConfig(enabled=True, token="test-token", reply_to_mode=reply_to_mode)
adapter = DiscordAdapter(config)

# Mock the Discord client and channel.
# ref_message.to_reference() → a distinct sentinel: the adapter now wraps
# the fetched Message via to_reference(fail_if_not_exists=False) so a
# deleted target degrades to "send without reply chip" instead of a 400.
# Mock the Discord client and channel. Reply references are built from
# ids via discord.MessageReference — no fetch round trip — so the
# harness only needs a send() capture; the auto-attribute covers the
# fetch_message.assert_not_called() assertions below.
mock_channel = AsyncMock()
ref_message = MagicMock()
ref_reference = MagicMock(name="MessageReference")
ref_message.to_reference = MagicMock(return_value=ref_reference)
mock_channel.fetch_message = AsyncMock(return_value=ref_message)

sent_msg = MagicMock()
sent_msg.id = 42
Expand All @@ -100,7 +97,6 @@ def _make_discord_adapter(reply_to_mode: str = "first"):
mock_client.get_channel = MagicMock(return_value=mock_channel)

adapter._client = mock_client
# Return the reference sentinel alongside so tests can assert identity.
adapter._test_expected_reference = ref_reference
return adapter, mock_channel, ref_reference

Expand Down Expand Up @@ -135,6 +131,23 @@ async def test_single_chunk_off_mode(self):
assert calls[0].kwargs.get("reference") is None


@pytest.mark.asyncio
async def test_first_mode_constructs_reference_without_fetch(self):
"""Pin: replies build the MessageReference from ids — no
fetch_message round trip. Fails pre-fix, which fetched the target
just to call to_reference() on it."""
adapter, channel, _ = _make_discord_adapter("first")
adapter.truncate_message = lambda content, max_len, **kw: ["chunk1", "chunk2"]

await adapter.send("12345", "test content", reply_to="999")

channel.fetch_message.assert_not_called()
calls = channel.send.call_args_list
assert len(calls) == 2
assert calls[0].kwargs.get("reference") is not None # first chunk
assert calls[1].kwargs.get("reference") is None # later chunks


class TestConfigSerialization:
"""Tests for reply_to_mode serialization (shared with Telegram)."""

Expand Down Expand Up @@ -281,3 +294,24 @@ def test_top_level_takes_precedence_over_extra(self, tmp_path, monkeypatch):
load_gateway_config()

assert os.environ.get("DISCORD_REPLY_TO_MODE") == "all"


class TestVoiceReplyReference:
"""send_voice builds its reply reference from ids too — same no-fetch
pin as the text path (construction happens before the file read)."""

@pytest.mark.asyncio
async def test_voice_reply_constructs_reference_without_fetch(self, tmp_path, monkeypatch):
adapter, channel, _ = _make_discord_adapter("first")
audio = tmp_path / "clip.ogg"
audio.write_bytes(b"OggS" + b"\x00" * 64)
monkeypatch.setattr(adapter, "_is_forum_parent", lambda _c: True)
forum_posts = []
async def fake_forum_post(_channel, **kwargs):
forum_posts.append(kwargs)
return MagicMock(success=True, message_id="77")
monkeypatch.setattr(adapter, "_forum_post_file", fake_forum_post)

await adapter.send_voice("12345", str(audio), reply_to="999")

channel.fetch_message.assert_not_called()
11 changes: 8 additions & 3 deletions tests/gateway/test_discord_send.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,10 +103,15 @@ async def fake_send(*, content, reference=None):

assert result.success is True
assert result.message_id == "1001"
assert channel.fetch_message.await_count == 1
# ids-only reference: the fetch is gone entirely — the retry happens
# on the send-side 10008, not a fetch failure
assert channel.fetch_message.await_count == 0
assert channel.send.await_count == 3
ref_msg.to_reference.assert_called_once_with(fail_if_not_exists=False)
assert send_calls[0]["reference"] is reference_obj
# the reference is constructed from ids, not fetched + to_reference()
_discord_mod.MessageReference.assert_any_call(
message_id=99, channel_id=None, guild_id=None,
fail_if_not_exists=False)
assert send_calls[0]["reference"] is _discord_mod.MessageReference.return_value
assert send_calls[1]["reference"] is None
assert send_calls[2]["reference"] is None

Expand Down
Loading