From a5f77187b3067105efdbf840a75ff7ff59cca9ed Mon Sep 17 00:00:00 2001 From: cottongin Date: Wed, 1 Apr 2026 22:20:18 -0400 Subject: [PATCH] feat: add dashboard ping button and .env file support Add a "Send IRC Message" section to the admin dashboard that sends a configurable privmsg to any IRC nick or channel via all connected bots. New POST /admin/ping endpoint broadcasts a "privmsg" WebSocket message type, handled by both Limnoria and Sopel plugins. Also enable pydantic-settings .env file loading (python-dotenv) and add .env.example documenting all NTR_* configuration variables. Made-with: Cursor --- .env.example | 34 +++++++++++++ .gitignore | 1 + plugins/limnoria/NtrPlaylist/plugin.py | 4 ++ plugins/sopel/ntr_playlist.py | 3 ++ pyproject.toml | 1 + src/ntr_fetcher/api.py | 4 ++ src/ntr_fetcher/config.py | 5 +- src/ntr_fetcher/dashboard.py | 25 ++++++++++ src/ntr_fetcher/main.py | 2 + src/ntr_fetcher/static/dashboard.html | 69 ++++++++++++++++++++++++++ 10 files changed, 147 insertions(+), 1 deletion(-) create mode 100644 .env.example diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4681777 --- /dev/null +++ b/.env.example @@ -0,0 +1,34 @@ +# NtR SoundCloud Fetcher — environment variables +# Copy to .env and fill in values. Shell env vars override these. + +# --- Required ----------------------------------------------------------- + +NTR_ADMIN_TOKEN=changeme + +# --- Server -------------------------------------------------------------- + +# NTR_HOST=127.0.0.1 +# NTR_PORT=8000 +# NTR_DB_PATH=./ntr_fetcher.db +# NTR_POLL_INTERVAL_SECONDS=3600 + +# --- SoundCloud ----------------------------------------------------------- + +# NTR_SOUNDCLOUD_USER=nicktherat + +# --- Show schedule -------------------------------------------------------- + +# NTR_SHOW_DAY=2 # 0=Mon … 6=Sun (default: 2=Wed) +# NTR_SHOW_HOUR=22 # Hour in Eastern time +# NTR_SHOW_ROTATION_DELAY_HOURS=0 + +# --- Dashboard (all three required to enable) ----------------------------- + +# NTR_WEB_USER= +# NTR_WEB_PASSWORD= +# NTR_SECRET_KEY= + +# --- Ping (optional defaults for the dashboard ping form) ----------------- + +# NTR_PING_TARGET= +# NTR_PING_MESSAGE= diff --git a/.gitignore b/.gitignore index c02f989..55d3191 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ build/ # Environment / secrets .env .env.* +!.env.example # AI session artifacts chat-summaries/ diff --git a/plugins/limnoria/NtrPlaylist/plugin.py b/plugins/limnoria/NtrPlaylist/plugin.py index ea646d4..c1b5c3c 100644 --- a/plugins/limnoria/NtrPlaylist/plugin.py +++ b/plugins/limnoria/NtrPlaylist/plugin.py @@ -185,6 +185,10 @@ class NtrPlaylist(callbacks.Plugin): msg = ircmsgs.privmsg(channel, data["message"]) self._irc.queueMsg(msg) LOGGER.info("Announced to %s: %s", channel, data["message"]) + elif data.get("type") == "privmsg" and "target" in data and "message" in data: + msg = ircmsgs.privmsg(data["target"], data["message"]) + self._irc.queueMsg(msg) + LOGGER.info("Sent privmsg to %s: %s", data["target"], data["message"]) elif data.get("type") == "status": LOGGER.info( "Status update: %d bot(s) connected, clients=%s", diff --git a/plugins/sopel/ntr_playlist.py b/plugins/sopel/ntr_playlist.py index 4fb3d72..fbe8c6c 100644 --- a/plugins/sopel/ntr_playlist.py +++ b/plugins/sopel/ntr_playlist.py @@ -95,6 +95,9 @@ def _ws_listener(bot): if data.get("type") == "announce" and "message" in data: bot.say(data["message"], channel) LOGGER.info("Announced to %s: %s", channel, data["message"]) + elif data.get("type") == "privmsg" and "target" in data and "message" in data: + bot.say(data["message"], data["target"]) + LOGGER.info("Sent privmsg to %s: %s", data["target"], data["message"]) elif data.get("type") == "status": LOGGER.info( "Status update: %d bot(s) connected, clients=%s", diff --git a/pyproject.toml b/pyproject.toml index 0265995..54f143f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,7 @@ dependencies = [ "uvicorn[standard]", "httpx", "pydantic-settings", + "python-dotenv", ] [project.optional-dependencies] diff --git a/src/ntr_fetcher/api.py b/src/ntr_fetcher/api.py index a19effb..c14f402 100644 --- a/src/ntr_fetcher/api.py +++ b/src/ntr_fetcher/api.py @@ -33,6 +33,8 @@ def create_app( web_user: str | None = None, web_password: str | None = None, secret_key: str | None = None, + ping_target: str | None = None, + ping_message: str | None = None, ) -> FastAPI: app = FastAPI(title="NtR SoundCloud Fetcher") @@ -167,6 +169,8 @@ def create_app( secret_key=secret_key, show_day=show_day, show_hour=show_hour, + ping_target=ping_target or "", + ping_message=ping_message or "", ) app.include_router(dashboard_router) diff --git a/src/ntr_fetcher/config.py b/src/ntr_fetcher/config.py index 164ea10..58a11ce 100644 --- a/src/ntr_fetcher/config.py +++ b/src/ntr_fetcher/config.py @@ -2,7 +2,7 @@ from pydantic_settings import BaseSettings class Settings(BaseSettings): - model_config = {"env_prefix": "NTR_"} + model_config = {"env_prefix": "NTR_", "env_file": ".env", "env_file_encoding": "utf-8"} port: int = 8000 host: str = "127.0.0.1" @@ -18,6 +18,9 @@ class Settings(BaseSettings): web_password: str | None = None secret_key: str | None = None + ping_target: str | None = None + ping_message: str | None = None + @property def dashboard_enabled(self) -> bool: return all([self.web_user, self.web_password, self.secret_key]) diff --git a/src/ntr_fetcher/dashboard.py b/src/ntr_fetcher/dashboard.py index fc6e2f7..baef0ee 100644 --- a/src/ntr_fetcher/dashboard.py +++ b/src/ntr_fetcher/dashboard.py @@ -44,6 +44,11 @@ class AnnounceRequest(BaseModel): position: int +class PingRequest(BaseModel): + target: str + message: str + + def create_dashboard_router( db: Database, manager: AnnounceManager, @@ -53,6 +58,8 @@ def create_dashboard_router( secret_key: str, show_day: int = 2, show_hour: int = 22, + ping_target: str = "", + ping_message: str = "", ) -> APIRouter: router = APIRouter() @@ -98,6 +105,8 @@ def create_dashboard_router( return RedirectResponse(url="/login", status_code=303) html = (STATIC_DIR / "dashboard.html").read_text() html = html.replace("{{WS_TOKEN}}", admin_token) + html = html.replace("{{PING_TARGET}}", ping_target) + html = html.replace("{{PING_MESSAGE}}", ping_message) return HTMLResponse(html) @router.post("/admin/announce") @@ -119,6 +128,22 @@ def create_dashboard_router( await manager.broadcast({"type": "announce", "message": message}) return {"status": "announced", "message": message} + @router.post("/admin/ping") + async def ping(body: PingRequest, request: Request): + user = _get_session_user(request) + if user is None: + auth_header = request.headers.get("authorization", "") + if not auth_header.startswith("Bearer ") or auth_header.removeprefix("Bearer ") != admin_token: + raise HTTPException(status_code=401, detail="Unauthorized") + + target = body.target.strip() + message = body.message.strip() + if not target or not message: + raise HTTPException(status_code=422, detail="Target and message must not be empty") + + await manager.broadcast({"type": "privmsg", "target": target, "message": message}) + return {"status": "sent", "target": target, "message": message} + @router.websocket("/ws/announce") async def ws_announce(websocket: WebSocket): await websocket.accept() diff --git a/src/ntr_fetcher/main.py b/src/ntr_fetcher/main.py index 2fc5c71..67efd0a 100644 --- a/src/ntr_fetcher/main.py +++ b/src/ntr_fetcher/main.py @@ -85,6 +85,8 @@ def run() -> None: web_user=settings.web_user, web_password=settings.web_password, secret_key=settings.secret_key, + ping_target=settings.ping_target, + ping_message=settings.ping_message, ) @app.on_event("startup") diff --git a/src/ntr_fetcher/static/dashboard.html b/src/ntr_fetcher/static/dashboard.html index ebe382d..9ce93fe 100644 --- a/src/ntr_fetcher/static/dashboard.html +++ b/src/ntr_fetcher/static/dashboard.html @@ -67,6 +67,21 @@ font-weight: 600; } .btn-group { display: flex; gap: 4px; } + .ping-section { + margin-top: 2rem; + padding-top: 1.5rem; + border-top: 1px solid #333; + } + .ping-form { + display: flex; + gap: 8px; + align-items: end; + flex-wrap: wrap; + } + .ping-form label { margin-bottom: 0; } + .ping-form input { margin-bottom: 0; } + .ping-btn:disabled { opacity: 0.5; cursor: not-allowed; } + .ping-btn.success { background: #4caf50; border-color: #4caf50; pointer-events: none; } @@ -89,6 +104,22 @@

