""" cogs/gitscan.py β€” continual git-server project monitor + community voting. What it does ============ * **Continual monitoring** of a configured git server (Gitea/Forgejo, GitLab or GitHub) β€” either scoped to specific users/orgs OR server-wide. * **Newest-only drops**: existing repositories are baselined silently the moment a watch is added, so the bot only announces *brand-new* projects afterwards. * **Chronological drops**: new projects are posted oldestβ†’newest so the freshest project is always the most recent message in the channel. * **Rich drop cards**: each drop links the project with a description, language, stars/forks, topics and a live community vote tally. * **Community voting** three ways: πŸ‘/πŸ‘Ž buttons on the drop, a πŸ’¬ vote+comment modal, and the `/radar_vote` slash command. * **Highlights**: projects matching configured keywords or a star threshold are re-posted to a highlight channel with an optional role ping. * **Admin modal editing**: admins can edit a drop's curator note / description in place, and configure everything (channels, keywords, watches) via modals. Slash commands ============== /radar_config (admin) β€” drop/highlight channels, role, keywords, stars /radar_watch (admin) β€” add a user/org/server-wide watch (modal) /radar_unwatch (admin) β€” remove a watch /radar_status β€” show current monitor configuration /radar_latest β€” newest projects on demand /radar_scan (admin) β€” force a scan cycle now /radar_vote β€” vote on a project from a slash command /radar_top β€” community vote leaderboard """ from __future__ import annotations import logging import nextcord from nextcord import Interaction, SlashOption from nextcord.ext import commands, tasks from config import settings from utils.embeds import ( build_error_embed, build_git_leaderboard_embed, build_git_status_embed, build_info_embed, build_repo_drop_embed, build_repo_list_embed, ) from utils.git_api import Repo log = logging.getLogger("reporadar.cogs.gitscan") GUILD_IDS = [settings.guild_id] if settings.guild_id else None # Static custom_ids so a single persistent view routes every drop's buttons, # even after a bot restart. The repo is resolved from the message id via SQLite. CID_UP = "radarvote:up" CID_DOWN = "radarvote:down" CID_COMMENT = "radarvote:comment" CID_EDIT = "radarvote:edit" VOTE_FIELD_NAME = "β—ˆ πŸ—³ Community" def _is_admin(interaction: Interaction) -> bool: """True when the invoking member has Manage Server (admins and higher).""" perms = interaction.user.guild_permissions if interaction.guild else None return bool(perms and perms.manage_guild) def _vote_value(votes: dict[str, int]) -> str: return f"πŸ‘ `{votes['up']}` πŸ‘Ž `{votes['down']}` β€’ net `{votes['score']:+d}`" # ════════════════════════════════════════════════════════════════ # Voting / editing UI # ════════════════════════════════════════════════════════════════ class VoteCommentModal(nextcord.ui.Modal): """Lets a member cast a vote with an optional public comment.""" def __init__(self, cog: "RepoScanCog", repo_key: str, message_id: int | None) -> None: super().__init__(title="Vote on this project", timeout=300) self._cog = cog self._repo_key = repo_key self._message_id = message_id self.direction = nextcord.ui.TextInput( label="Vote (up / down)", placeholder="up β€”orβ€” down", default_value="up", required=True, max_length=5, style=nextcord.TextInputStyle.short, ) self.add_item(self.direction) self.comment = nextcord.ui.TextInput( label="Comment (optional)", placeholder="Why is this project worth a look?", required=False, max_length=300, style=nextcord.TextInputStyle.paragraph, ) self.add_item(self.comment) async def callback(self, interaction: Interaction) -> None: raw = (self.direction.value or "up").strip().lower() value = -1 if raw in ("down", "d", "-1", "πŸ‘Ž") else 1 await self._cog.register_vote( interaction, self._repo_key, value, comment=(self.comment.value or "").strip(), message_id=self._message_id, ) class EditDropModal(nextcord.ui.Modal): """Admin-only in-place edit of a drop's description + curator note.""" def __init__( self, cog: "RepoScanCog", message: nextcord.Message, current_desc: str, current_note: str, ) -> None: super().__init__(title="Edit project drop", timeout=300) self._cog = cog self._message = message self.description = nextcord.ui.TextInput( label="Description", default_value=(current_desc or "")[:1000], required=False, max_length=1000, style=nextcord.TextInputStyle.paragraph, ) self.add_item(self.description) self.note = nextcord.ui.TextInput( label="Curator note (shown on the card)", default_value=(current_note or "")[:300], placeholder="e.g. Verified working PoC β€” worth a look", required=False, max_length=300, style=nextcord.TextInputStyle.paragraph, ) self.add_item(self.note) async def callback(self, interaction: Interaction) -> None: await interaction.response.defer(ephemeral=True) if not self._message.embeds: await interaction.followup.send( embed=build_error_embed("Edit Failed", "The drop message has no embed."), ephemeral=True, ) return embed = self._message.embeds[0] new_desc = (self.description.value or "").strip() if new_desc: embed.description = new_desc # Upsert the curator-note field (preserve everything else). note = (self.note.value or "").strip() note_field = "β—ˆ Curator Note" existing_idx = next( (i for i, f in enumerate(embed.fields) if f.name == note_field), None ) if existing_idx is not None: if note: embed.set_field_at(existing_idx, name=note_field, value=note, inline=False) else: embed.remove_field(existing_idx) elif note: # Insert near the top so it reads as an editorial highlight. embed.insert_field_at(0, name=note_field, value=note, inline=False) try: await self._message.edit(embed=embed) except nextcord.DiscordException as exc: await interaction.followup.send( embed=build_error_embed("Edit Failed", f"Could not update the message: `{exc}`"), ephemeral=True, ) return await interaction.followup.send( embed=build_info_embed("Drop Updated", "Your edits are live on the project card."), ephemeral=True, ) class VoteView(nextcord.ui.View): """Persistent view attached to every project drop. Buttons use static custom_ids; the target repo is resolved from the message id via SQLite so the view survives bot restarts without re-registration. """ def __init__(self, cog: "RepoScanCog") -> None: super().__init__(timeout=None) self._cog = cog async def _resolve(self, interaction: Interaction) -> str | None: if interaction.guild_id is None or interaction.message is None: return None drop = await self._cog.bot.store.get_drop_by_message( interaction.guild_id, interaction.message.id ) return drop["repo_key"] if drop else None @nextcord.ui.button(emoji="πŸ‘", style=nextcord.ButtonStyle.success, custom_id=CID_UP) async def upvote(self, _button: nextcord.ui.Button, interaction: Interaction) -> None: repo_key = await self._resolve(interaction) if repo_key is None: await interaction.response.send_message( "This drop is no longer tracked.", ephemeral=True ) return await self._cog.register_vote(interaction, repo_key, 1, message_id=interaction.message.id) @nextcord.ui.button(emoji="πŸ‘Ž", style=nextcord.ButtonStyle.danger, custom_id=CID_DOWN) async def downvote(self, _button: nextcord.ui.Button, interaction: Interaction) -> None: repo_key = await self._resolve(interaction) if repo_key is None: await interaction.response.send_message( "This drop is no longer tracked.", ephemeral=True ) return await self._cog.register_vote(interaction, repo_key, -1, message_id=interaction.message.id) @nextcord.ui.button( label="Vote + Comment", emoji="πŸ’¬", style=nextcord.ButtonStyle.secondary, custom_id=CID_COMMENT ) async def comment_vote(self, _button: nextcord.ui.Button, interaction: Interaction) -> None: repo_key = await self._resolve(interaction) if repo_key is None: await interaction.response.send_message( "This drop is no longer tracked.", ephemeral=True ) return await interaction.response.send_modal( VoteCommentModal(self._cog, repo_key, interaction.message.id) ) @nextcord.ui.button( label="Edit", emoji="✏", style=nextcord.ButtonStyle.secondary, custom_id=CID_EDIT ) async def edit_drop(self, _button: nextcord.ui.Button, interaction: Interaction) -> None: if not _is_admin(interaction): await interaction.response.send_message( "Only admins (Manage Server) can edit drops.", ephemeral=True ) return msg = interaction.message if msg is None or not msg.embeds: await interaction.response.send_message("Nothing to edit.", ephemeral=True) return embed = msg.embeds[0] cur_desc = embed.description or "" cur_note = next( (f.value for f in embed.fields if f.name == "β—ˆ Curator Note"), "" ) await interaction.response.send_modal(EditDropModal(self._cog, msg, cur_desc, cur_note)) # ════════════════════════════════════════════════════════════════ # Admin configuration modals # ════════════════════════════════════════════════════════════════ class GitConfigModal(nextcord.ui.Modal): """Admin modal to configure drop/highlight channels, role, keywords, stars.""" def __init__(self, cog: "RepoScanCog", existing: dict | None) -> None: super().__init__(title="RepoRadar Setup", timeout=300) self._cog = cog existing = existing or {} self.drop_channel = nextcord.ui.TextInput( label="Drop channel ID", placeholder="Right-click a channel β†’ Copy Channel ID", default_value=str(existing.get("drop_channel_id") or ""), required=True, max_length=25, style=nextcord.TextInputStyle.short, ) self.add_item(self.drop_channel) self.highlight_channel = nextcord.ui.TextInput( label="Highlight channel ID (optional)", default_value=str(existing.get("highlight_channel_id") or ""), required=False, max_length=25, style=nextcord.TextInputStyle.short, ) self.add_item(self.highlight_channel) self.highlight_role = nextcord.ui.TextInput( label="Highlight ping role ID (optional)", default_value=str(existing.get("highlight_role_id") or ""), required=False, max_length=25, style=nextcord.TextInputStyle.short, ) self.add_item(self.highlight_role) self.min_stars = nextcord.ui.TextInput( label="Min stars to highlight (0 = off)", default_value=str(existing.get("min_stars") or 0), required=False, max_length=6, style=nextcord.TextInputStyle.short, ) self.add_item(self.min_stars) self.keywords = nextcord.ui.TextInput( label="Highlight keywords (comma-separated)", placeholder="rce, exploit, c2, loader, evasion", default_value=str(existing.get("highlight_keywords") or ""), required=False, max_length=300, style=nextcord.TextInputStyle.paragraph, ) self.add_item(self.keywords) async def callback(self, interaction: Interaction) -> None: await interaction.response.defer(ephemeral=True) def _int(value: str) -> int | None: value = (value or "").strip() return int(value) if value.isdigit() else None drop_id = _int(self.drop_channel.value) if drop_id is None: await interaction.followup.send( embed=build_error_embed("Invalid Channel", "The drop channel ID must be numeric."), ephemeral=True, ) return if interaction.guild and interaction.guild.get_channel(drop_id) is None: await interaction.followup.send( embed=build_error_embed( "Channel Not Found", "I can't see that drop channel. Check the ID and my permissions.", ), ephemeral=True, ) return await self._cog.bot.store.set_git_config( interaction.guild_id, drop_channel_id=drop_id, highlight_channel_id=_int(self.highlight_channel.value), highlight_role_id=_int(self.highlight_role.value), min_stars=_int(self.min_stars.value) or 0, highlight_keywords=(self.keywords.value or "").strip(), enabled=1, ) await interaction.followup.send( embed=build_info_embed( "RepoRadar Configured", f"New projects will drop to <#{drop_id}>.\n" "Add a watch with `/radar_watch`, then `/radar_scan` to test.", ), ephemeral=True, ) class WatchModal(nextcord.ui.Modal): """Admin modal to add a watch (user / org / server-wide).""" def __init__(self, cog: "RepoScanCog") -> None: super().__init__(title="Add Watch", timeout=300) self._cog = cog self.scope_type = nextcord.ui.TextInput( label="Scope (user / org / server)", placeholder="user β€”orβ€” org β€”orβ€” server", default_value="user", required=True, max_length=10, style=nextcord.TextInputStyle.short, ) self.add_item(self.scope_type) self.scope_value = nextcord.ui.TextInput( label="User / org name (blank for server-wide)", placeholder="e.g. torvalds (leave blank if scope = server)", required=False, max_length=120, style=nextcord.TextInputStyle.short, ) self.add_item(self.scope_value) self.channel_override = nextcord.ui.TextInput( label="Drop channel override ID (optional)", placeholder="Defaults to the configured drop channel", required=False, max_length=25, style=nextcord.TextInputStyle.short, ) self.add_item(self.channel_override) async def callback(self, interaction: Interaction) -> None: await interaction.response.defer(ephemeral=True) scope_type = (self.scope_type.value or "user").strip().lower() if scope_type not in ("user", "org", "server"): await interaction.followup.send( embed=build_error_embed( "Invalid Scope", "Scope must be `user`, `org`, or `server`." ), ephemeral=True, ) return scope_value = (self.scope_value.value or "").strip() if scope_type != "server" and not scope_value: await interaction.followup.send( embed=build_error_embed( "Missing Name", "Provide the user/org name for a non-server watch." ), ephemeral=True, ) return if scope_type == "server": scope_value = "" ch_raw = (self.channel_override.value or "").strip() channel_id = int(ch_raw) if ch_raw.isdigit() else None await self._cog.bot.store.add_git_watch( interaction.guild_id, scope_type, scope_value, channel_id, interaction.user.id ) # Baseline immediately so we never flood the channel with old projects. seeded = await self._cog.baseline_watch(interaction.guild_id, scope_type, scope_value) label = "the entire server" if scope_type == "server" else f"`{scope_value}`" await interaction.followup.send( embed=build_info_embed( "Watch Added", f"Now monitoring {label} ({scope_type}). Baselined **{seeded}** existing " "projects silently β€” only *new* projects from now on will be dropped.", ), ephemeral=True, ) # ════════════════════════════════════════════════════════════════ # The cog # ════════════════════════════════════════════════════════════════ class RepoScanCog(commands.Cog): """Continual git-server monitor with drops, voting and highlights.""" def __init__(self, bot: commands.Bot) -> None: self.bot = bot self._view_registered = False self.monitor.change_interval(seconds=max(60, settings.git_poll_interval)) self.monitor.start() def cog_unload(self) -> None: self.monitor.cancel() # ── persistent view registration ───────────────────────── @commands.Cog.listener() async def on_ready(self) -> None: if not self._view_registered: self.bot.add_view(VoteView(self)) self._view_registered = True log.info("Persistent VoteView registered") # ── monitor loop ───────────────────────────────────────── @tasks.loop(seconds=300) async def monitor(self) -> None: if not self.bot.git.configured: return try: await self._scan_all() except Exception: # noqa: BLE001 β€” never let the loop die log.exception("monitor scan cycle failed") @monitor.before_loop async def _before(self) -> None: await self.bot.wait_until_ready() # ── scanning ───────────────────────────────────────────── async def _fetch_scope(self, scope_type: str, scope_value: str) -> list[Repo]: git = self.bot.git if scope_type == "server": return await git.list_all_repos(limit=100) return await git.list_repos_for_owner(scope_value, limit=100) async def baseline_watch(self, guild_id: int, scope_type: str, scope_value: str) -> int: """Silently mark a watch's current repos as seen (no announcements).""" repos = await self._fetch_scope(scope_type, scope_value) seen = await self.bot.store.get_seen_keys(guild_id) count = 0 for repo in repos: if repo.key in seen: continue await self.bot.store.mark_seen( guild_id, repo.key, name=repo.name, full_name=repo.full_name, url=repo.url, description=repo.description, created_remote=repo.created_sort_key, announced=True, ) count += 1 return count async def _scan_all(self) -> None: watches = await self.bot.store.all_git_watches() # Group watches by guild so we share the per-guild seen set + config. by_guild: dict[int, list[dict]] = {} for w in watches: by_guild.setdefault(w["guild_id"], []).append(w) for guild_id, guild_watches in by_guild.items(): cfg = await self.bot.store.get_git_config(guild_id) or {} if not cfg.get("enabled", 1): continue default_channel = cfg.get("drop_channel_id") or settings.git_drop_channel_id for w in guild_watches: await self._scan_watch(guild_id, w, cfg, default_channel) async def _scan_watch( self, guild_id: int, watch: dict, cfg: dict, default_channel: int | None ) -> None: channel_id = watch.get("channel_id") or default_channel if channel_id is None: return # nowhere to post repos = await self._fetch_scope(watch["scope_type"], watch["scope_value"]) if not repos: return seen = await self.bot.store.get_seen_keys(guild_id) new = [r for r in repos if r.key not in seen] if not new: return # Oldest β†’ newest so the freshest project lands as the most recent message. new.sort(key=lambda r: r.created_sort_key) # Burst guard: an unexpectedly large batch = a new/unseeded scope; baseline # it silently instead of flooding the channel. if len(new) > settings.git_max_drops_per_cycle: log.info( "Burst of %d new repos for guild %s β€” baselining silently", len(new), guild_id, ) for repo in new: await self.bot.store.mark_seen( guild_id, repo.key, name=repo.name, full_name=repo.full_name, url=repo.url, description=repo.description, created_remote=repo.created_sort_key, announced=True, ) return watch_label = ( "entire server" if watch["scope_type"] == "server" else f"`{watch['scope_value']}` ({watch['scope_type']})" ) for repo in new: await self._drop_repo(guild_id, repo, cfg, channel_id, watch_label) await self.bot.store.mark_seen( guild_id, repo.key, name=repo.name, full_name=repo.full_name, url=repo.url, description=repo.description, created_remote=repo.created_sort_key, announced=True, ) def _is_highlight(self, repo: Repo, cfg: dict) -> bool: min_stars = int(cfg.get("min_stars") or 0) if min_stars and repo.stars >= min_stars: return True keywords = [k.strip().lower() for k in (cfg.get("highlight_keywords") or "").split(",") if k.strip()] if not keywords: return False haystack = " ".join( [repo.name.lower(), repo.full_name.lower(), repo.description.lower(), " ".join(t.lower() for t in repo.topics)] ) return any(kw in haystack for kw in keywords) async def _drop_repo( self, guild_id: int, repo: Repo, cfg: dict, channel_id: int, watch_label: str ) -> None: channel = self.bot.get_channel(channel_id) if channel is None: log.warning("Drop channel %s not visible for guild %s", channel_id, guild_id) return votes = await self.bot.store.get_vote_tally(guild_id, repo.key) highlight = self._is_highlight(repo, cfg) embed = build_repo_drop_embed(repo, votes=votes, watch_label=watch_label, highlight=False) try: msg = await channel.send(embed=embed, view=VoteView(self)) except nextcord.DiscordException as exc: log.error("Failed to drop %s in %s: %s", repo.full_name, channel_id, exc) return await self.bot.store.record_drop( guild_id, repo.key, repo.full_name, repo.url, channel.id, msg.id ) log.info("Dropped new project %s β†’ channel %s", repo.full_name, channel.id) # Highlight to the featured channel with an optional role ping. if highlight: hl_channel_id = cfg.get("highlight_channel_id") hl_channel = self.bot.get_channel(hl_channel_id) if hl_channel_id else None if hl_channel is not None: role_id = cfg.get("highlight_role_id") content = f"<@&{role_id}>" if role_id else None hl_embed = build_repo_drop_embed( repo, votes=votes, watch_label=watch_label, highlight=True ) try: hl_msg = await hl_channel.send(content=content, embed=hl_embed, view=VoteView(self)) await self.bot.store.record_drop( guild_id, repo.key, repo.full_name, repo.url, hl_channel.id, hl_msg.id ) except nextcord.DiscordException as exc: log.error("Failed to highlight %s: %s", repo.full_name, exc) # ── shared vote registration ───────────────────────────── async def register_vote( self, interaction: Interaction, repo_key: str, value: int, *, comment: str = "", message_id: int | None = None, ) -> None: """Cast/toggle a vote and refresh the drop card's tally field in place.""" if interaction.guild_id is None: await interaction.response.send_message( "Voting only works inside a server.", ephemeral=True ) return # Acknowledge immediately so the (possibly slow) DB + edit work below # never trips Discord's 3-second interaction deadline. if not interaction.response.is_done(): await interaction.response.defer(ephemeral=True) store = self.bot.store prior = await store.get_user_vote(interaction.guild_id, repo_key, interaction.user.id) if prior == value and not comment: # Clicking the same arrow again retracts the vote. await store.clear_vote(interaction.guild_id, repo_key, interaction.user.id) verb = "retracted" else: await store.cast_vote(interaction.guild_id, repo_key, interaction.user.id, value, comment) verb = "recorded" votes = await store.get_vote_tally(interaction.guild_id, repo_key) # Update the anchor message's community field without losing admin edits. await self._refresh_vote_field(interaction, repo_key, votes, message_id) arrow = "πŸ‘ up" if value > 0 else "πŸ‘Ž down" note = " with a comment" if comment else "" msg = f"Vote {verb} ({arrow}){note}. Current net score: **{votes['score']:+d}**." await interaction.followup.send(msg, ephemeral=True) async def _refresh_vote_field( self, interaction: Interaction, repo_key: str, votes: dict[str, int], message_id: int | None, ) -> None: message = None if message_id and interaction.message and interaction.message.id == message_id: message = interaction.message else: # Resolve the latest drop message for this repo (e.g. slash-command votes). drop = await self.bot.store.get_latest_drop_for_repo(interaction.guild_id, repo_key) if drop: channel = self.bot.get_channel(drop["channel_id"]) if channel is not None: try: message = await channel.fetch_message(drop["message_id"]) except nextcord.DiscordException: message = None if message is None or not message.embeds: return embed = message.embeds[0] idx = next((i for i, f in enumerate(embed.fields) if f.name == VOTE_FIELD_NAME), None) if idx is not None: embed.set_field_at(idx, name=VOTE_FIELD_NAME, value=_vote_value(votes), inline=True) try: await message.edit(embed=embed) except nextcord.DiscordException: pass # ── slash commands ─────────────────────────────────────── @nextcord.slash_command( name="radar_config", description="Admin: configure RepoRadar (channels, keywords, stars).", guild_ids=GUILD_IDS, ) async def radar_config(self, interaction: Interaction) -> None: if not _is_admin(interaction): await interaction.response.send_message( embed=build_error_embed( "Permission Denied", "You need **Manage Server** to configure the monitor." ), ephemeral=True, ) return existing = await self.bot.store.get_git_config(interaction.guild_id) await interaction.response.send_modal(GitConfigModal(self, existing)) @nextcord.slash_command( name="radar_watch", description="Admin: watch a git user/org or the whole server for new projects.", guild_ids=GUILD_IDS, ) async def radar_watch(self, interaction: Interaction) -> None: if not _is_admin(interaction): await interaction.response.send_message( embed=build_error_embed( "Permission Denied", "You need **Manage Server** to add a watch." ), ephemeral=True, ) return if not self.bot.git.configured: await interaction.response.send_message( embed=build_error_embed( "Git Server Not Configured", "Set `GIT_BASE_URL` (and `GIT_PROVIDER`/`GIT_TOKEN`) in the bot's `.env`.", ), ephemeral=True, ) return await interaction.response.send_modal(WatchModal(self)) @nextcord.slash_command( name="radar_unwatch", description="Admin: stop watching a git user/org or the server.", guild_ids=GUILD_IDS, ) async def radar_unwatch( self, interaction: Interaction, scope_type: str = SlashOption( description="Watch scope to remove", choices={"user": "user", "org": "org", "server": "server"}, required=True, ), name: str = SlashOption( description="User/org name (leave blank for server-wide)", required=False, default="", ), ) -> None: if not _is_admin(interaction): await interaction.response.send_message( embed=build_error_embed( "Permission Denied", "You need **Manage Server** to remove a watch." ), ephemeral=True, ) return value = "" if scope_type == "server" else name.strip() removed = await self.bot.store.remove_git_watch(interaction.guild_id, scope_type, value) if removed: await interaction.response.send_message( embed=build_info_embed( "Watch Removed", f"Stopped monitoring `{scope_type}` **{value or 'entire server'}**.", ), ephemeral=True, ) else: await interaction.response.send_message( embed=build_error_embed("Not Found", "No matching watch was configured."), ephemeral=True, ) @nextcord.slash_command( name="radar_status", description="Show the current RepoRadar configuration and active watches.", guild_ids=GUILD_IDS, ) async def radar_status(self, interaction: Interaction) -> None: await interaction.response.defer(ephemeral=True) cfg = await self.bot.store.get_git_config(interaction.guild_id) or {} watches = await self.bot.store.get_git_watches(interaction.guild_id) embed = build_git_status_embed( provider=self.bot.git.provider, base_url=self.bot.git.base_url, configured=self.bot.git.configured, drop_channel_id=cfg.get("drop_channel_id") or settings.git_drop_channel_id, highlight_channel_id=cfg.get("highlight_channel_id"), highlight_role_id=cfg.get("highlight_role_id"), min_stars=int(cfg.get("min_stars") or 0), keywords=cfg.get("highlight_keywords") or "", enabled=bool(cfg.get("enabled", 1)), watches=watches, poll_interval=max(60, settings.git_poll_interval), ) await interaction.followup.send(embed=embed, ephemeral=True) @nextcord.slash_command( name="radar_latest", description="Show the newest projects from a git user/org or the whole server.", guild_ids=GUILD_IDS, ) async def radar_latest( self, interaction: Interaction, scope_type: str = SlashOption( description="Where to look", choices={"user": "user", "org": "org", "server": "server"}, required=False, default="server", ), name: str = SlashOption( description="User/org name (ignored for server-wide)", required=False, default="", ), limit: int = SlashOption( description="How many to show (1-15)", required=False, default=10, min_value=1, max_value=15, ), ) -> None: await interaction.response.defer() if not self.bot.git.configured: await interaction.followup.send( embed=build_error_embed( "Git Server Not Configured", "Set `GIT_BASE_URL` (and provider/token) in the bot's `.env`.", ) ) return repos = await self._fetch_scope(scope_type, name.strip()) repos.sort(key=lambda r: r.created_sort_key, reverse=True) title = "LATEST PROJECTS" if scope_type == "server" else f"LATEST FROM {name.strip()}" await interaction.followup.send( embed=build_repo_list_embed(repos, title=title, limit=limit) ) @nextcord.slash_command( name="radar_scan", description="Admin: force a RepoRadar scan cycle right now.", guild_ids=GUILD_IDS, ) async def radar_scan(self, interaction: Interaction) -> None: if not _is_admin(interaction): await interaction.response.send_message( embed=build_error_embed( "Permission Denied", "You need **Manage Server** to force a scan." ), ephemeral=True, ) return await interaction.response.defer(ephemeral=True) try: await self._scan_all() except Exception as exc: # noqa: BLE001 log.exception("forced scan failed") await interaction.followup.send( embed=build_error_embed("Scan Failed", f"`{exc}`"), ephemeral=True ) return await interaction.followup.send( embed=build_info_embed("Scan Complete", "Checked all watches for new projects."), ephemeral=True, ) @nextcord.slash_command( name="radar_vote", description="Vote on a project RepoRadar has dropped (by owner/name).", guild_ids=GUILD_IDS, ) async def radar_vote( self, interaction: Interaction, project: str = SlashOption( description="Project full name, e.g. owner/repo", required=True, ), vote: str = SlashOption( description="Your vote", choices={"πŸ‘ up": "up", "πŸ‘Ž down": "down"}, required=True, ), ) -> None: drop = await self.bot.store.find_drop_by_fullname(interaction.guild_id, project.strip()) if drop is None: await interaction.response.send_message( embed=build_error_embed( "Project Not Found", f"I haven't dropped `{project}` here. Use `/radar_latest` to browse.", ), ephemeral=True, ) return value = 1 if vote == "up" else -1 await self.register_vote(interaction, drop["repo_key"], value, message_id=drop["message_id"]) @nextcord.slash_command( name="radar_top", description="Community leaderboard β€” top-voted projects.", guild_ids=GUILD_IDS, ) async def radar_top( self, interaction: Interaction, limit: int = SlashOption( description="How many to show (1-15)", required=False, default=10, min_value=1, max_value=15, ), ) -> None: await interaction.response.defer() rows = await self.bot.store.get_vote_leaderboard(interaction.guild_id, limit=limit) await interaction.followup.send(embed=build_git_leaderboard_embed(rows, limit=limit)) def setup(bot: commands.Bot) -> None: bot.add_cog(RepoScanCog(bot))