Skip to content
Open
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
329 changes: 329 additions & 0 deletions backend/discord_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,329 @@
"""
Enhanced Discord Client with Rate Limit Handling

This module provides a wrapper around discord.py client with automatic
rate limit handling and retry logic.
"""

import logging
from typing import Optional, Union, Dict, Any
import discord
from discord.ext import commands

from rate_limiter import get_rate_limiter, DiscordRateLimiter

logger = logging.getLogger(__name__)


class EnhancedDiscordClient:
"""
Enhanced Discord client with built-in rate limit handling.

Provides methods for sending and editing messages with automatic
retry logic on rate limit errors.
"""

def __init__(
self,
bot: Union[commands.Bot, discord.Client],
rate_limiter: Optional[DiscordRateLimiter] = None,
):
"""
Initialize the enhanced Discord client.

Args:
bot: The discord.py bot or client instance
rate_limiter: Optional custom rate limiter instance
"""
self.bot = bot
self.rate_limiter = rate_limiter or get_rate_limiter()

async def send_message_with_retry(
self,
channel: Union[discord.TextChannel, discord.DMChannel, int],
content: Optional[str] = None,
*,
embed: Optional[discord.Embed] = None,
embeds: Optional[list] = None,
file: Optional[discord.File] = None,
files: Optional[list] = None,
delete_after: Optional[float] = None,
nonce: Optional[Union[str, int]] = None,
allowed_mentions: Optional[discord.AllowedMentions] = None,
reference: Optional[Union[discord.Message, discord.MessageReference]] = None,
mention_author: Optional[bool] = None,
view: Optional[discord.ui.View] = None,
poll: Optional[discord.Poll] = None,
) -> discord.Message:
"""
Send a message with automatic rate limit handling.

Args:
channel: The Discord channel to send to
content: Message content
embed: Single embed to attach
embeds: List of embeds
file: Single file attachment
files: Multiple file attachments
delete_after: Delete message after this many seconds
nonce: Unique nonce for message
allowed_mentions: Allowed mentions configuration
reference: Message to reply to
mention_author: Whether to mention author in reply
view: UI View for buttons/select menus
poll: Poll to attach

Returns:
The sent discord.Message

Raises:
discord.DiscordException: If all retries are exhausted
"""
endpoint = "channels.messages.post"

async def _send() -> discord.Message:
# Convert channel ID to channel object if needed
if isinstance(channel, int):
ch = self.bot.get_channel(channel)
if not ch:
ch = await self.bot.fetch_channel(channel)
else:
ch = channel

return await ch.send(
content=content,
embed=embed,
embeds=embeds,
file=file,
files=files,
delete_after=delete_after,
nonce=nonce,
allowed_mentions=allowed_mentions,
reference=reference,
mention_author=mention_author,
view=view,
poll=poll,
)
Comment on lines +82 to +106
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

All operations share the same static endpoint string regardless of the actual channel/resource.

endpoint = "channels.messages.post" is used for every send, regardless of which channel. Discord's rate limits are per-route (e.g., per channel ID). Using a single bucket for all channels means a rate limit on channel A will block sends to channel B.

Consider including the channel/resource ID in the endpoint key:

endpoint = f"channels.{channel_id}.messages.post"

