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
This commit is contained in:
cottongin
2026-04-01 22:20:18 -04:00
parent 82049ab47f
commit a5f77187b3
10 changed files with 147 additions and 1 deletions

View File

@@ -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)

View File

@@ -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])

View File

@@ -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()

View File

@@ -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")

View File

@@ -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; }
</style>
</head>
<body>
@@ -89,6 +104,22 @@
<p>Loading shows...</p>
</section>
<section class="ping-section">
<h4>Send IRC Message</h4>
<div class="ping-form">
<label>
Target
<input type="text" id="ping-target" value="{{PING_TARGET}}" placeholder="nick or #channel">
</label>
<label>
Message
<input type="text" id="ping-message" value="{{PING_MESSAGE}}" placeholder="message text">
</label>
<button class="ping-btn" id="ping-btn" disabled title="No bots connected"
onclick="sendPing(this)">Send</button>
</div>
</section>
<div class="toast" id="toast"></div>
</main>
@@ -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;