Compare commits

...

4 commits

Author SHA1 Message Date
e71558959c bot: cogs: add Debug cog and load extensions when bot is ready
All checks were successful
/ test (push) Successful in 18s
As the name implies, the Debug cog is solely there to debug stuff. In
the future the commands in that cog should only respond to whoever has
the provided `BOT_OWNER_ID` (me).

Additionally, extensions for discord.py are now loaded dynamically in
`main.py`.

Signed-off-by: Max R. Carrara <max@aequito.sh>
2025-03-14 00:14:08 +01:00
52d619377e bot: types: add types package
This package collects all kinds of useful type definitions that will
aid me in development.

Some things could be expanded in the future when they become necessary
(e.g. poll result embeds), but for now this should be enough.

Signed-off-by: Max R. Carrara <max@aequito.sh>
2025-03-14 00:12:09 +01:00
8ffe964386 bot: main: fix commands not being processed
Signed-off-by: Max R. Carrara <max@aequito.sh>
2025-03-14 00:11:41 +01:00
18f057793e bot: main: fix root logger not being initialised correctly
tl;dr: shouldn't have passed __name__ to logging.getLogger()

This also makes it so that discord.py is logging everything as
expected, which it didn't do before.

Signed-off-by: Max R. Carrara <max@aequito.sh>
2025-03-14 00:10:05 +01:00
5 changed files with 289 additions and 6 deletions

43
src/bot/cogs/debug.py Normal file
View file

@ -0,0 +1,43 @@
import asyncio
import logging
import discord
from discord.ext import commands
from bot.types import RichEmbed
_log = logging.getLogger(__name__)
class Debug(commands.Cog):
def __init__(self, bot: commands.Bot) -> None:
self.bot = bot
@commands.command()
async def ping(self, ctx: commands.Context):
_log.debug("pong!")
embed: RichEmbed = {
"type": "rich",
"description": "Pong."
}
await ctx.send(embed=discord.Embed.from_dict(embed))
async def setup(bot: commands.Bot):
_log.debug("Adding Debug cog")
await bot.add_cog(Debug(bot))
cog = bot.get_cog(Debug.__name__)
if cog is None:
return
_log.debug("Loaded Debug cog with the following commands:")
_log.debug([c.name for c in cog.get_commands()])
async def teardown(bot: commands.Bot):
_log.debug("Removing Debug cog")
await bot.remove_cog(Debug.__name__)

View file

