-
-
Notifications
You must be signed in to change notification settings - Fork 43
feat: implement core bot infrastructure and monitoring system #957
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 28 commits
40a6d03
155bb58
12c9d7d
9850ff4
cd34a6a
138719d
2828d41
f8106d5
d2bfbb0
ceef3d8
60ca11d
e2b998e
3772808
3817168
f13c30b
51ba4d4
5efa11d
25365f5
a55c50c
270b4e1
93b9d45
7d833e6
b4d7d90
11c5d90
4394b40
b22618d
b216cee
e46b4a3
3440841
cf6a7bb
fc16eab
252a56b
7b06cc9
c38c843
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,17 +1,27 @@ | ||
"""TuxApp: Orchestration and lifecycle management for the Tux Discord bot.""" | ||
""" | ||
TuxApp: Main application entrypoint and lifecycle orchestrator. | ||
|
||
This module contains the `TuxApp` class, which serves as the primary entrypoint | ||
for the Tux Discord bot. It is responsible for: | ||
|
||
- **Environment Setup**: Validating configuration, initializing Sentry, and setting | ||
up OS-level signal handlers for graceful shutdown. | ||
- **Bot Instantiation**: Creating the instance of the `Tux` bot class with the | ||
appropriate intents, command prefix logic, and owner IDs. | ||
- **Lifecycle Management**: Starting the asyncio event loop and managing the | ||
bot's main `start` and `shutdown` sequence, including handling `KeyboardInterrupt`. | ||
""" | ||
|
||
import asyncio | ||
import signal | ||
from types import FrameType | ||
|
||
import discord | ||
import sentry_sdk | ||
from loguru import logger | ||
|
||
from tux.bot import Tux | ||
from tux.help import TuxHelp | ||
from tux.utils.config import CONFIG | ||
from tux.utils.env import get_current_env | ||
from tux.utils.sentry_manager import SentryManager | ||
|
||
|
||
async def get_prefix(bot: Tux, message: discord.Message) -> list[str]: | ||
|
@@ -28,129 +38,129 @@ | |
|
||
|
||
class TuxApp: | ||
"""Orchestrates the startup, shutdown, and environment for the Tux bot.""" | ||
|
||
def __init__(self): | ||
"""Initialize the TuxApp with no bot instance yet.""" | ||
self.bot = None | ||
|
||
def run(self) -> None: | ||
"""Run the Tux bot application (entrypoint for CLI).""" | ||
asyncio.run(self.start()) | ||
|
||
def setup_sentry(self) -> None: | ||
"""Initialize Sentry for error monitoring and tracing.""" | ||
if not CONFIG.SENTRY_DSN: | ||
logger.warning("No Sentry DSN configured, skipping Sentry setup") | ||
return | ||
|
||
logger.info("Setting up Sentry...") | ||
|
||
try: | ||
sentry_sdk.init( | ||
dsn=CONFIG.SENTRY_DSN, | ||
release=CONFIG.BOT_VERSION, | ||
environment=get_current_env(), | ||
enable_tracing=True, | ||
attach_stacktrace=True, | ||
send_default_pii=False, | ||
traces_sample_rate=1.0, | ||
profiles_sample_rate=1.0, | ||
_experiments={ | ||
"enable_logs": True, # https://docs.sentry.io/platforms/python/logs/ | ||
}, | ||
) | ||
|
||
# Add additional global tags | ||
sentry_sdk.set_tag("discord_library_version", discord.__version__) | ||
""" | ||
Orchestrates the startup, shutdown, and environment for the Tux bot. | ||
|
||
logger.info(f"Sentry initialized: {sentry_sdk.is_initialized()}") | ||
This class is not a `discord.py` cog, but rather a top-level application | ||
runner that manages the bot's entire lifecycle from an OS perspective. | ||
""" | ||
|
||
except Exception as e: | ||
logger.error(f"Failed to initialize Sentry: {e}") | ||
|
||
def setup_signals(self) -> None: | ||
"""Set up signal handlers for graceful shutdown.""" | ||
signal.signal(signal.SIGTERM, self.handle_sigterm) | ||
signal.signal(signal.SIGINT, self.handle_sigterm) | ||
# --- Initialization --- | ||
|
||
def handle_sigterm(self, signum: int, frame: FrameType | None) -> None: | ||
"""Handle SIGTERM/SIGINT by raising KeyboardInterrupt for graceful shutdown.""" | ||
logger.info(f"Received signal {signum}") | ||
def __init__(self): | ||
"""Initializes the TuxApp, setting the bot instance to None initially.""" | ||
self.bot: Tux | None = None | ||
|
||
if sentry_sdk.is_initialized(): | ||
with sentry_sdk.push_scope() as scope: | ||
scope.set_tag("signal.number", signum) | ||
scope.set_tag("lifecycle.event", "termination_signal") | ||
# --- Application Lifecycle --- | ||
|
||
sentry_sdk.add_breadcrumb( | ||
category="lifecycle", | ||
message=f"Received termination signal {signum}", | ||
level="info", | ||
) | ||
def run(self) -> None: | ||
""" | ||
The main synchronous entrypoint for the application. | ||
|
||
raise KeyboardInterrupt | ||
This method starts the asyncio event loop and runs the primary `start` | ||
coroutine, effectively launching the bot. | ||
""" | ||
asyncio.run(self.start()) | ||
|
||
def validate_config(self) -> bool: | ||
"""Validate that all required configuration is present.""" | ||
if not CONFIG.BOT_TOKEN: | ||
logger.critical("No bot token provided. Set DEV_BOT_TOKEN or PROD_BOT_TOKEN in your .env file.") | ||
return False | ||
async def start(self) -> None: | ||
""" | ||
The main asynchronous entrypoint for the application. | ||
|
||
return True | ||
This method orchestrates the entire bot startup sequence: setting up | ||
Sentry and signal handlers, validating config, creating the `Tux` | ||
instance, and connecting to Discord. It includes a robust | ||
try/except/finally block to ensure graceful shutdown. | ||
""" | ||
|
||
async def start(self) -> None: | ||
"""Start the Tux bot, handling setup, errors, and shutdown.""" | ||
self.setup_sentry() | ||
# Initialize Sentry | ||
SentryManager.setup() | ||
|
||
# Set up signal handlers | ||
self.setup_signals() | ||
|
||
# Validate config | ||
if not self.validate_config(): | ||
return | ||
|
||
# Configure owner IDs, dynamically adding sysadmins if configured. | ||
# This allows specified users to have access to sensitive commands like `eval`. | ||
owner_ids = {CONFIG.BOT_OWNER_ID} | ||
|
||
if CONFIG.ALLOW_SYSADMINS_EVAL: | ||
logger.warning( | ||
"⚠️ Eval is enabled for sysadmins, this is potentially dangerous; see settings.yml.example for more info.", | ||
"⚠️ Eval is enabled for sysadmins, this is potentially dangerous; " | ||
"see settings.yml.example for more info.", | ||
) | ||
owner_ids.update(CONFIG.SYSADMIN_IDS) | ||
|
||
else: | ||
logger.warning("🔒️ Eval is disabled for sysadmins; see settings.yml.example for more info.") | ||
|
||
# Instantiate the main bot class with all necessary parameters. | ||
self.bot = Tux( | ||
command_prefix=get_prefix, | ||
strip_after_prefix=True, | ||
case_insensitive=True, | ||
intents=discord.Intents.all(), | ||
# owner_ids={CONFIG.BOT_OWNER_ID, *CONFIG.SYSADMIN_IDS}, | ||
owner_ids=owner_ids, | ||
allowed_mentions=discord.AllowedMentions(everyone=False), | ||
help_command=TuxHelp(), | ||
activity=None, | ||
status=discord.Status.online, | ||
) | ||
|
||
# Start the bot | ||
try: | ||
# This is the main blocking call that connects to Discord and runs the bot. | ||
await self.bot.start(CONFIG.BOT_TOKEN, reconnect=True) | ||
|
||
except KeyboardInterrupt: | ||
# This is caught when the user presses Ctrl+C. | ||
logger.info("Shutdown requested (KeyboardInterrupt)") | ||
except Exception as e: | ||
logger.critical(f"Bot failed to start: {e}") | ||
await self.shutdown() | ||
|
||
# Catch any other unexpected exception during bot runtime. | ||
logger.critical(f"Bot failed to start or run: {e}") | ||
finally: | ||
# Ensure that shutdown is always called to clean up resources. | ||
await self.shutdown() | ||
|
||
async def shutdown(self) -> None: | ||
"""Gracefully shut down the bot and flush Sentry.""" | ||
""" | ||
Gracefully shuts down the bot and its resources. | ||
|
||
This involves calling the bot's internal shutdown sequence and then | ||
flushing any remaining Sentry events to ensure all data is sent. | ||
""" | ||
if self.bot and not self.bot.is_closed(): | ||
await self.bot.shutdown() | ||
|
||
if sentry_sdk.is_initialized(): | ||
sentry_sdk.flush() | ||
await asyncio.sleep(0.1) | ||
SentryManager.flush() | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. suggestion (performance): Consider making Sentry flush asynchronous. Using a synchronous flush may block the event loop if there are pending events. If possible, use an async flush to prevent blocking during shutdown. Suggested implementation: await SentryManager.flush_async()
await asyncio.sleep(0.1) # Brief pause to allow buffers to flush If import asyncio
class SentryManager:
@staticmethod
async def flush_async(timeout=None):
loop = asyncio.get_running_loop()
await loop.run_in_executor(None, SentryManager.flush, timeout) Make sure to update all usages accordingly. |
||
await asyncio.sleep(0.1) # Brief pause to allow buffers to flush | ||
|
||
logger.info("Shutdown complete") | ||
|
||
# --- Environment Setup --- | ||
|
||
def setup_signals(self) -> None: | ||
""" | ||
Sets up OS-level signal handlers for graceful shutdown. | ||
|
||
This ensures that when the bot process receives a SIGINT (Ctrl+C) or | ||
SIGTERM (from systemd or Docker), it is intercepted and handled | ||
cleanly instead of causing an abrupt exit. | ||
""" | ||
signal.signal(signal.SIGTERM, SentryManager.report_signal) | ||
signal.signal(signal.SIGINT, SentryManager.report_signal) | ||
|
||
def validate_config(self) -> bool: | ||
""" | ||
Performs a pre-flight check for essential configuration. | ||
|
||
Returns | ||
------- | ||
bool | ||
True if the configuration is valid, False otherwise. | ||
""" | ||
if not CONFIG.BOT_TOKEN: | ||
logger.critical("No bot token provided. Set DEV_BOT_TOKEN or PROD_BOT_TOKEN in your .env file.") | ||
return False | ||
|
||
return True | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
suggestion: Signal handler setup may not be cross-platform safe.
Consider using
loop.add_signal_handler
for better cross-platform compatibility, or document any platform-specific limitations if this isn't feasible.Suggested implementation: