Initial sanitary RepoRadar release

This commit is contained in:
2026-06-27 21:20:43 -04:00
commit b90ec9cdd0
15 changed files with 2435 additions and 0 deletions
Executable
+12
View File
@@ -0,0 +1,12 @@
data/
*.db
*.sqlite
*.sqlite3
__pycache__/
*.pyc
*.pyo
.env
.git
.gitignore
.dockerignore
*.md
Executable
+38
View File
@@ -0,0 +1,38 @@
# ── Discord ─────────────────────────────────────────────────
# Bot token from https://discord.com/developers/applications
DISCORD_TOKEN=
# Optional: restrict slash-command sync to one guild for instant updates.
# Leave blank to register commands globally (can take up to an hour to appear).
GUILD_ID=
# ── Git server ──────────────────────────────────────────────
# Provider: auto | gitea | gitlab | github (gitea also covers Forgejo)
GIT_PROVIDER=auto
# Base URL of your git server, e.g. https://git.example.com or https://gitlab.com
# For GitHub, leave blank and set GIT_PROVIDER=github (defaults to api.github.com).
GIT_BASE_URL=
# Read-only personal access token (optional for fully public servers).
GIT_TOKEN=
# ── Channels / roles (also configurable in Discord via /radar_config) ──
# Default channel new projects are dropped into.
GIT_DROP_CHANNEL_ID=
# Channel that highlighted / featured drops are re-posted to.
GIT_HIGHLIGHT_CHANNEL_ID=
# Role pinged when a highlighted project drops.
GIT_HIGHLIGHT_ROLE_ID=
# ── Tuning ──────────────────────────────────────────────────
# How often (seconds) to poll the git server for new projects (min 60).
GIT_POLL_INTERVAL=300
# Max new projects dropped per scan cycle (bursts above this are baselined silently).
GIT_MAX_DROPS_PER_CYCLE=10
# Logging verbosity: DEBUG | INFO | WARNING | ERROR
LOG_LEVEL=INFO
+10
View File
@@ -0,0 +1,10 @@
.env
data/
*.db
*.sqlite
*.sqlite3
pycache/
*.pyc
*.pyo
.venv/
venv/
Executable
+31
View File
@@ -0,0 +1,31 @@
# RepoRadar — Discord bot Docker image
FROM python:3.12-slim
# Avoid interactive prompts + keep Python output unbuffered for clean logs.
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PIP_NO_CACHE_DIR=1
WORKDIR /app
# Install dependencies first for better layer caching.
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy the application source.
COPY . .
# Run as a non-root user; own the data directory it writes the SQLite DB to.
RUN useradd --create-home --uid 10001 reporadar \
&& mkdir -p /app/data \
&& chown -R reporadar:reporadar /app
USER reporadar
# Persist the SQLite database across container recreations.
VOLUME ["/app/data"]
# Lightweight import check so orchestrators can detect a broken image.
HEALTHCHECK --interval=60s --timeout=10s --retries=3 \
CMD python -c "import nextcord, aiohttp, aiosqlite" || exit 1
CMD ["python", "main.py"]
Executable
+163
View File
@@ -0,0 +1,163 @@
# RepoRadar 🛰
A standalone Discord bot that **scrapes a git server for the newest projects**
and drops them into Discord — with descriptions, community voting, and
admin-curated highlights.
RepoRadar continually monitors a self-hosted or hosted git server (Gitea /
Forgejo, GitLab, or GitHub), and as soon as a *new* project appears it posts a
rich card linking the repo with its description, language, stars/forks, topics
and a live community vote tally. Existing repositories are **baselined silently**
the moment a watch is added, so you only ever see brand-new drops — in
chronological order, freshest last.
> RepoRadar is a **separate bot** from VulnForge/THREATLENS. It runs on its own
> token, with its own database and Docker stack.
---
## Features
- **Continual monitoring** of a git server — scoped to specific users/orgs *or*
server-wide.
- **Newest-only drops** — existing repos are baselined on first sight; only new
projects are announced afterwards.
- **Chronological order** — new projects post oldest → newest so the freshest
drop is always the most recent message.
- **Rich drop cards** — link, description, owner, language, stars, forks, topics
and a live vote tally.
- **Community voting, three ways**
- 👍 / 👎 buttons on each drop
- 💬 *Vote + Comment* modal
- `/radar_vote owner/repo up|down` 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 (Manage Server) can edit a drop's description
and curator note in place, and configure everything via modals.
---
## Slash commands
| Command | Who | What it does |
| ----------------- | ----------- | ------------------------------------------------------- |
| `/radar_config` | Admin | Configure drop/highlight channels, role, keywords, stars (modal) |
| `/radar_watch` | Admin | Watch a git user/org or the whole server (modal) |
| `/radar_unwatch` | Admin | Remove a watch |
| `/radar_status` | Everyone | Show current monitor configuration and active watches |
| `/radar_latest` | Everyone | Show the newest projects on demand |
| `/radar_scan` | Admin | Force a scan cycle right now |
| `/radar_vote` | Everyone | Vote on a dropped project by `owner/repo` |
| `/radar_top` | Everyone | Community leaderboard of top-voted projects |
"Admin" = members with the **Manage Server** permission.
---
## Quick start
### 1. Create the Discord application
1. Go to <https://discord.com/developers/applications> → **New Application**.
2. **Bot** tab → **Add Bot** → copy the **token**.
3. **OAuth2 → URL Generator**: scopes `bot` + `applications.commands`;
bot permissions: *Send Messages*, *Embed Links*, *Read Message History*.
Invite the bot with the generated URL.
### 2. Configure
```bash
cp .env.example .env
# edit .env — set at minimum DISCORD_TOKEN, GIT_PROVIDER / GIT_BASE_URL, and
# (optionally) GUILD_ID for instant command sync.
```
### 3. Run with Docker (recommended)
```bash
docker compose up -d --build
docker compose logs -f reporadar
```
### 4. Run locally (without Docker)
```bash
python -m venv .venv
. .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r requirements.txt
python main.py
```
### 5. Configure in Discord
1. `/radar_config` — set the drop channel (and optionally a highlight channel,
ping role, star threshold, and keywords).
2. `/radar_watch` — add a watch (`user`, `org`, or `server`). Existing repos are
baselined silently.
3. `/radar_scan` — force a scan to verify, or just wait for the poll loop.
---
## Configuration reference
All values can be set in `.env`; channels/roles/keywords are also settable in
Discord via `/radar_config` (per-guild settings take precedence).
| Env var | Default | Description |
| -------------------------- | ---------------- | ------------------------------------------------------- |
| `DISCORD_TOKEN` | — | Bot token (required) |
| `GUILD_ID` | — | Restrict command sync to one guild (instant updates) |
| `GIT_PROVIDER` | `auto` | `auto` \| `gitea` \| `gitlab` \| `github` |
| `GIT_BASE_URL` | — | Git server URL (blank for GitHub) |
| `GIT_TOKEN` | — | Read-only PAT (optional for public servers) |
| `GIT_DROP_CHANNEL_ID` | — | Default drop channel |
| `GIT_HIGHLIGHT_CHANNEL_ID` | — | Featured-drop channel |
| `GIT_HIGHLIGHT_ROLE_ID` | — | Role pinged on highlights |
| `GIT_POLL_INTERVAL` | `300` | Poll interval in seconds (min 60) |
| `GIT_MAX_DROPS_PER_CYCLE` | `10` | Burst guard — larger batches are baselined silently |
| `LOG_LEVEL` | `INFO` | `DEBUG` \| `INFO` \| `WARNING` \| `ERROR` |
---
## Supported git providers
| Provider | `GIT_PROVIDER` | Notes |
| ----------------- | -------------- | ------------------------------------------------------ |
| Gitea / Forgejo | `gitea` | Typical self-hosted git server (REST API v1) |
| GitLab | `gitlab` | self-hosted or gitlab.com (REST API v4) |
| GitHub | `github` | Leave `GIT_BASE_URL` blank (uses api.github.com) |
| Auto-detect | `auto` | Probes `/api/v1/version` then `/api/v4/version` |
---
## Under the hood
1. **`utils/git_api.py`** — a provider-agnostic async client (single shared
`aiohttp` session, retry/backoff) that normalises every provider's payload
into a simple `Repo` dataclass. `created_at` drives the "newest-only" logic.
2. **`utils/helpers.py`** — an async SQLite `Store` (config, watches, seen-repo
de-dup, drop anchors, community votes) + small formatting helpers.
3. **`utils/embeds.py`** — all the cyberpunk-styled embed builders.
4. **`cogs/gitscan.py`** — the monitor loop, drop logic, voting UI and slash
commands.
5. **`main.py`** — Nextcord 2.6 boot (bot built inside the loop, manual
extension load + command rollout, graceful shutdown).
State persists in `data/reporadar.db` (mounted as a Docker volume), so seen
repos and votes survive restarts.
---
## Project structure
```
reporadar/
├── main.py # entrypoint / bot bootstrap
├── config.py # env-driven settings + colour palette
├── requirements.txt
├── Dockerfile
├── docker-compose.yml
├── .env.example
├── .dockerignore
├── cogs/
│ └── gitscan.py # monitor + voting + slash commands
└── utils/
├── git_api.py # provider-agnostic git client
├── helpers.py # async SQLite store + formatting
└── embeds.py # embed builders
```
+1
View File
@@ -0,0 +1 @@
"""RepoRadar cogs package."""
+930
View File
@@ -0,0 +1,930 @@
"""
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))
Executable
+84
View File
@@ -0,0 +1,84 @@
"""
config.py — Centralised configuration for RepoRadar.
Loads environment variables and defines git-server endpoints, the colour
palette, cache TTLs and other tunables. Import `settings` everywhere instead of
reading os.environ directly so configuration stays in one place.
"""
from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
from dotenv import load_dotenv
# Load .env from the project root (the directory this file lives in).
PROJECT_ROOT = Path(__file__).resolve().parent
load_dotenv(PROJECT_ROOT / ".env")
def _get_int(name: str) -> int | None:
"""Parse an optional integer env var, returning None when unset/blank."""
raw = os.getenv(name, "").strip()
if not raw:
return None
try:
return int(raw)
except ValueError:
return None
@dataclass(frozen=True)
class Settings:
"""Immutable runtime settings sourced from environment + defaults."""
# ── Discord ──────────────────────────────────────────────
discord_token: str = os.getenv("DISCORD_TOKEN", "").strip()
# Restrict slash-command sync to one guild for instant updates (recommended).
guild_id: int | None = _get_int("GUILD_ID")
# ── Git server ───────────────────────────────────────────
# Provider: "auto" (probe), "gitea" (also Forgejo), "gitlab", or "github".
git_provider: str = os.getenv("GIT_PROVIDER", "auto").strip().lower()
# Base URL of the git server, e.g. https://git.example.com / https://gitlab.com
# For GitHub leave blank (defaults to api.github.com) and set GIT_PROVIDER=github.
git_base_url: str = os.getenv("GIT_BASE_URL", "").strip().rstrip("/")
# Personal access token (read-only is enough). Optional for public servers.
git_token: str = os.getenv("GIT_TOKEN", "").strip()
# Default channel for new-project drops (also settable per-guild via modal).
git_drop_channel_id: int | None = _get_int("GIT_DROP_CHANNEL_ID")
# Channel for highlighted/featured drops (keyword/star matches).
git_highlight_channel_id: int | None = _get_int("GIT_HIGHLIGHT_CHANNEL_ID")
# Role to ping on a highlighted drop.
git_highlight_role_id: int | None = _get_int("GIT_HIGHLIGHT_ROLE_ID")
# How often (seconds) the monitor polls the git server for new projects.
git_poll_interval: int = _get_int("GIT_POLL_INTERVAL") or 300
# Max new projects to drop per scan cycle (avoids flooding on bursts).
git_max_drops_per_cycle: int = _get_int("GIT_MAX_DROPS_PER_CYCLE") or 10
# ── Paths ────────────────────────────────────────────────
data_dir: Path = PROJECT_ROOT / "data"
db_path: Path = PROJECT_ROOT / "data" / "reporadar.db"
# ── Behaviour ────────────────────────────────────────────
log_level: str = os.getenv("LOG_LEVEL", "INFO").strip().upper()
http_timeout: int = 30 # seconds per request
request_retries: int = 3 # transient retry attempts
user_agent: str = "RepoRadar/1.0 (+https://github.com/your-org/reporadar)"
# ── Cyberpunk / hacker colour palette (hex → int) ─────────
color_drop: int = 0x8A63FF # neon violet (new project drops)
color_highlight: int = 0xE0115F # ruby (featured / highlighted)
color_success: int = 0x00FF9C # matrix green
color_error: int = 0xFF003C # neon red
color_medium: int = 0xFFB300 # amber (unconfigured / neutral)
color_info: int = 0x0A84FF # electric blue
def ensure_dirs(self) -> None:
"""Create the data directory if it does not yet exist."""
self.data_dir.mkdir(parents=True, exist_ok=True)
settings = Settings()
+13
View File
@@ -0,0 +1,13 @@
services:
reporadar:
build: .
image: reporadar:latest
container_name: reporadar
restart: unless-stopped
env_file: .env
volumes:
- reporadar_data:/app/data
# Outbound-only bot — no ports need to be exposed.
volumes:
reporadar_data:
Executable
+123
View File
@@ -0,0 +1,123 @@
"""
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)
+7
View File
@@ -0,0 +1,7 @@
# ──────────────────────────────────────────────────────────────
# RepoRadar — Python dependencies
# ──────────────────────────────────────────────────────────────
nextcord>=2.6.0 # Modern Discord API wrapper (modals, slash, views)
aiohttp>=3.9.0 # Async HTTP client for git server APIs
aiosqlite>=0.20.0 # Async SQLite for config, watches, drops + votes
python-dotenv>=1.0.1 # Load secrets from .env
+1
View File
@@ -0,0 +1 @@
"""RepoRadar utility package: git client, store helpers, and embed builders."""
+235
View File
@@ -0,0 +1,235 @@
"""
utils/embeds.py — cyberpunk / hacker-aesthetic embed builders for RepoRadar.
Every embed is built here so styling stays consistent: dark backdrops, neon
accent colours, monospace code blocks, and tidy mobile + desktop formatting.
Functions accept the normalised `Repo` dataclass from utils.git_api and return
ready-to-send nextcord.Embed objects.
"""
from __future__ import annotations
from datetime import datetime, timezone
import nextcord
from config import settings
from utils.git_api import Repo
from utils.helpers import discord_timestamp, truncate
BRAND = "RepoRadar // Project Intelligence"
def _footer(embed: nextcord.Embed) -> nextcord.Embed:
embed.set_footer(text=f"{BRAND}")
embed.timestamp = datetime.now(timezone.utc)
return embed
# ──────────────────────────────────────────────────────────────
# Error / utility embeds
# ──────────────────────────────────────────────────────────────
def build_error_embed(title: str, message: str) -> nextcord.Embed:
embed = nextcord.Embed(
title=f"{title}",
description=message,
color=settings.color_error,
)
return _footer(embed)
def build_info_embed(title: str, message: str) -> nextcord.Embed:
embed = nextcord.Embed(
title=f"{title}",
description=message,
color=settings.color_success,
)
return _footer(embed)
# ──────────────────────────────────────────────────────────────
# Project-drop embeds
# ──────────────────────────────────────────────────────────────
def _lang_emoji(language: str) -> str:
"""A glanceable marker for the repo's primary language."""
return {
"python": "🐍", "go": "🐹", "rust": "🦀", "c": "🔧", "c++": "🔧",
"java": "", "javascript": "🟨", "typescript": "🔷", "shell": "🐚",
"ruby": "💎", "php": "🐘", "html": "🌐", "powershell": "💠",
}.get((language or "").lower(), "📦")
def build_repo_drop_embed(
repo: Repo,
*,
votes: dict[str, int] | None = None,
watch_label: str | None = None,
highlight: bool = False,
admin_note: str | None = None,
) -> nextcord.Embed:
"""The card posted when a brand-new project is discovered on the git server."""
votes = votes or {"up": 0, "down": 0, "score": 0}
title_prefix = "🌟 FEATURED DROP" if highlight else "🆕 NEW PROJECT"
embed = nextcord.Embed(
title=f"{_lang_emoji(repo.language)} {truncate(repo.full_name or repo.name, 240)}",
url=repo.url or None,
description=truncate(repo.description or "*No description provided.*", 1024),
color=settings.color_highlight if highlight else settings.color_drop,
)
embed.set_author(name=f"{title_prefix}")
embed.add_field(name="◈ Owner", value=f"`{repo.owner or 'unknown'}`", inline=True)
if repo.language:
embed.add_field(name="◈ Language", value=f"`{repo.language}`", inline=True)
embed.add_field(
name="◈ Created",
value=discord_timestamp(repo.created_at, "R"),
inline=True,
)
embed.add_field(name="◈ ⭐ Stars", value=f"`{repo.stars}`", inline=True)
embed.add_field(name="◈ 🍴 Forks", value=f"`{repo.forks}`", inline=True)
embed.add_field(
name="◈ 🗳 Community",
value=f"👍 `{votes['up']}` 👎 `{votes['down']}` • net `{votes['score']:+d}`",
inline=True,
)
if repo.topics:
embed.add_field(
name="◈ Topics",
value=" ".join(f"`{t}`" for t in repo.topics[:10]),
inline=False,
)
if admin_note:
embed.add_field(name="◈ Curator Note", value=truncate(admin_note, 1024), inline=False)
if watch_label:
embed.add_field(name="◈ Source", value=watch_label, inline=False)
if repo.url:
embed.add_field(name="◈ Repository", value=f"[🔗 Open project]({repo.url})", inline=False)
return _footer(embed)
def build_repo_list_embed(
repos: list[Repo],
*,
title: str = "🛰 LATEST PROJECTS",
limit: int = 10,
) -> nextcord.Embed:
"""Compact chronological list of the newest repositories (newest first)."""
embed = nextcord.Embed(
title=f"{title}",
description=(
"```ansi\n\u001b[1;35m> Freshest projects from the git server\u001b[0m\n```"
f"Showing the **{min(limit, len(repos))}** most recent."
),
color=settings.color_drop,
)
for repo in repos[:limit]:
created = discord_timestamp(repo.created_at, "R")
meta = f"{repo.stars} · 🍴 {repo.forks}"
if repo.language:
meta = f"`{repo.language}` · " + meta
desc = truncate(repo.description or "*No description.*", 130)
link = f"[open]({repo.url})" if repo.url else ""
embed.add_field(
name=f"{_lang_emoji(repo.language)} {truncate(repo.full_name or repo.name, 60)}",
value=f"{desc}\n{meta} · {created} · {link}",
inline=False,
)
if not repos:
embed.description = "No projects could be retrieved right now."
return _footer(embed)
def build_git_leaderboard_embed(rows: list[dict], *, limit: int = 10) -> nextcord.Embed:
"""Top community-voted projects."""
embed = nextcord.Embed(
title="🏆 COMMUNITY TOP PROJECTS",
description="```ansi\n\u001b[1;32m> Ranked by community vote score\u001b[0m\n```",
color=settings.color_success,
)
medals = {0: "🥇", 1: "🥈", 2: "🥉"}
for i, row in enumerate(rows[:limit]):
name = row.get("full_name") or row.get("repo_key", "unknown")
url = row.get("url") or ""
score = int(row.get("score") or 0)
up = int(row.get("up") or 0)
down = int(row.get("down") or 0)
marker = medals.get(i, f"`#{i + 1}`")
link = f"[open]({url})" if url else ""
embed.add_field(
name=f"{marker} {truncate(name, 60)}",
value=f"net `{score:+d}` • 👍 `{up}` 👎 `{down}` {link}",
inline=False,
)
if not rows:
embed.description = "No votes have been cast yet. Be the first to vote on a drop!"
return _footer(embed)
def build_git_status_embed(
*,
provider: str,
base_url: str,
configured: bool,
drop_channel_id: int | None,
highlight_channel_id: int | None,
highlight_role_id: int | None,
min_stars: int,
keywords: str,
enabled: bool,
watches: list[dict],
poll_interval: int,
) -> nextcord.Embed:
"""Render the current monitor configuration for a guild."""
embed = nextcord.Embed(
title="⚙ REPORADAR — CONFIGURATION",
color=settings.color_drop if configured else settings.color_medium,
)
status = "🟢 enabled" if enabled else "🔴 disabled"
embed.add_field(name="◈ Provider", value=f"`{provider or 'unconfigured'}`", inline=True)
embed.add_field(name="◈ Server", value=f"`{base_url or 'api.github.com'}`", inline=True)
embed.add_field(name="◈ Monitor", value=status, inline=True)
embed.add_field(
name="◈ Drop channel",
value=f"<#{drop_channel_id}>" if drop_channel_id else "`not set`",
inline=True,
)
embed.add_field(
name="◈ Highlight channel",
value=f"<#{highlight_channel_id}>" if highlight_channel_id else "`not set`",
inline=True,
)
embed.add_field(
name="◈ Highlight role",
value=f"<@&{highlight_role_id}>" if highlight_role_id else "`none`",
inline=True,
)
embed.add_field(name="◈ Min ⭐ to highlight", value=f"`{min_stars}`", inline=True)
embed.add_field(name="◈ Poll interval", value=f"`{poll_interval}s`", inline=True)
embed.add_field(
name="◈ Highlight keywords",
value=f"`{keywords}`" if keywords else "`none`",
inline=False,
)
if watches:
lines = []
for w in watches:
scope = w["scope_type"]
val = w["scope_value"] or "*entire server*"
ch = f" → <#{w['channel_id']}>" if w.get("channel_id") else ""
lines.append(f"• `{scope}` **{val}**{ch}")
embed.add_field(name="◈ Active watches", value=truncate("\n".join(lines), 1024), inline=False)
else:
embed.add_field(
name="◈ Active watches",
value="None yet — add one with `/radar_watch`.",
inline=False,
)
return _footer(embed)
+357
View File
@@ -0,0 +1,357 @@
"""
utils/git_api.py — async client for self-hosted / hosted git servers.
Supports three provider families behind one normalised interface so the cog and
embeds never touch the upstream schemas directly:
* Gitea / Forgejo (REST API v1) — typical self-hosted "git server"
* GitLab (REST API v4)
* GitHub (REST API v3 / api.github.com)
Responsibilities:
* Single shared aiohttp.ClientSession (started on bot boot, closed on shutdown).
* Provider auto-detection (probe /api/v1/version then /api/v4/version).
* Repository listing for a specific user/org OR server-wide, plus a single repo.
* Normalises every payload into the simple `Repo` dataclass.
The `created_at` timestamp is what drives "newest projects only" — the monitor
baselines existing repos on first sight, then only drops repos created after
that baseline, in chronological order.
"""
from __future__ import annotations
import asyncio
import logging
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any
from urllib.parse import quote
import aiohttp
from config import settings
log = logging.getLogger("reporadar.git_api")
# Provider identifiers.
GITEA = "gitea"
GITLAB = "gitlab"
GITHUB = "github"
def _parse_dt(raw: str | None) -> datetime | None:
"""Parse an ISO8601 timestamp from any provider into an aware UTC datetime."""
if not raw:
return None
cleaned = raw.replace("Z", "+00:00")
for fmt in ("%Y-%m-%dT%H:%M:%S.%f%z", "%Y-%m-%dT%H:%M:%S%z"):
try:
return datetime.strptime(cleaned, fmt)
except ValueError:
continue
try:
parsed = datetime.fromisoformat(cleaned)
return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
except ValueError:
log.debug("Could not parse git datetime: %s", raw)
return None
@dataclass
class Repo:
"""A normalised view of a single repository across providers."""
# Stable, provider-unique key used for de-duplication + vote tallies.
key: str
name: str
full_name: str
owner: str
description: str
url: str
created_at: datetime | None
updated_at: datetime | None
stars: int = 0
forks: int = 0
language: str = ""
topics: list[str] = field(default_factory=list)
default_branch: str = ""
private: bool = False
archived: bool = False
@property
def created_sort_key(self) -> str:
"""Sortable ISO string; empty timestamps sort first (oldest)."""
return self.created_at.isoformat() if self.created_at else ""
class GitClient:
"""Async, provider-agnostic git server client (cache-light, retry-aware)."""
def __init__(self) -> None:
self._session: aiohttp.ClientSession | None = None
self.provider: str = settings.git_provider
self.base_url: str = settings.git_base_url
self._detected = False
# ── lifecycle ────────────────────────────────────────────
async def start(self) -> None:
if self._session is None or self._session.closed:
headers = {"User-Agent": settings.user_agent, "Accept": "application/json"}
timeout = aiohttp.ClientTimeout(total=settings.http_timeout)
self._session = aiohttp.ClientSession(headers=headers, timeout=timeout)
await self._detect_provider()
log.info(
"Git client ready (provider=%s, base=%s, auth=%s)",
self.provider or "unconfigured",
self.base_url or "<none>",
bool(settings.git_token),
)
async def close(self) -> None:
if self._session and not self._session.closed:
await self._session.close()
def _require_session(self) -> aiohttp.ClientSession:
if self._session is None or self._session.closed:
raise RuntimeError("GitClient session not started — call start() first.")
return self._session
@property
def configured(self) -> bool:
"""True when we have enough config to talk to a git server."""
if self.provider == GITHUB:
return True # api.github.com default works without a base URL
return bool(self.base_url)
# ── auth headers per provider ────────────────────────────
def _auth_headers(self) -> dict[str, str]:
token = settings.git_token
if not token:
return {}
if self.provider == GITLAB:
return {"PRIVATE-TOKEN": token}
if self.provider == GITHUB:
return {"Authorization": f"Bearer {token}"}
# Gitea / Forgejo
return {"Authorization": f"token {token}"}
def _api_root(self) -> str:
if self.provider == GITHUB:
return (self.base_url or "https://api.github.com").rstrip("/")
if self.provider == GITLAB:
return f"{self.base_url}/api/v4"
return f"{self.base_url}/api/v1" # gitea / forgejo
# ── low-level GET with retry/backoff ─────────────────────
async def _get(
self, url: str, params: dict[str, Any] | None = None
) -> tuple[Any | None, dict[str, str]]:
"""GET returning (json, response_headers). None json on failure."""
session = self._require_session()
headers = self._auth_headers()
delay = 2.0
for attempt in range(1, settings.request_retries + 1):
try:
async with session.get(url, params=params, headers=headers) as resp:
if resp.status == 200:
return await resp.json(), dict(resp.headers)
if resp.status in (403, 429, 500, 502, 503):
log.warning(
"GET %s%s (attempt %s/%s), backoff %.1fs",
url, resp.status, attempt, settings.request_retries, delay,
)
await asyncio.sleep(delay)
delay *= 2
continue
log.error("GET %s → unexpected status %s", url, resp.status)
return None, {}
except (aiohttp.ClientError, asyncio.TimeoutError) as exc:
log.warning("GET %s failed (%s/%s): %s", url, attempt, settings.request_retries, exc)
await asyncio.sleep(delay)
delay *= 2
log.error("GET %s exhausted retries", url)
return None, {}
# ── provider detection ───────────────────────────────────
async def _detect_provider(self) -> None:
if self._detected:
return
self._detected = True
if self.provider and self.provider != "auto":
return # explicit provider — trust the operator
if not self.base_url:
self.provider = GITHUB
return
host = self.base_url.lower()
if "github" in host:
self.provider = GITHUB
return
if "gitlab" in host:
self.provider = GITLAB
return
# Probe Gitea/Forgejo, then GitLab.
gitea_ver, _ = await self._get(f"{self.base_url}/api/v1/version")
if isinstance(gitea_ver, dict) and gitea_ver.get("version"):
self.provider = GITEA
return
gitlab_ver, _ = await self._get(f"{self.base_url}/api/v4/version")
if isinstance(gitlab_ver, dict) and gitlab_ver.get("version"):
self.provider = GITLAB
return
log.warning("Could not auto-detect git provider for %s; defaulting to gitea", self.base_url)
self.provider = GITEA
# ── normalisation per provider ───────────────────────────
def _norm_gitea(self, r: dict[str, Any]) -> Repo:
owner = (r.get("owner") or {}).get("login") or (r.get("owner") or {}).get("username") or ""
full = r.get("full_name") or f"{owner}/{r.get('name', '')}"
return Repo(
key=f"{self.provider}:{full}",
name=r.get("name", ""),
full_name=full,
owner=owner,
description=r.get("description") or "",
url=r.get("html_url") or "",
created_at=_parse_dt(r.get("created_at")),
updated_at=_parse_dt(r.get("updated_at")),
stars=int(r.get("stars_count", 0) or 0),
forks=int(r.get("forks_count", 0) or 0),
language=r.get("language") or "",
topics=list(r.get("topics") or []),
default_branch=r.get("default_branch") or "",
private=bool(r.get("private", False)),
archived=bool(r.get("archived", False)),
)
def _norm_gitlab(self, r: dict[str, Any]) -> Repo:
ns = r.get("namespace") or {}
owner = ns.get("path") or ns.get("name") or ""
full = r.get("path_with_namespace") or f"{owner}/{r.get('path', '')}"
return Repo(
key=f"{self.provider}:{r.get('id', full)}",
name=r.get("name") or r.get("path", ""),
full_name=full,
owner=owner,
description=r.get("description") or "",
url=r.get("web_url") or "",
created_at=_parse_dt(r.get("created_at")),
updated_at=_parse_dt(r.get("last_activity_at")),
stars=int(r.get("star_count", 0) or 0),
forks=int(r.get("forks_count", 0) or 0),
language="",
topics=list(r.get("topics") or r.get("tag_list") or []),
default_branch=r.get("default_branch") or "",
private=(r.get("visibility") == "private"),
archived=bool(r.get("archived", False)),
)
def _norm_github(self, r: dict[str, Any]) -> Repo:
owner = (r.get("owner") or {}).get("login") or ""
full = r.get("full_name") or f"{owner}/{r.get('name', '')}"
return Repo(
key=f"{self.provider}:{full}",
name=r.get("name", ""),
full_name=full,
owner=owner,
description=r.get("description") or "",
url=r.get("html_url") or "",
created_at=_parse_dt(r.get("created_at")),
updated_at=_parse_dt(r.get("pushed_at") or r.get("updated_at")),
stars=int(r.get("stargazers_count", 0) or 0),
forks=int(r.get("forks_count", 0) or 0),
language=r.get("language") or "",
topics=list(r.get("topics") or []),
default_branch=r.get("default_branch") or "",
private=bool(r.get("private", False)),
archived=bool(r.get("archived", False)),
)
def _normalize(self, raw: dict[str, Any]) -> Repo:
if self.provider == GITLAB:
return self._norm_gitlab(raw)
if self.provider == GITHUB:
return self._norm_github(raw)
return self._norm_gitea(raw)
# ── public listing API ───────────────────────────────────
async def list_repos_for_owner(self, owner: str, limit: int = 100) -> list[Repo]:
"""Repos belonging to a user or organisation (newest first upstream)."""
await self._detect_provider()
root = self._api_root()
out: list[Repo] = []
if self.provider == GITLAB:
# Try group projects first, fall back to user projects.
enc = quote(owner, safe="")
for path in (f"/groups/{enc}/projects", f"/users/{enc}/projects"):
data, _ = await self._get(
f"{root}{path}",
{"per_page": min(limit, 100), "order_by": "created_at", "sort": "desc",
"include_subgroups": "true"},
)
if isinstance(data, list) and data:
out = [self._normalize(x) for x in data]
break
elif self.provider == GITHUB:
# Works for both users and orgs via the generic /users endpoint.
data, _ = await self._get(
f"{root}/users/{quote(owner, safe='')}/repos",
{"per_page": min(limit, 100), "sort": "created", "direction": "desc"},
)
if isinstance(data, list):
out = [self._normalize(x) for x in data]
else: # gitea / forgejo — /users/{u}/repos covers orgs too on most builds
for path in (f"/users/{quote(owner, safe='')}/repos",
f"/orgs/{quote(owner, safe='')}/repos"):
data, _ = await self._get(f"{root}{path}", {"limit": min(limit, 50)})
if isinstance(data, list) and data:
out = [self._normalize(x) for x in data]
break
return out
async def list_all_repos(self, limit: int = 100) -> list[Repo]:
"""Server-wide repository discovery (most recent first)."""
await self._detect_provider()
root = self._api_root()
if self.provider == GITLAB:
data, _ = await self._get(
f"{root}/projects",
{"per_page": min(limit, 100), "order_by": "created_at", "sort": "desc",
"simple": "true"},
)
return [self._normalize(x) for x in data] if isinstance(data, list) else []
if self.provider == GITHUB:
# api.github.com has no "all repos" concept; use the public timeline.
data, _ = await self._get(f"{root}/repositories", {"per_page": min(limit, 100)})
repos = [self._normalize(x) for x in data] if isinstance(data, list) else []
repos.sort(key=lambda r: r.created_sort_key, reverse=True)
return repos[:limit]
# Gitea / Forgejo search endpoint sorted by newest.
data, _ = await self._get(
f"{root}/repos/search",
{"limit": min(limit, 50), "sort": "created", "order": "desc"},
)
items = (data or {}).get("data") if isinstance(data, dict) else None
return [self._normalize(x) for x in items] if isinstance(items, list) else []
async def get_repo(self, owner: str, name: str) -> Repo | None:
"""Fetch a single repository by owner + name."""
await self._detect_provider()
root = self._api_root()
if self.provider == GITLAB:
enc = quote(f"{owner}/{name}", safe="")
data, _ = await self._get(f"{root}/projects/{enc}")
elif self.provider == GITHUB:
data, _ = await self._get(f"{root}/repos/{quote(owner, safe='')}/{quote(name, safe='')}")
else:
data, _ = await self._get(
f"{root}/repos/{quote(owner, safe='')}/{quote(name, safe='')}"
)
return self._normalize(data) if isinstance(data, dict) and data else None
+430
View File
@@ -0,0 +1,430 @@
"""
utils/helpers.py — small utilities + the async SQLite store for RepoRadar.
Contains:
* `truncate` / `discord_timestamp` formatting helpers used by the embeds.
* The async SQLite `Store` for per-guild config, watches, seen-repo de-dup,
posted drops (vote anchors), and community votes.
"""
from __future__ import annotations
import logging
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import aiosqlite
log = logging.getLogger("reporadar.helpers")
# ──────────────────────────────────────────────────────────────
# Formatting helpers
# ──────────────────────────────────────────────────────────────
def discord_timestamp(dt: datetime | None, style: str = "R") -> str:
"""Render a Discord dynamic timestamp tag, or 'N/A' if dt is None."""
if dt is None:
return "N/A"
return f"<t:{int(dt.timestamp())}:{style}>"
def truncate(text: str | None, limit: int) -> str:
"""Trim text to `limit` chars (Discord field/desc safe), adding an ellipsis."""
if text is None:
return ""
return text if len(text) <= limit else text[: limit - 1].rstrip() + ""
# ──────────────────────────────────────────────────────────────
# Async SQLite store
# ──────────────────────────────────────────────────────────────
class Store:
"""Async SQLite-backed persistence for RepoRadar.
Tables:
git_config(guild_id PK, drop_channel_id, highlight_channel_id,
highlight_role_id, min_stars, highlight_keywords, enabled, updated_at)
git_watch(id PK, guild_id, scope_type, scope_value, channel_id, added_by, created_at)
git_seen(guild_id, repo_key, ... , first_seen_at, announced) -- newest-only de-dup
git_drop(id PK, guild_id, repo_key, channel_id, message_id, dropped_at) -- vote anchor
git_vote(repo_key, guild_id, user_id, value, comment, created_at) -- community votes
"""
def __init__(self, db_path: Path) -> None:
self._db_path = str(db_path)
async def init(self) -> None:
async with aiosqlite.connect(self._db_path) as db:
await db.execute(
"""
CREATE TABLE IF NOT EXISTS git_config (
guild_id INTEGER PRIMARY KEY,
drop_channel_id INTEGER,
highlight_channel_id INTEGER,
highlight_role_id INTEGER,
min_stars INTEGER DEFAULT 0,
highlight_keywords TEXT DEFAULT '',
enabled INTEGER DEFAULT 1,
updated_at TEXT
)
"""
)
await db.execute(
"""
CREATE TABLE IF NOT EXISTS git_watch (
id INTEGER PRIMARY KEY AUTOINCREMENT,
guild_id INTEGER NOT NULL,
scope_type TEXT NOT NULL, -- 'user' | 'org' | 'server'
scope_value TEXT NOT NULL DEFAULT '',
channel_id INTEGER, -- override drop channel
added_by INTEGER,
created_at TEXT NOT NULL,
UNIQUE(guild_id, scope_type, scope_value)
)
"""
)
await db.execute(
"""
CREATE TABLE IF NOT EXISTS git_seen (
guild_id INTEGER NOT NULL,
repo_key TEXT NOT NULL,
name TEXT,
full_name TEXT,
url TEXT,
description TEXT,
created_remote TEXT,
first_seen_at TEXT NOT NULL,
announced INTEGER DEFAULT 0,
PRIMARY KEY (guild_id, repo_key)
)
"""
)
await db.execute(
"""
CREATE TABLE IF NOT EXISTS git_drop (
id INTEGER PRIMARY KEY AUTOINCREMENT,
guild_id INTEGER NOT NULL,
repo_key TEXT NOT NULL,
full_name TEXT,
url TEXT,
channel_id INTEGER NOT NULL,
message_id INTEGER NOT NULL,
dropped_at TEXT NOT NULL,
UNIQUE(guild_id, message_id)
)
"""
)
await db.execute(
"""
CREATE TABLE IF NOT EXISTS git_vote (
repo_key TEXT NOT NULL,
guild_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
value INTEGER NOT NULL, -- +1 | -1
comment TEXT DEFAULT '',
created_at TEXT NOT NULL,
PRIMARY KEY (repo_key, guild_id, user_id)
)
"""
)
await db.commit()
log.info("SQLite store initialised at %s", self._db_path)
# ── per-guild config ─────────────────────────────────────
async def set_git_config(self, guild_id: int, **fields: Any) -> None:
"""Upsert git config columns for a guild (only provided fields change)."""
allowed = {
"drop_channel_id",
"highlight_channel_id",
"highlight_role_id",
"min_stars",
"highlight_keywords",
"enabled",
}
cols = {k: v for k, v in fields.items() if k in allowed}
now = datetime.now(timezone.utc).isoformat()
async with aiosqlite.connect(self._db_path) as db:
# Ensure a row exists, then patch the requested columns.
await db.execute(
"INSERT OR IGNORE INTO git_config (guild_id, updated_at) VALUES (?, ?)",
(guild_id, now),
)
if cols:
assignments = ", ".join(f"{k} = ?" for k in cols)
await db.execute(
f"UPDATE git_config SET {assignments}, updated_at = ? WHERE guild_id = ?",
(*cols.values(), now, guild_id),
)
await db.commit()
async def get_git_config(self, guild_id: int) -> dict[str, Any] | None:
async with aiosqlite.connect(self._db_path) as db:
db.row_factory = aiosqlite.Row
async with db.execute(
"SELECT * FROM git_config WHERE guild_id = ?", (guild_id,)
) as cur:
row = await cur.fetchone()
return dict(row) if row else None
# ── watches ──────────────────────────────────────────────
async def add_git_watch(
self,
guild_id: int,
scope_type: str,
scope_value: str,
channel_id: int | None,
added_by: int | None,
) -> None:
async with aiosqlite.connect(self._db_path) as db:
await db.execute(
"""
INSERT INTO git_watch
(guild_id, scope_type, scope_value, channel_id, added_by, created_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(guild_id, scope_type, scope_value) DO UPDATE SET
channel_id = excluded.channel_id
""",
(
guild_id,
scope_type,
scope_value,
channel_id,
added_by,
datetime.now(timezone.utc).isoformat(),
),
)
await db.commit()
async def remove_git_watch(self, guild_id: int, scope_type: str, scope_value: str) -> int:
async with aiosqlite.connect(self._db_path) as db:
cur = await db.execute(
"DELETE FROM git_watch WHERE guild_id = ? AND scope_type = ? AND scope_value = ?",
(guild_id, scope_type, scope_value),
)
await db.commit()
return cur.rowcount
async def get_git_watches(self, guild_id: int) -> list[dict[str, Any]]:
async with aiosqlite.connect(self._db_path) as db:
db.row_factory = aiosqlite.Row
async with db.execute(
"SELECT * FROM git_watch WHERE guild_id = ? ORDER BY created_at",
(guild_id,),
) as cur:
return [dict(r) for r in await cur.fetchall()]
async def all_git_watches(self) -> list[dict[str, Any]]:
async with aiosqlite.connect(self._db_path) as db:
db.row_factory = aiosqlite.Row
async with db.execute("SELECT * FROM git_watch ORDER BY guild_id") as cur:
return [dict(r) for r in await cur.fetchall()]
# ── seen repos (newest-only de-dup) ──────────────────────
async def get_seen_keys(self, guild_id: int) -> set[str]:
async with aiosqlite.connect(self._db_path) as db:
async with db.execute(
"SELECT repo_key FROM git_seen WHERE guild_id = ?", (guild_id,)
) as cur:
return {r[0] for r in await cur.fetchall()}
async def mark_seen(
self,
guild_id: int,
repo_key: str,
*,
name: str = "",
full_name: str = "",
url: str = "",
description: str = "",
created_remote: str = "",
announced: bool = False,
) -> None:
async with aiosqlite.connect(self._db_path) as db:
await db.execute(
"""
INSERT OR IGNORE INTO git_seen
(guild_id, repo_key, name, full_name, url, description,
created_remote, first_seen_at, announced)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
guild_id,
repo_key,
name,
full_name,
url,
description,
created_remote,
datetime.now(timezone.utc).isoformat(),
1 if announced else 0,
),
)
await db.commit()
async def has_any_seen(self, guild_id: int) -> bool:
async with aiosqlite.connect(self._db_path) as db:
async with db.execute(
"SELECT 1 FROM git_seen WHERE guild_id = ? LIMIT 1", (guild_id,)
) as cur:
return (await cur.fetchone()) is not None
# ── drops (vote anchors) ─────────────────────────────────
async def record_drop(
self,
guild_id: int,
repo_key: str,
full_name: str,
url: str,
channel_id: int,
message_id: int,
) -> None:
async with aiosqlite.connect(self._db_path) as db:
await db.execute(
"""
INSERT OR REPLACE INTO git_drop
(guild_id, repo_key, full_name, url, channel_id, message_id, dropped_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(
guild_id,
repo_key,
full_name,
url,
channel_id,
message_id,
datetime.now(timezone.utc).isoformat(),
),
)
await db.commit()
async def get_drop_by_message(self, guild_id: int, message_id: int) -> dict[str, Any] | None:
async with aiosqlite.connect(self._db_path) as db:
db.row_factory = aiosqlite.Row
async with db.execute(
"SELECT * FROM git_drop WHERE guild_id = ? AND message_id = ?",
(guild_id, message_id),
) as cur:
row = await cur.fetchone()
return dict(row) if row else None
async def get_latest_drop_for_repo(self, guild_id: int, repo_key: str) -> dict[str, Any] | None:
async with aiosqlite.connect(self._db_path) as db:
db.row_factory = aiosqlite.Row
async with db.execute(
"""
SELECT * FROM git_drop
WHERE guild_id = ? AND repo_key = ?
ORDER BY dropped_at DESC LIMIT 1
""",
(guild_id, repo_key),
) as cur:
row = await cur.fetchone()
return dict(row) if row else None
async def find_drop_by_fullname(self, guild_id: int, full_name: str) -> dict[str, Any] | None:
"""Find the most recent drop whose full_name matches (case-insensitive)."""
async with aiosqlite.connect(self._db_path) as db:
db.row_factory = aiosqlite.Row
async with db.execute(
"""
SELECT * FROM git_drop
WHERE guild_id = ? AND LOWER(full_name) = LOWER(?)
ORDER BY dropped_at DESC LIMIT 1
""",
(guild_id, full_name),
) as cur:
row = await cur.fetchone()
return dict(row) if row else None
# ── community votes ──────────────────────────────────────
async def cast_vote(
self,
guild_id: int,
repo_key: str,
user_id: int,
value: int,
comment: str = "",
) -> None:
"""Insert/replace a single user's vote (+1/-1) for a repo."""
async with aiosqlite.connect(self._db_path) as db:
await db.execute(
"""
INSERT INTO git_vote
(repo_key, guild_id, user_id, value, comment, created_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(repo_key, guild_id, user_id) DO UPDATE SET
value = excluded.value,
comment = excluded.comment,
created_at = excluded.created_at
""",
(
repo_key,
guild_id,
user_id,
1 if value >= 0 else -1,
comment,
datetime.now(timezone.utc).isoformat(),
),
)
await db.commit()
async def clear_vote(self, guild_id: int, repo_key: str, user_id: int) -> int:
async with aiosqlite.connect(self._db_path) as db:
cur = await db.execute(
"DELETE FROM git_vote WHERE guild_id = ? AND repo_key = ? AND user_id = ?",
(guild_id, repo_key, user_id),
)
await db.commit()
return cur.rowcount
async def get_vote_tally(self, guild_id: int, repo_key: str) -> dict[str, int]:
"""Return {'up', 'down', 'score'} for a repo in a guild."""
async with aiosqlite.connect(self._db_path) as db:
async with db.execute(
"""
SELECT
COALESCE(SUM(CASE WHEN value > 0 THEN 1 ELSE 0 END), 0) AS up,
COALESCE(SUM(CASE WHEN value < 0 THEN 1 ELSE 0 END), 0) AS down
FROM git_vote WHERE guild_id = ? AND repo_key = ?
""",
(guild_id, repo_key),
) as cur:
row = await cur.fetchone()
up = int(row[0] or 0)
down = int(row[1] or 0)
return {"up": up, "down": down, "score": up - down}
async def get_user_vote(self, guild_id: int, repo_key: str, user_id: int) -> int | None:
async with aiosqlite.connect(self._db_path) as db:
async with db.execute(
"SELECT value FROM git_vote WHERE guild_id = ? AND repo_key = ? AND user_id = ?",
(guild_id, repo_key, user_id),
) as cur:
row = await cur.fetchone()
return int(row[0]) if row else None
async def get_vote_leaderboard(self, guild_id: int, limit: int = 10) -> list[dict[str, Any]]:
"""Top repos by net vote score, joined with their latest drop metadata."""
async with aiosqlite.connect(self._db_path) as db:
db.row_factory = aiosqlite.Row
async with db.execute(
"""
SELECT
v.repo_key AS repo_key,
SUM(CASE WHEN v.value > 0 THEN 1 ELSE 0 END) AS up,
SUM(CASE WHEN v.value < 0 THEN 1 ELSE 0 END) AS down,
SUM(v.value) AS score,
MAX(d.full_name) AS full_name,
MAX(d.url) AS url
FROM git_vote v
LEFT JOIN git_drop d
ON d.guild_id = v.guild_id AND d.repo_key = v.repo_key
WHERE v.guild_id = ?
GROUP BY v.repo_key
ORDER BY score DESC, up DESC
LIMIT ?
""",
(guild_id, limit),
) as cur:
return [dict(r) for r in await cur.fetchall()]