931 lines
38 KiB
Python
Executable File
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))
|