@ -11,11 +11,14 @@ from discord.ext import commands
from bot.env import Environment from bot.env import Environment
_log: logging.Logger _log = logging.getLogger(__name__)
_default_extensions: dict[str, str] = {
"Debug": "bot.cogs.debug",
}
def setup_logging() -> logging.Logger: def setup_logging():
root_logger = logging.getLogger(__name__) root_logger = logging.getLogger()
log_level_map = { log_level_map = {
"NOTSET": logging.NOTSET, "NOTSET": logging.NOTSET,
@ -57,8 +60,6 @@ def setup_logging() -> logging.Logger:
log_error_file_handler.setLevel(logging.ERROR) log_error_file_handler.setLevel(logging.ERROR)
root_logger.addHandler(log_error_file_handler) root_logger.addHandler(log_error_file_handler)
return root_logger
def setup_bot() -> commands.Bot: def setup_bot() -> commands.Bot:
intents = discord.Intents.all() intents = discord.Intents.all()
@ -129,6 +130,17 @@ def register_event_handlers(bot: commands.Bot):
async def on_ready(): async def on_ready():
_log.info("Ready: Connected to Discord") _log.info("Ready: Connected to Discord")
_log.info("Loading extensions")
for ext_name, ext_path in _default_extensions.items():
try:
_log.info(f'Loading extension "{ext_name}" ({ext_path})')
await bot.load_extension(ext_path)
_log.info(f'Loaded extension "{ext_name}" ({ext_path})')
except Exception as e:
_log.error(
f'Failed to load extension "{ext_name}" ({ext_path})', exc_info=e
)
@bot.event @bot.event
async def on_resumed(): async def on_resumed():
_log.info("Resumed: Session with Discord resumed") _log.info("Resumed: Session with Discord resumed")
@ -144,6 +156,7 @@ def register_event_handlers(bot: commands.Bot):
@bot.event @bot.event
async def on_message(message: discord.Message): async def on_message(message: discord.Message):
_log.debug(f"Read message ({message.id = }): {message.content}") _log.debug(f"Read message ({message.id = }): {message.content}")
await bot.process_commands(message)
@bot.event @bot.event
async def on_command(ctx: commands.Context): async def on_command(ctx: commands.Context):
@ -181,7 +194,7 @@ async def main():
await bot.start(token=token, reconnect=True) await bot.start(token=token, reconnect=True)
_log = setup_logging() setup_logging()
_log.debug("Logging initialised") _log.debug("Logging initialised")
_log.debug(f"{sys.argv = }") _log.debug(f"{sys.argv = }")

41
src/bot/types/__init__.py Normal file
View file

@ -0,0 +1,41 @@
from datetime import date, datetime, timezone
from .embed import (
Embed,
RichEmbed,
ImageEmbed,
VideoEmbed,
GifvEmbed,
ArticleEmbed,
LinkEmbed,
)
__all__ = [
"Embed",
"RichEmbed",
"ImageEmbed",
"VideoEmbed",
"GifvEmbed",
"ArticleEmbed",
"LinkEmbed",
"ISO8601Timestamp",
]
class ISO8601Timestamp:
def __init__(self, dt: date | datetime) -> None:
self.dt = dt
def __str__(self) -> str:
return self.dt.isoformat()
def __repr__(self) -> str:
return f"ISO8601Timestamp({repr(self.dt)})"
@classmethod
def today(cls):
return cls(date.today())
@classmethod
def now(cls, tz: timezone | None):
return cls(datetime.now(tz))

166
src/bot/types/embed.py Normal file
View file

@ -0,0 +1,166 @@
from typing import Annotated, Literal, TypedDict
from annotated_types import Len
import discord
from .timestamp import ISO8601Timestamp
class _EmbedFooterReq(TypedDict):
text: str
class _EmbedFooterOpt(TypedDict, total=False):
icon_url: str
proxy_icon_url: str
class EmbedFooter(_EmbedFooterReq, _EmbedFooterOpt):
pass
class _EmbedImageReq(TypedDict):
url: str
class _EmbedImageOpt(TypedDict, total=False):
proxy_url: str
height: int
width: int
class EmbedImage(_EmbedImageReq, _EmbedImageOpt):
pass
class _EmbedThumbnailReq(TypedDict):
url: str
class _EmbedThumbnailOpt(TypedDict, total=False):
proxy_url: str
height: int
width: int
class EmbedThumbnail(_EmbedThumbnailReq, _EmbedThumbnailOpt):
pass
class EmbedVideo(TypedDict, total=False):
url: str
proxy_url: str
height: int
width: int
class EmbedProvider(TypedDict, total=False):
name: str
url: str
class EmbedAuthor(TypedDict, total=False):
name: str
url: str
icon_url: str
proxy_icon_url: str
class _EmbedFieldReq(TypedDict):
name: str
value: str
class _EmbedFieldOpt(TypedDict, total=False):
inline: bool
class EmbedField(_EmbedFieldReq, _EmbedFieldOpt):
pass
class _EmbedBase(TypedDict, total=False):
title: str
description: str
url: str
timestamp: ISO8601Timestamp
color: discord.Colour | int
footer: EmbedFooter
image: EmbedImage
thumbnail: EmbedThumbnail
video: EmbedVideo
provider: EmbedProvider
author: EmbedAuthor
fields: Annotated[list[EmbedField], Len(max_length=25)]
class RichEmbedImpl(TypedDict):
type: Literal["rich"]
class RichEmbed(_EmbedBase, RichEmbedImpl):
pass
class ImageEmbedImpl(TypedDict):
type: Literal["image"]
class ImageEmbed(_EmbedBase, ImageEmbedImpl):
pass
class VideoEmbedImpl(TypedDict):
type: Literal["video"]
class VideoEmbed(_EmbedBase, VideoEmbedImpl):
pass
class GifvEmbedImpl(TypedDict):
type: Literal["givf"]
class GifvEmbed(_EmbedBase, GifvEmbedImpl):
pass
class ArticleEmbedImpl(TypedDict):
type: Literal["article"]
class ArticleEmbed(_EmbedBase, ArticleEmbedImpl):
pass
class LinkEmbedImpl(TypedDict):
type: Literal["link"]
class LinkEmbed(_EmbedBase, LinkEmbedImpl):
pass
class PollResultEmbedImpl(TypedDict):
type: Literal["poll_result"]
# TODO: There are a bunch of fields for poll results, but I *really*
# don't need these at the moment:
# https://discord.com/developers/docs/resources/message#embed-fields-by-embed-type-poll-result-embed-fields
class PollResultEmbed(_EmbedBase, PollResultEmbedImpl):
pass
Embed = (
RichEmbed
| ImageEmbed
| VideoEmbed
| GifvEmbed
| ArticleEmbed
| LinkEmbed
| PollResultEmbed
)

View file

@ -0,0 +1,20 @@
from datetime import date, datetime, timezone
class ISO8601Timestamp:
def __init__(self, dt: date | datetime) -> None:
self.dt = dt
def __str__(self) -> str:
return self.dt.isoformat()
def __repr__(self) -> str:
return f"ISO8601Timestamp({repr(self.dt)})"
@classmethod
def today(cls):
return cls(date.today())
@classmethod
def now(cls, tz: timezone | None):
return cls(datetime.now(tz))