""" main.py — RepoRadar entrypoint. A standalone Discord bot that watches a self-hosted/hosted git server (Gitea, Forgejo, GitLab or GitHub) and drops the *newest* projects into Discord with descriptions, community voting and admin-curated highlights. Nextcord 2.6 boot pattern (learned the hard way): * Construct the Bot *inside* the running asyncio loop (avoids dual-loop "heartbeat blocked" deadlocks). * `setup_hook` is NOT called with `bot.start()` in 2.6 — load extensions manually in our own `setup()` before `bot.start()`. * `add_cog()` attaches commands to the cog but does NOT push them into the bot's rollout set — iterate each cog's `application_commands` and call `add_application_command(cmd, use_rollout=True)` with the guild id. * Bot is NOT an async context manager — use try/finally + `await bot.close()`. """ from __future__ import annotations import asyncio import logging import signal import nextcord from nextcord.ext import commands from config import settings from utils.git_api import GitClient from utils.helpers import Store logging.basicConfig( level=getattr(logging, settings.log_level, logging.INFO), format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", ) log = logging.getLogger("reporadar") EXTENSIONS = ("cogs.gitscan",) class RepoRadar(commands.Bot): """The RepoRadar bot — owns the git client and the SQLite store.""" def __init__(self) -> None: intents = nextcord.Intents.default() super().__init__(intents=intents) self.git = GitClient() self.store = Store(settings.db_path) async def setup(self) -> None: """Initialise resources and load + register cogs (called before start).""" settings.ensure_dirs() await self.store.init() await self.git.start() for ext in EXTENSIONS: self.load_extension(ext) log.info("Loaded extension %s", ext) # Push each cog's slash commands into the rollout set (Nextcord 2.6 quirk). registered = 0 for cog in self.cogs.values(): for cmd in cog.application_commands: if settings.guild_id: cmd.guild_ids_to_rollout.add(settings.guild_id) self.add_application_command(cmd, use_rollout=True) registered += 1 log.info("Registered %d application command(s)", registered) async def on_ready(self) -> None: log.info("Logged in as %s (id=%s)", self.user, self.user.id if self.user else "?") await self.change_presence( activity=nextcord.Activity( type=nextcord.ActivityType.watching, name="git servers for fresh drops ☣", ) ) async def close(self) -> None: log.info("Shutting down — closing git session…") await self.git.close() await super().close() async def runner() -> None: if not settings.discord_token: raise SystemExit("DISCORD_TOKEN is not set. Configure it in .env before starting.") # Construct the bot INSIDE the running loop (Nextcord 2.6 requirement). bot = RepoRadar() loop = asyncio.get_running_loop() stop = asyncio.Event() for sig in (signal.SIGINT, signal.SIGTERM): try: loop.add_signal_handler(sig, stop.set) except NotImplementedError: # Windows event loop doesn't support signal handlers — ignore. pass await bot.setup() async def _shutdown_watcher() -> None: await stop.wait() log.info("Shutdown signal received") if not bot.is_closed(): await bot.close() watcher = asyncio.create_task(_shutdown_watcher()) try: await bot.start(settings.discord_token) finally: stop.set() watcher.cancel() if not bot.is_closed(): await bot.close() if __name__ == "__main__": try: asyncio.run(runner()) except (KeyboardInterrupt, SystemExit) as exc: log.info("RepoRadar stopped: %s", exc)