Skip to content
Merged
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
5 changes: 3 additions & 2 deletions capy_discord/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@

def main() -> None:
"""Main function to run the application."""
setup_logging()
setup_logging(settings.log_level)

capy_discord.instance = Bot(command_prefix=settings.prefix, intents=discord.Intents.all())
capy_discord.instance.run(settings.token)
capy_discord.instance.run(settings.token, log_handler=None)


main()
23 changes: 18 additions & 5 deletions capy_discord/exts/tools/ping.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,32 @@
from discord.ext import commands

import capy_discord
from capy_discord.logging import get_logger


class PingCog(commands.Cog):
class Ping(commands.Cog):
"""Cog for ping command."""

def __init__(self) -> None:
"""Initialize the Ping cog."""
self.logger = get_logger(__name__)

@app_commands.command(name="ping", description="Shows the bot's latency")
async def ping(self, interaction: discord.Interaction) -> None:
"""Respond with the bot's latency."""
message = f"⏱ {round(capy_discord.instance.latency * 1000)} ms Latency!"
embed = discord.Embed(title="Ping", description=message)
await interaction.response.send_message(embed=embed)
try:
latency = round(capy_discord.instance.latency * 1000) # in ms
message = f"Pong! {latency} ms Latency!"
embed = discord.Embed(title="Ping", description=message)
self.logger.info("/ping invoked user: %s guild: %s", interaction.user.id, interaction.guild_id)

await interaction.response.send_message(embed=embed)

except Exception:
self.logger.exception("/ping attempted user")
await interaction.response.send_message("We're sorry, this interaction failed. Please contact an admin.")


async def setup(bot: commands.Bot) -> None:
"""Set up the Ping cog."""
await bot.add_cog(PingCog())
await bot.add_cog(Ping())
33 changes: 17 additions & 16 deletions capy_discord/exts/tools/sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,29 +4,27 @@
- Manual sync via command
- Slash command sync
- Global sync

#TODO: Add sync status tracking
"""

import discord
from discord import app_commands
from discord.ext import commands

import capy_discord
from capy_discord.logging import get_logger


class SyncCog(commands.Cog):
class Sync(commands.Cog):
"""Cog for synchronizing application commands."""

async def _sync_commands(self) -> list[discord.app_commands.AppCommand]:
"""Synchronize commands with Discord.

Returns:
List of synced commands
def __init__(self) -> None:
"""Initialize the Sync cog."""
self.logger = get_logger(__name__)

