|
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