diff --git a/plugins/platforms/discord/adapter.py b/plugins/platforms/discord/adapter.py index b2befc242baa..25808bad7e7a 100644 --- a/plugins/platforms/discord/adapter.py +++ b/plugins/platforms/discord/adapter.py @@ -3014,13 +3014,18 @@ async def send( 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. 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. + reference = discord.MessageReference( + message_id=int(reply_to), + channel_id=getattr(channel, "id", None), + guild_id=getattr(getattr(channel, "guild", None), "id", None), + fail_if_not_exists=False, + ) + except (ValueError, TypeError) as e: + logger.debug("Could not build reply-to reference: %s", e) for i, chunk in enumerate(chunks): if self._reply_to_mode == "all": @@ -3261,7 +3266,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)) @@ -3401,6 +3406,16 @@ 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): + # PartialMessage has no to_reference — build it from ids so + # overflow continuations stay threaded (edit_message uses + # get_partial_message, no fetch round trip). + reference = discord.MessageReference( + message_id=prev_msg.id, + channel_id=getattr(channel, "id", None), + guild_id=getattr(getattr(channel, "guild", None), "id", None), + fail_if_not_exists=False, + ) try: sent = await channel.send(content=chunk, reference=reference) except Exception as send_err: @@ -3699,13 +3714,16 @@ async def send_voice( 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 reply path. + reference = discord.MessageReference( + message_id=int(reply_to), + channel_id=getattr(channel, "id", None), + guild_id=getattr(getattr(channel, "guild", None), "id", None), + fail_if_not_exists=False, + ) + except (ValueError, TypeError) as e: + logger.debug("Could not build voice reply-to reference: %s", e) with open(audio_path, "rb") as f: file_data = f.read() diff --git a/tests/gateway/test_discord_edit_message_overflow.py b/tests/gateway/test_discord_edit_message_overflow.py index 92705e5f7e78..7408946945e4 100644 --- a/tests/gateway/test_discord_edit_message_overflow.py +++ b/tests/gateway/test_discord_edit_message_overflow.py @@ -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( @@ -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") diff --git a/tests/gateway/test_discord_reply_mode.py b/tests/gateway/test_discord_reply_mode.py index b6917255a805..61b347e5c698 100644 --- a/tests/gateway/test_discord_reply_mode.py +++ b/tests/gateway/test_discord_reply_mode.py @@ -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 @@ -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 @@ -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).""" @@ -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() diff --git a/tests/gateway/test_discord_send.py b/tests/gateway/test_discord_send.py index 1aafb3b8a556..e26d55dbde4d 100644 --- a/tests/gateway/test_discord_send.py +++ b/tests/gateway/test_discord_send.py @@ -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