commit b90ec9cdd04d08bce696fc57d25a30ea312cb8e1 Author: subinacls Date: Sat Jun 27 21:20:43 2026 -0400 Initial sanitary RepoRadar release diff --git a/.dockerignore b/.dockerignore new file mode 100755 index 0000000..7e0f6f7 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +data/ +*.db +*.sqlite +*.sqlite3 +__pycache__/ +*.pyc +*.pyo +.env +.git +.gitignore +.dockerignore +*.md diff --git a/.env.example b/.env.example new file mode 100755 index 0000000..b5082ad --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..12ad76f --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +.env +data/ +*.db +*.sqlite +*.sqlite3 +pycache/ +*.pyc +*.pyo +.venv/ +venv/ diff --git a/Dockerfile b/Dockerfile new file mode 100755 index 0000000..5519ca3 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100755 index 0000000..7368c84 --- /dev/null +++ b/README.md @@ -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 → **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 +``` diff --git a/cogs/__init__.py b/cogs/__init__.py new file mode 100755 index 0000000..9c9f3d6 --- /dev/null +++ b/cogs/__init__.py @@ -0,0 +1 @@ +"""RepoRadar cogs package.""" diff --git a/cogs/gitscan.py b/cogs/gitscan.py new file mode 100755 index 0000000..f7d9809 --- /dev/null +++ b/cogs/gitscan.py @@ -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)) diff --git a/config.py b/config.py new file mode 100755 index 0000000..907a67e --- /dev/null +++ b/config.py @@ -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() diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100755 index 0000000..45f2831 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/main.py b/main.py new file mode 100755 index 0000000..34aa99a --- /dev/null +++ b/main.py @@ -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) diff --git a/requirements.txt b/requirements.txt new file mode 100755 index 0000000..77581b0 --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/utils/__init__.py b/utils/__init__.py new file mode 100755 index 0000000..f22dfdd --- /dev/null +++ b/utils/__init__.py @@ -0,0 +1 @@ +"""RepoRadar utility package: git client, store helpers, and embed builders.""" diff --git a/utils/embeds.py b/utils/embeds.py new file mode 100755 index 0000000..e33f5c3 --- /dev/null +++ b/utils/embeds.py @@ -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) diff --git a/utils/git_api.py b/utils/git_api.py new file mode 100755 index 0000000..07c5e9e --- /dev/null +++ b/utils/git_api.py @@ -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 "", + 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 diff --git a/utils/helpers.py b/utils/helpers.py new file mode 100755 index 0000000..41201c6 --- /dev/null +++ b/utils/helpers.py @@ -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"" + + +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()]