431 lines
18 KiB
Python
Executable File
431 lines
18 KiB
Python
Executable File
"""
|
|
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()]
|