#! Note: This operation can be rate limited
"""
async def _sync_commands(self) -> list[discord.app_commands.AppCommand]:
"""Synchronize commands with Discord."""
synced_commands: list[discord.app_commands.AppCommand] = await capy_discord.instance.tree.sync()
self.logger.info("_sync_commands internal: %s", synced_commands)
return synced_commands

@commands.command(name="sync", hidden=True)
Expand All @@ -36,24 +34,27 @@ async def sync(self, ctx: commands.Context[commands.Bot]) -> None:
synced = await self._sync_commands()

description = f"Synced {len(synced)} commands: {[cmd.name for cmd in synced]}"
self.logger.info("!sync invoked user: %s guild: %s", ctx.author.id, ctx.guild.id)
await ctx.send(description)

except Exception as e:
await ctx.send(f"Failed to sync commands: {e}")
except Exception:
self.logger.exception("!sync attempted with error")
await ctx.send("We're sorry, this interaction failed. Please contact an admin.")

@app_commands.command(name="sync", description="Sync application commands")
async def sync_slash(self, interaction: discord.Interaction) -> None:
"""Sync commands via slash command."""
try:
synced = await self._sync_commands()

description = f"Synced {len(synced)} commands: {[cmd.name for cmd in synced]}"
self.logger.info("/sync invoked user: %s guild: %s", interaction.user.id, interaction.guild_id)
await interaction.response.send_message(description)

except Exception as e:
await interaction.response.send_message(f"Failed to sync commands: {e}")
except Exception:
self.logger.exception("/sync attempted user with error")
await interaction.response.send_message("We're sorry, this interaction failed. Please contact an admin.")


async def setup(bot: commands.Bot) -> None:
"""Set up the Sync cog."""
await bot.add_cog(SyncCog(bot))
await bot.add_cog(Sync())
94 changes: 76 additions & 18 deletions capy_discord/logging.py
Original file line number Diff line number Diff line change
@@ -1,31 +1,89 @@
import datetime
import logging
import logging.handlers
import sys
from datetime import UTC, datetime
from logging.handlers import RotatingFileHandler
from pathlib import Path
from typing import Final

from capy_discord.config import settings
# Standard format: [Time] [Level] [Logger Name]: Message
LOG_FORMAT: Final[str] = "[%(asctime)s] [%(levelname)s] [%(name)s]: %(message)s"
DATE_FORMAT: Final[str] = "%Y-%m-%d %H:%M:%S"


def setup_logging() -> None:
"""Set up logging for the application."""
log_format = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
log_level = logging.getLevelNamesMapping()[settings.log_level.upper()]
log_file = f"{datetime.datetime.now(datetime.UTC).date()}.log"
class ColoredFormatter(logging.Formatter):
"""A custom logging formatter that adds colors to the output."""

# Create logs directory if it doesn't exist
def __init__(self, fmt: str, datefmt: str) -> None:
"""Initialize the ColoredFormatter."""
super().__init__(fmt, datefmt)
self.level_colors = {
logging.INFO: "\033[92m", # Green
logging.WARNING: "\033[93m", # Yellow
logging.ERROR: "\033[91m", # Red
logging.CRITICAL: "\033[91m", # Red
logging.DEBUG: "\033[94m", # Blue
}
self.name_color = "\033[96m" # Cyan
self.reset = "\033[0m"

def format(self, record: logging.LogRecord) -> str:
"""Format the log record."""
# Get the color for the level
level_color = self.level_colors.get(record.levelno, "")

# Temporarily add color to the levelname and name
original_levelname = record.levelname
original_name = record.name

record.levelname = f"{level_color}{original_levelname}{self.reset}"
record.name = f"{self.name_color}{original_name}{self.reset}"

# Format the message
formatted_message = super().format(record)

# Restore the original values
record.levelname = original_levelname
record.name = original_name

return formatted_message


def get_logger(name: str) -> logging.Logger:
"""Get a logger instance with the specified name."""
return logging.getLogger(name)


def setup_logging(level: int | str = logging.INFO) -> None:
"""Set up the logging configuration with the specified level."""
root_logger = logging.getLogger()
root_logger.setLevel(level)

# Create a handler that writes to stdout
stream_handler = logging.StreamHandler(sys.stdout)
colored_formatter = ColoredFormatter(LOG_FORMAT, datefmt=DATE_FORMAT)
stream_handler.setFormatter(colored_formatter)

# Create a log directory if it doesn't exist
log_dir = Path("logs")
log_dir.mkdir(exist_ok=True)
Comment thread
sourcery-ai[bot] marked this conversation as resolved.

# Root logger
logger = logging.getLogger()
logger.setLevel(log_level)
# Create a timestamped filename
timestamp = datetime.now(UTC).strftime("%Y-%m-%d_%H-%M-%S")
log_filename = log_dir / f"capy-discord_{timestamp}.log"

# File handler
file_handler = logging.handlers.RotatingFileHandler(
log_dir / log_file,
maxBytes=1024 * 1024 * 5, # 5 MB
# Create a handler that writes to a file with rotation
file_handler = RotatingFileHandler(
filename=log_filename,
maxBytes=5 * 1024 * 1024, # 5 MB
backupCount=5,
encoding="utf-8",
)
file_handler.setFormatter(logging.Formatter(log_format))
logger.addHandler(file_handler)
file_formatter = logging.Formatter(LOG_FORMAT, datefmt=DATE_FORMAT)
file_handler.setFormatter(file_formatter)

# Removing previous handlers to avoid duplicate logs from discord after setup_logging invocation
if root_logger.hasHandlers():
root_logger.handlers.clear()

root_logger.addHandler(stream_handler)
root_logger.addHandler(file_handler)
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ lint = { cmd = "pre-commit install && pre-commit run --all-files", help = "Insta
build = { cmd = "docker build -t capy-discord .", help = "Builds the application's Docker image." }
run = { cmd = "docker run -it --rm capy-discord", help = "Runs the application inside a new Docker container." }
dev = { cmd = "docker run -it --rm -v ./bot:/app/bot capy-discord", help = "Runs the app in Docker with local code mounted for live changes." }
test = { cmd = "pytest -n auto --ff", help = "Runs all tests, starting with previously failed ones." }
retest = { cmd = "pytest -n auto --lf", help = "Reruns only the tests that failed during the last run." }
test = { cmd = "pytest -n auto --ff || (code=$?; if [ $code -eq 5 ]; then exit 0; else exit $code; fi)", help = "Runs all tests, starting with previously failed ones." }
retest = { cmd = "pytest -n auto --lf || (code=$?; if [ $code -eq 5 ]; then exit 0; else exit $code; fi)", help = "Reruns only the tests that failed during the last run." }
testcov = { cmd = "pytest -n auto --cov=bot --cov-report= && coverage report", help = "Runs tests, generates coverage data, and fails if coverage is below 80%." }
commit = { cmd = "pre-commit install && git add . && git commit -m", help = "Stages all changes and commits with message." }

Expand Down