fix: split Discord messages exceeding 2000-character limit
Discord's API rejects messages longer than 2000 characters with HTTP 400. Previously, long agent responses were silently lost after retries exhausted. Adds _split_message() (matching Telegram's approach) to chunk content at line boundaries before sending. Only the first chunk carries the reply reference. Retry logic extracted to _send_payload() for reuse across chunks. Closes #898
This commit is contained in:
@@ -17,6 +17,27 @@ from nanobot.config.schema import DiscordConfig
|
|||||||
|
|
||||||
DISCORD_API_BASE = "https://discord.com/api/v10"
|
DISCORD_API_BASE = "https://discord.com/api/v10"
|
||||||
MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024 # 20MB
|
MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024 # 20MB
|
||||||
|
MAX_MESSAGE_LEN = 2000 # Discord message character limit
|
||||||
|
|
||||||
|
|
||||||
|
def _split_message(content: str, max_len: int = MAX_MESSAGE_LEN) -> list[str]:
|
||||||
|
"""Split content into chunks within max_len, preferring line breaks."""
|
||||||
|
if len(content) <= max_len:
|
||||||
|
return [content]
|
||||||
|
chunks: list[str] = []
|
||||||
|
while content:
|
||||||
|
if len(content) <= max_len:
|
||||||
|
chunks.append(content)
|
||||||
|
break
|
||||||
|
cut = content[:max_len]
|
||||||
|
pos = cut.rfind('\n')
|
||||||
|
if pos == -1:
|
||||||
|
pos = cut.rfind(' ')
|
||||||
|
if pos == -1:
|
||||||
|
pos = max_len
|
||||||
|
chunks.append(content[:pos])
|
||||||
|
content = content[pos:].lstrip()
|
||||||
|
return chunks
|
||||||
|
|
||||||
|
|
||||||
class DiscordChannel(BaseChannel):
|
class DiscordChannel(BaseChannel):
|
||||||
@@ -79,15 +100,26 @@ class DiscordChannel(BaseChannel):
|
|||||||
return
|
return
|
||||||
|
|
||||||
url = f"{DISCORD_API_BASE}/channels/{msg.chat_id}/messages"
|
url = f"{DISCORD_API_BASE}/channels/{msg.chat_id}/messages"
|
||||||
payload: dict[str, Any] = {"content": msg.content}
|
|
||||||
|
|
||||||
if msg.reply_to:
|
|
||||||
payload["message_reference"] = {"message_id": msg.reply_to}
|
|
||||||
payload["allowed_mentions"] = {"replied_user": False}
|
|
||||||
|
|
||||||
headers = {"Authorization": f"Bot {self.config.token}"}
|
headers = {"Authorization": f"Bot {self.config.token}"}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
chunks = _split_message(msg.content or "")
|
||||||
|
for i, chunk in enumerate(chunks):
|
||||||
|
payload: dict[str, Any] = {"content": chunk}
|
||||||
|
|
||||||
|
# Only set reply reference on the first chunk
|
||||||
|
if i == 0 and msg.reply_to:
|
||||||
|
payload["message_reference"] = {"message_id": msg.reply_to}
|
||||||
|
payload["allowed_mentions"] = {"replied_user": False}
|
||||||
|
|
||||||
|
await self._send_payload(url, headers, payload)
|
||||||
|
finally:
|
||||||
|
await self._stop_typing(msg.chat_id)
|
||||||
|
|
||||||
|
async def _send_payload(
|
||||||
|
self, url: str, headers: dict[str, str], payload: dict[str, Any]
|
||||||
|
) -> None:
|
||||||
|
"""Send a single Discord API payload with retry on rate-limit."""
|
||||||
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)
|
||||||
@@ -104,8 +136,6 @@ class DiscordChannel(BaseChannel):
|
|||||||
logger.error("Error sending Discord message: {}", e)
|
logger.error("Error sending Discord message: {}", e)
|
||||||
else:
|
else:
|
||||||
await asyncio.sleep(1)
|
await asyncio.sleep(1)
|
||||||
finally:
|
|
||||||
await self._stop_typing(msg.chat_id)
|
|
||||||
|
|
||||||
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."""
|
||||||
|
|||||||
Reference in New Issue
Block a user