|
1 |
| -"""TuxApp: Orchestration and lifecycle management for the Tux Discord bot.""" |
| 1 | +""" |
| 2 | +TuxApp: Main application entrypoint and lifecycle orchestrator. |
| 3 | +
|
| 4 | +This module contains the `TuxApp` class, which serves as the primary entrypoint |
| 5 | +for the Tux Discord bot. It is responsible for: |
| 6 | +
|
| 7 | +- **Environment Setup**: Validating configuration, initializing Sentry, and setting |
| 8 | + up OS-level signal handlers for graceful shutdown. |
| 9 | +- **Bot Instantiation**: Creating the instance of the `Tux` bot class with the |
| 10 | + appropriate intents, command prefix logic, and owner IDs. |
| 11 | +- **Lifecycle Management**: Starting the asyncio event loop and managing the |
| 12 | + bot's main `start` and `shutdown` sequence, including handling `KeyboardInterrupt`. |
| 13 | +""" |
2 | 14 |
|
3 | 15 | import asyncio
|
4 | 16 | import signal
|
5 |
| -from types import FrameType |
6 | 17 |
|
7 | 18 | import discord
|
8 |
| -import sentry_sdk |
9 | 19 | from loguru import logger
|
10 | 20 |
|
11 | 21 | from tux.bot import Tux
|
12 | 22 | from tux.help import TuxHelp
|
13 | 23 | from tux.utils.config import CONFIG
|
14 |
| -from tux.utils.env import get_current_env |
15 |
| - |
16 |
| - |
17 |
| -async def get_prefix(bot: Tux, message: discord.Message) -> list[str]: |
18 |
| - """Resolve the command prefix for a guild or use the default prefix.""" |
19 |
| - prefix: str | None = None |
20 |
| - if message.guild: |
21 |
| - try: |
22 |
| - from tux.database.controllers import DatabaseController # noqa: PLC0415 |
23 |
| - |
24 |
| - prefix = await DatabaseController().guild_config.get_guild_prefix(message.guild.id) |
25 |
| - except Exception as e: |
26 |
| - logger.error(f"Error getting guild prefix: {e}") |
27 |
| - return [prefix or CONFIG.DEFAULT_PREFIX] |
| 24 | +from tux.utils.sentry_manager import SentryManager |
28 | 25 |
|
29 | 26 |
|
30 | 27 | class TuxApp:
|
31 |
| - """Orchestrates the startup, shutdown, and environment for the Tux bot.""" |
32 |
| - |
33 |
| - def __init__(self): |
34 |
| - """Initialize the TuxApp with no bot instance yet.""" |
35 |
| - self.bot = None |
36 |
| - |
37 |
| - def run(self) -> None: |
38 |
| - """Run the Tux bot application (entrypoint for CLI).""" |
39 |
| - asyncio.run(self.start()) |
| 28 | + """ |
| 29 | + Orchestrates the startup, shutdown, and environment for the Tux bot. |
40 | 30 |
|
41 |
| - def setup_sentry(self) -> None: |
42 |
| - """Initialize Sentry for error monitoring and tracing.""" |
43 |
| - if not CONFIG.SENTRY_DSN: |
44 |
| - logger.warning("No Sentry DSN configured, skipping Sentry setup") |
45 |
| - return |
46 |
| - |
47 |
| - logger.info("Setting up Sentry...") |
48 |
| - |
49 |
| - try: |
50 |
| - sentry_sdk.init( |
51 |
| - dsn=CONFIG.SENTRY_DSN, |
52 |
| - release=CONFIG.BOT_VERSION, |
53 |
| - environment=get_current_env(), |
54 |
| - enable_tracing=True, |
55 |
| - attach_stacktrace=True, |
56 |
| - send_default_pii=False, |
57 |
| - traces_sample_rate=1.0, |
58 |
| - profiles_sample_rate=1.0, |
59 |
| - _experiments={ |
60 |
| - "enable_logs": True, # https://docs.sentry.io/platforms/python/logs/ |
61 |
| - }, |
62 |
| - ) |
63 |
| - |
64 |
| - # Add additional global tags |
65 |
| - sentry_sdk.set_tag("discord_library_version", discord.__version__) |
| 31 | + This class is not a `discord.py` cog, but rather a top-level application |
| 32 | + runner that manages the bot's entire lifecycle from an OS perspective. |
| 33 | + """ |
66 | 34 |
|
67 |
| - logger.info(f"Sentry initialized: {sentry_sdk.is_initialized()}") |
| 35 | + # --- Initialization --- |
68 | 36 |
|
69 |
| - except Exception as e: |
70 |
| - logger.error(f"Failed to initialize Sentry: {e}") |
71 |
| - |
72 |
| - def setup_signals(self) -> None: |
73 |
| - """Set up signal handlers for graceful shutdown.""" |
74 |
| - signal.signal(signal.SIGTERM, self.handle_sigterm) |
75 |
| - signal.signal(signal.SIGINT, self.handle_sigterm) |
76 |
| - |
77 |
| - def handle_sigterm(self, signum: int, frame: FrameType | None) -> None: |
78 |
| - """Handle SIGTERM/SIGINT by raising KeyboardInterrupt for graceful shutdown.""" |
79 |
| - logger.info(f"Received signal {signum}") |
| 37 | + def __init__(self): |
| 38 | + """Initializes the TuxApp, setting the bot instance to None initially.""" |
| 39 | + self.bot: Tux | None = None |
80 | 40 |
|
81 |
| - if sentry_sdk.is_initialized(): |
82 |
| - with sentry_sdk.push_scope() as scope: |
83 |
| - scope.set_tag("signal.number", signum) |
84 |
| - scope.set_tag("lifecycle.event", "termination_signal") |
| 41 | + # --- Application Lifecycle --- |
85 | 42 |
|
86 |
| - sentry_sdk.add_breadcrumb( |
87 |
| - category="lifecycle", |
88 |
| - message=f"Received termination signal {signum}", |
89 |
| - level="info", |
90 |
| - ) |
| 43 | + def run(self) -> None: |
| 44 | + """ |
| 45 | + The main synchronous entrypoint for the application. |
91 | 46 |
|
92 |
| - raise KeyboardInterrupt |
| 47 | + This method starts the asyncio event loop and runs the primary `start` |
| 48 | + coroutine, effectively launching the bot. |
| 49 | + """ |
| 50 | + asyncio.run(self.start()) |
93 | 51 |
|
94 |
| - def validate_config(self) -> bool: |
95 |
| - """Validate that all required configuration is present.""" |
96 |
| - if not CONFIG.BOT_TOKEN: |
97 |
| - logger.critical("No bot token provided. Set DEV_BOT_TOKEN or PROD_BOT_TOKEN in your .env file.") |
98 |
| - return False |
| 52 | + async def start(self) -> None: |
| 53 | + """ |
| 54 | + The main asynchronous entrypoint for the application. |
99 | 55 |
|
100 |
| - return True |
| 56 | + This method orchestrates the entire bot startup sequence: setting up |
| 57 | + Sentry and signal handlers, validating config, creating the `Tux` |
| 58 | + instance, and connecting to Discord. It includes a robust |
| 59 | + try/except/finally block to ensure graceful shutdown. |
| 60 | + """ |
101 | 61 |
|
102 |
| - async def start(self) -> None: |
103 |
| - """Start the Tux bot, handling setup, errors, and shutdown.""" |
104 |
| - self.setup_sentry() |
| 62 | + # Initialize Sentry |
| 63 | + SentryManager.setup() |
105 | 64 |
|
| 65 | + # Set up signal handlers |
106 | 66 | self.setup_signals()
|
107 | 67 |
|
| 68 | + # Validate config |
108 | 69 | if not self.validate_config():
|
109 | 70 | return
|
110 | 71 |
|
| 72 | + # Configure owner IDs, dynamically adding sysadmins if configured. |
| 73 | + # This allows specified users to have access to sensitive commands like `eval`. |
111 | 74 | owner_ids = {CONFIG.BOT_OWNER_ID}
|
112 |
| - |
113 | 75 | if CONFIG.ALLOW_SYSADMINS_EVAL:
|
114 | 76 | logger.warning(
|
115 |
| - "⚠️ Eval is enabled for sysadmins, this is potentially dangerous; see settings.yml.example for more info.", |
| 77 | + "⚠️ Eval is enabled for sysadmins, this is potentially dangerous; " |
| 78 | + "see settings.yml.example for more info.", |
116 | 79 | )
|
117 | 80 | owner_ids.update(CONFIG.SYSADMIN_IDS)
|
118 |
| - |
119 | 81 | else:
|
120 | 82 | logger.warning("🔒️ Eval is disabled for sysadmins; see settings.yml.example for more info.")
|
121 | 83 |
|
| 84 | + # Instantiate the main bot class with all necessary parameters. |
122 | 85 | self.bot = Tux(
|
123 |
| - command_prefix=get_prefix, |
124 | 86 | strip_after_prefix=True,
|
125 | 87 | case_insensitive=True,
|
126 | 88 | intents=discord.Intents.all(),
|
127 |
| - # owner_ids={CONFIG.BOT_OWNER_ID, *CONFIG.SYSADMIN_IDS}, |
128 | 89 | owner_ids=owner_ids,
|
129 | 90 | allowed_mentions=discord.AllowedMentions(everyone=False),
|
130 | 91 | help_command=TuxHelp(),
|
131 | 92 | activity=None,
|
132 | 93 | status=discord.Status.online,
|
133 | 94 | )
|
134 | 95 |
|
| 96 | + # Start the bot |
135 | 97 | try:
|
| 98 | + # This is the main blocking call that connects to Discord and runs the bot. |
136 | 99 | await self.bot.start(CONFIG.BOT_TOKEN, reconnect=True)
|
137 | 100 |
|
138 | 101 | except KeyboardInterrupt:
|
| 102 | + # This is caught when the user presses Ctrl+C. |
139 | 103 | logger.info("Shutdown requested (KeyboardInterrupt)")
|
140 | 104 | except Exception as e:
|
141 |
| - logger.critical(f"Bot failed to start: {e}") |
142 |
| - await self.shutdown() |
143 |
| - |
| 105 | + # Catch any other unexpected exception during bot runtime. |
| 106 | + logger.critical(f"Bot failed to start or run: {e}") |
144 | 107 | finally:
|
| 108 | + # Ensure that shutdown is always called to clean up resources. |
145 | 109 | await self.shutdown()
|
146 | 110 |
|
147 | 111 | async def shutdown(self) -> None:
|
148 |
| - """Gracefully shut down the bot and flush Sentry.""" |
| 112 | + """ |
| 113 | + Gracefully shuts down the bot and its resources. |
| 114 | +
|
| 115 | + This involves calling the bot's internal shutdown sequence and then |
| 116 | + flushing any remaining Sentry events to ensure all data is sent. |
| 117 | + """ |
149 | 118 | if self.bot and not self.bot.is_closed():
|
150 | 119 | await self.bot.shutdown()
|
151 | 120 |
|
152 |
| - if sentry_sdk.is_initialized(): |
153 |
| - sentry_sdk.flush() |
154 |
| - await asyncio.sleep(0.1) |
| 121 | + SentryManager.flush() |
| 122 | + await asyncio.sleep(0.1) # Brief pause to allow buffers to flush |
155 | 123 |
|
156 | 124 | logger.info("Shutdown complete")
|
| 125 | + |
| 126 | + # --- Environment Setup --- |
| 127 | + |
| 128 | + def setup_signals(self) -> None: |
| 129 | + """ |
| 130 | + Sets up OS-level signal handlers for graceful shutdown. |
| 131 | +
|
| 132 | + This ensures that when the bot process receives a SIGINT (Ctrl+C) or |
| 133 | + SIGTERM (from systemd or Docker), it is intercepted and handled |
| 134 | + cleanly instead of causing an abrupt exit. |
| 135 | + """ |
| 136 | + signal.signal(signal.SIGTERM, SentryManager.report_signal) |
| 137 | + signal.signal(signal.SIGINT, SentryManager.report_signal) |
| 138 | + |
| 139 | + def validate_config(self) -> bool: |
| 140 | + """ |
| 141 | + Performs a pre-flight check for essential configuration. |
| 142 | +
|
| 143 | + Returns |
| 144 | + ------- |
| 145 | + bool |
| 146 | + True if the configuration is valid, False otherwise. |
| 147 | + """ |
| 148 | + if not CONFIG.BOT_TOKEN: |
| 149 | + logger.critical("No bot token provided. Set DEV_BOT_TOKEN or PROD_BOT_TOKEN in your .env file.") |
| 150 | + return False |
| 151 | + |
| 152 | + return True |
0 commit comments