Discord drops an empty outbound message instead of sending it (#78815)

* fix(discord): reject empty outbound messages

* test(discord): cover empty final reply backfill state

Missed-message backfill decides what to replay from discord_messages, so
a dropped final reply must be recorded as failed by the new guard the
same way the exception path records one — otherwise the reply is both
never sent and never retried.

Co-authored-by: Jony <619963502@qq.com>

* chore: map 619963502@qq.com to zyz619963502zyz for PR #73449 salvage

---------

Co-authored-by: Jony <619963502@qq.com>
This commit is contained in:
brooklyn! 2026-08-04 12:23:04 -06:00 committed by GitHub
parent 9712b8f0cc
commit b3e45a3d46
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 61 additions and 0 deletions

View File

@ -0,0 +1,2 @@
zyz619963502zyz
# PR #73449 salvage → #78815

View File

@ -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.

View File

@ -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))