Initial sanitary RepoRadar release
This commit is contained in:
Executable
+12
@@ -0,0 +1,12 @@
|
||||
data/
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
.env
|
||||
.git
|
||||
.gitignore
|
||||
.dockerignore
|
||||
*.md
|
||||
Executable
+38
@@ -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
@@ -0,0 +1,10 @@
|
||||
.env
|
||||
data/
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
pycache/
|
||||
*.pyc
|
||||
*.pyo
|
||||
.venv/
|
||||
venv/
|
||||
Executable
+31
@@ -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"]
|
||||
@@ -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
|
||||
```
|
||||
Executable
+1
@@ -0,0 +1 @@
|
||||
"""RepoRadar cogs package."""
|
||||
Executable
+930
@@ -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))
|
||||
@@ -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()
|
||||
Executable
+13
@@ -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:
|
||||
@@ -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)
|
||||
Executable
+7
@@ -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
|
||||
Executable
+1
@@ -0,0 +1 @@
|
||||
"""RepoRadar utility package: git client, store helpers, and embed builders."""
|
||||
Executable
+235
@@ -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)
|
||||
Executable
+357
@@ -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
|
||||
Executable
+430
@@ -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()]
|
||||
Reference in New Issue
Block a user