Loading shows...

+
+

Send IRC Message

+
+ + + +
+
+
@@ -256,6 +287,39 @@ } } + async function sendPing(btn) { + const target = document.getElementById("ping-target").value.trim(); + const message = document.getElementById("ping-message").value.trim(); + if (!target || !message) { + showToast("Target and message are required", true); + return; + } + btn.disabled = true; + btn.textContent = "..."; + try { + const resp = await fetch("/admin/ping", { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({target, message}), + }); + if (!resp.ok) { + const data = await resp.json().catch(() => ({})); + throw new Error(data.detail || "Send failed"); + } + btn.textContent = "\u2713"; + btn.classList.add("success"); + setTimeout(() => { + btn.textContent = "Send"; + btn.classList.remove("success"); + btn.disabled = subscriberCount === 0; + }, 2000); + } catch (e) { + showToast(e.message, true); + btn.textContent = "Send"; + btn.disabled = subscriberCount === 0; + } + } + function updateStatus(count, clients) { subscriberCount = count; const dot = document.getElementById("status-dot"); @@ -288,6 +352,11 @@ btn.title = ""; } }); + const pingBtn = document.getElementById("ping-btn"); + if (pingBtn && !pingBtn.classList.contains("success")) { + pingBtn.disabled = count === 0; + pingBtn.title = count === 0 ? "No bots connected" : ""; + } } let wsBackoff = 1000;