Files
RepoRadar/cogs/gitscan.py
T

931 lines
38 KiB
Python
Executable File

"""
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))