Skip to content
Closed
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
48 changes: 33 additions & 15 deletions plugins/platforms/discord/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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()
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