fix: handle edge cases in message splitting and send failure

- _split_message: return empty list for empty/None content instead
  of a list with one empty string (Discord rejects empty content)
- _split_message: use pos <= 0 fallback to prevent empty chunks
  when content starts with a newline or space
- _send_payload: return bool to indicate success/failure
- send: abort remaining chunks when a chunk fails to send,
  preventing partial/corrupted message delivery
This commit is contained in:
Nikolas de Hor
2026-02-20 10:09:04 -03:00
parent 4c75e1673f
commit 4cbd857250

View File

@@ -22,6 +22,8 @@ MAX_MESSAGE_LEN = 2000 # Discord message character limit
def _split_message(content: str, max_len: int = MAX_MESSAGE_LEN) -> list[str]: def _split_message(content: str, max_len: int = MAX_MESSAGE_LEN) -> list[str]:
"""Split content into chunks within max_len, preferring line breaks.""" """Split content into chunks within max_len, preferring line breaks."""
if not content:
return []
if len(content) <= max_len: if len(content) <= max_len:
return [content] return [content]
chunks: list[str] = [] chunks: list[str] = []
@@ -31,9 +33,9 @@ def _split_message(content: str, max_len: int = MAX_MESSAGE_LEN) -> list[str]:
break break
cut = content[:max_len] cut = content[:max_len]
pos = cut.rfind('\n') pos = cut.rfind('\n')
if pos == -1: if pos <= 0:
pos = cut.rfind(' ') pos = cut.rfind(' ')
if pos == -1: if pos <= 0:
pos = max_len pos = max_len
chunks.append(content[:pos]) chunks.append(content[:pos])
content = content[pos:].lstrip() content = content[pos:].lstrip()
@@ -104,6 +106,9 @@ class DiscordChannel(BaseChannel):
try: try:
chunks = _split_message(msg.content or "") chunks = _split_message(msg.content or "")
if not chunks:
return
for i, chunk in enumerate(chunks): for i, chunk in enumerate(chunks):
payload: dict[str, Any] = {"content": chunk} payload: dict[str, Any] = {"content": chunk}
@@ -112,14 +117,18 @@ class DiscordChannel(BaseChannel):
payload["message_reference"] = {"message_id": msg.reply_to} payload["message_reference"] = {"message_id": msg.reply_to}
payload["allowed_mentions"] = {"replied_user": False} payload["allowed_mentions"] = {"replied_user": False}
await self._send_payload(url, headers, payload) if not await self._send_payload(url, headers, payload):
break # Abort remaining chunks on failure
finally: finally:
await self._stop_typing(msg.chat_id) await self._stop_typing(msg.chat_id)
async def _send_payload( async def _send_payload(
self, url: str, headers: dict[str, str], payload: dict[str, Any] self, url: str, headers: dict[str, str], payload: dict[str, Any]
) -> None: ) -> bool:
"""Send a single Discord API payload with retry on rate-limit.""" """Send a single Discord API payload with retry on rate-limit.
Returns True on success, False if all attempts failed.
"""
for attempt in range(3): for attempt in range(3):
try: try:
response = await self._http.post(url, headers=headers, json=payload) response = await self._http.post(url, headers=headers, json=payload)
@@ -130,12 +139,13 @@ class DiscordChannel(BaseChannel):
await asyncio.sleep(retry_after) await asyncio.sleep(retry_after)
continue continue
response.raise_for_status() response.raise_for_status()
return return True
except Exception as e: except Exception as e:
if attempt == 2: if attempt == 2:
logger.error("Error sending Discord message: {}", e) logger.error("Error sending Discord message: {}", e)
else: else:
await asyncio.sleep(1) await asyncio.sleep(1)
return False
async def _gateway_loop(self) -> None: async def _gateway_loop(self) -> None:
"""Main gateway loop: identify, heartbeat, dispatch events.""" """Main gateway loop: identify, heartbeat, dispatch events."""