diff --git a/contributors/emails/619963502@qq.com b/contributors/emails/619963502@qq.com new file mode 100644 index 0000000000000..fed3604ab4e6c --- /dev/null +++ b/contributors/emails/619963502@qq.com @@ -0,0 +1,2 @@ +zyz619963502zyz +# PR #73449 salvage → #78815 diff --git a/plugins/platforms/discord/adapter.py b/plugins/platforms/discord/adapter.py index c7c3036f75f9e..22d4c2af2daae 100644 --- a/plugins/platforms/discord/adapter.py +++ b/plugins/platforms/discord/adapter.py @@ -23,6 +23,7 @@ import subprocess import tempfile import threading import time +import traceback from collections import defaultdict from contextlib import suppress from typing import Callable, Dict, List, Optional, Any, Tuple @@ -3040,6 +3041,29 @@ class DiscordAdapter(BasePlatformAdapter): """ if not self._client: return SendResult(success=False, error="Not connected") + if not (content or "").strip(): + logger.warning( + "[%s] Dropped empty message to chat=%s (caller bug). Call site:\n%s", + self.name, + chat_id, + "".join(traceback.format_stack(limit=12)[:-1]), + ) + result = SendResult( + success=False, + error="Refusing to send empty message", + ) + # Mirror the exception path's recovery bookkeeping. Missed-message + # backfill decides what to replay from this table, so a dropped + # final reply must be recorded as failed — otherwise the reply is + # both never sent and never retried. + await asyncio.to_thread( + self._record_discord_response, + reply_to=reply_to, + result=result, + content=content, + final=bool(metadata and metadata.get("notify")), + ) + return result try: # Determine target channel: thread_id in metadata takes precedence. diff --git a/tests/gateway/test_discord_send.py b/tests/gateway/test_discord_send.py index e26d55dbde4dd..f12595e1abbb6 100644 --- a/tests/gateway/test_discord_send.py +++ b/tests/gateway/test_discord_send.py @@ -47,6 +47,41 @@ _ensure_discord_mock() from plugins.platforms.discord.adapter import DiscordAdapter # noqa: E402 +@pytest.mark.asyncio +async def test_send_rejects_whitespace_and_records_failed_final_reply( + caplog, monkeypatch, tmp_path +): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setenv("DISCORD_MISSED_MESSAGE_BACKFILL", "true") + adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***")) + channel = SimpleNamespace(send=AsyncMock()) + get_channel = MagicMock(return_value=channel) + adapter._client = SimpleNamespace( + get_channel=get_channel, + fetch_channel=AsyncMock(), + ) + with caplog.at_level("WARNING"): + result = await adapter.send( + "555", + " \n\t ", + reply_to="123", + metadata={"notify": True}, + ) + + assert result.success is False + assert result.error == "Refusing to send empty message" + get_channel.assert_not_called() + channel.send.assert_not_awaited() + row = adapter._with_discord_recovery_db( + lambda conn: conn.execute( + "SELECT status, replied, outage_response, response_message_id " + "FROM discord_messages WHERE message_id='123'" + ).fetchone() + ) + assert tuple(row) == ("failed", 0, 0, None) + assert "Dropped empty message to chat=555" in caplog.text + + def _voice_adapter(reference_obj, *, native_result=None, native_error=None): adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***")) ref_msg = SimpleNamespace(id=99, to_reference=MagicMock(return_value=reference_obj))