This applies to all methods (edit, delete, reactions, threads) as well.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/discord_client.py` around lines 82 - 106, The static endpoint string
("channels.messages.post") in backend/discord_client.py (used inside async def
_send and similar methods) causes all sends to share one rate-limit bucket;
update the endpoint construction to include the channel/resource identifier
(e.g., include the channel ID or target resource ID in the endpoint key) so each
channel gets its own route bucket, and apply the same change to the related
methods (edit, delete, reactions, threads) that currently use similar static
endpoint values.


try:
message = await self.rate_limiter.execute_with_retry(
_send,
endpoint,
)
logger.debug(f"Message sent to channel {channel}")
return message

except Exception as e:
logger.error(f"Failed to send message to {channel}: {e}")
raise
Comment on lines +41 to +118
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

discord.File objects cannot survive retries — streams are consumed on first attempt.

discord.File wraps an IO stream that is read once during channel.send(). If the first attempt fails with a 429 and the rate limiter retries, the file stream will already be exhausted/closed, causing the retry to send empty or corrupt data, or raise an error.

This applies to both file and files parameters in send_message_with_retry and edit_message_with_retry.

Consider either:

  1. Documenting that file uploads are not safely retryable, or
  2. Accepting file paths/bytes instead of discord.File and reconstructing the File object on each attempt inside _send.
🧰 Tools
🪛 Ruff (0.15.1)

[warning] 114-114: Consider moving this statement to an else block

(TRY300)


[warning] 117-117: Use logging.exception instead of logging.error

Replace with exception

(TRY400)


async def edit_message_with_retry(
self,
message: discord.Message,
*,
content: Optional[str] = None,
embed: Optional[discord.Embed] = None,
embeds: Optional[list] = None,
file: Optional[discord.File] = None,
files: Optional[list] = None,
delete_after: Optional[float] = None,
allowed_mentions: Optional[discord.AllowedMentions] = None,
view: Optional[discord.ui.View] = None,
) -> discord.Message:
"""
Edit a message with automatic rate limit handling.

Args:
message: The message to edit
content: New message content
embed: New embed
embeds: New embeds list
file: File to attach
files: Files to attach
delete_after: Delete after this many seconds
allowed_mentions: Allowed mentions configuration
view: New UI View

Returns:
The edited discord.Message

Raises:
discord.DiscordException: If all retries are exhausted
"""
endpoint = "channels.messages.patch"

async def _edit() -> discord.Message:
return await message.edit(
content=content,
embed=embed,
embeds=embeds,
file=file,
files=files,
delete_after=delete_after,
allowed_mentions=allowed_mentions,
view=view,
)

try:
edited = await self.rate_limiter.execute_with_retry(
_edit,
endpoint,
)
logger.debug(f"Message {message.id} edited")
return edited

except Exception as e:
logger.error(f"Failed to edit message {message.id}: {e}")
raise

async def delete_message_with_retry(
self,
message: discord.Message,
*,
delay: Optional[float] = None,
) -> None:
"""
Delete a message with automatic rate limit handling.

Args:
message: The message to delete
delay: Delay in seconds before deletion

Raises:
discord.DiscordException: If all retries are exhausted
"""
endpoint = "channels.messages.delete"

async def _delete() -> None:
await message.delete(delay=delay)

try:
await self.rate_limiter.execute_with_retry(
_delete,
endpoint,
)
logger.debug(f"Message {message.id} deleted")

except Exception as e:
logger.error(f"Failed to delete message {message.id}: {e}")
raise

async def add_reaction_with_retry(
self,
message: discord.Message,
emoji: Union[str, discord.Emoji],
) -> None:
"""
Add a reaction with automatic rate limit handling.

Args:
message: The message to react to
emoji: The emoji to add

Raises:
discord.DiscordException: If all retries are exhausted
"""
endpoint = "channels.messages.reactions.put"

async def _add_reaction() -> None:
await message.add_reaction(emoji)

try:
await self.rate_limiter.execute_with_retry(
_add_reaction,
endpoint,
)
logger.debug(f"Reaction added to message {message.id}")

except Exception as e:
logger.error(f"Failed to add reaction to message {message.id}: {e}")
raise

async def create_thread_with_retry(
self,
channel: discord.TextChannel,
*,
name: str,
message: Optional[discord.Message] = None,
auto_archive_duration: int = 60,
type: Optional[discord.ChannelType] = None,
slowmode_delay: Optional[int] = None,
) -> discord.Thread:
"""
Create a thread with automatic rate limit handling.

Args:
channel: The channel to create thread in
name: Thread name
message: Message to create thread from (optional)
auto_archive_duration: Archive duration in minutes
type: Channel type
slowmode_delay: Slowmode delay

Returns:
The created discord.Thread

Raises:
discord.DiscordException: If all retries are exhausted
"""
endpoint = "channels.threads.create"

async def _create_thread() -> discord.Thread:
if message:
return await message.create_thread(
name=name,
auto_archive_duration=auto_archive_duration,
slowmode_delay=slowmode_delay,
)
else:
return await channel.create_thread(
name=name,
auto_archive_duration=auto_archive_duration,
type=type,
slowmode_delay=slowmode_delay,
)

try:
thread = await self.rate_limiter.execute_with_retry(
_create_thread,
endpoint,
)
logger.debug(f"Thread '{name}' created in {channel.id}")
return thread

except Exception as e:
logger.error(f"Failed to create thread '{name}': {e}")
raise

def get_rate_limit_status(self, endpoint: Optional[str] = None) -> Dict[str, Any]:
"""
Get the current rate limit status.

Args:
endpoint: Specific endpoint to check (optional)

Returns:
Status dictionary
"""
return self.rate_limiter.get_status(endpoint)

def reset_rate_limits(self) -> None:
"""Reset all rate limit buckets."""
self.rate_limiter.reset_all()


def create_enhanced_client(
bot: Union[commands.Bot, discord.Client],
rate_limiter: Optional[DiscordRateLimiter] = None,
) -> EnhancedDiscordClient:
"""
Create an enhanced Discord client wrapper.

Args:
bot: The discord.py bot instance
rate_limiter: Optional custom rate limiter

Returns:
EnhancedDiscordClient instance
"""
return EnhancedDiscordClient(bot, rate_limiter)
Loading