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:
34
.env.example
Normal file
34
.env.example
Normal file
@@ -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=
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -23,6 +23,7 @@ build/
|
|||||||
# Environment / secrets
|
# Environment / secrets
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
# AI session artifacts
|
# AI session artifacts
|
||||||
chat-summaries/
|
chat-summaries/
|
||||||
|
|||||||
@@ -185,6 +185,10 @@ class NtrPlaylist(callbacks.Plugin):
|
|||||||
msg = ircmsgs.privmsg(channel, data["message"])
|
msg = ircmsgs.privmsg(channel, data["message"])
|
||||||
self._irc.queueMsg(msg)
|
self._irc.queueMsg(msg)
|
||||||
LOGGER.info("Announced to %s: %s", channel, data["message"])
|
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":
|
elif data.get("type") == "status":
|
||||||
LOGGER.info(
|
LOGGER.info(
|
||||||
"Status update: %d bot(s) connected, clients=%s",
|
"Status update: %d bot(s) connected, clients=%s",
|
||||||
|
|||||||
@@ -95,6 +95,9 @@ def _ws_listener(bot):
|
|||||||
if data.get("type") == "announce" and "message" in data:
|
if data.get("type") == "announce" and "message" in data:
|
||||||
bot.say(data["message"], channel)
|
bot.say(data["message"], channel)
|
||||||
LOGGER.info("Announced to %s: %s", channel, data["message"])
|
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":
|
elif data.get("type") == "status":
|
||||||
LOGGER.info(
|
LOGGER.info(
|
||||||
"Status update: %d bot(s) connected, clients=%s",
|
"Status update: %d bot(s) connected, clients=%s",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ dependencies = [
|
|||||||
"uvicorn[standard]",
|
"uvicorn[standard]",
|
||||||
"httpx",
|
"httpx",
|
||||||
"pydantic-settings",
|
"pydantic-settings",
|
||||||
|
"python-dotenv",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ def create_app(
|
|||||||
web_user: str | None = None,
|
web_user: str | None = None,
|
||||||
web_password: str | None = None,
|
web_password: str | None = None,
|
||||||
secret_key: str | None = None,
|
secret_key: str | None = None,
|
||||||
|
ping_target: str | None = None,
|
||||||
|
ping_message: str | None = None,
|
||||||
) -> FastAPI:
|
) -> FastAPI:
|
||||||
app = FastAPI(title="NtR SoundCloud Fetcher")
|
app = FastAPI(title="NtR SoundCloud Fetcher")
|
||||||
|
|
||||||
@@ -167,6 +169,8 @@ def create_app(
|
|||||||
secret_key=secret_key,
|
secret_key=secret_key,
|
||||||
show_day=show_day,
|
show_day=show_day,
|
||||||
show_hour=show_hour,
|
show_hour=show_hour,
|
||||||
|
ping_target=ping_target or "",
|
||||||
|
ping_message=ping_message or "",
|
||||||
)
|
)
|
||||||
app.include_router(dashboard_router)
|
app.include_router(dashboard_router)
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from pydantic_settings import BaseSettings
|
|||||||
|
|
||||||
|
|
||||||
class Settings(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
|
port: int = 8000
|
||||||
host: str = "127.0.0.1"
|
host: str = "127.0.0.1"
|
||||||
@@ -18,6 +18,9 @@ class Settings(BaseSettings):
|
|||||||
web_password: str | None = None
|
web_password: str | None = None
|
||||||
secret_key: str | None = None
|
secret_key: str | None = None
|
||||||
|
|
||||||
|
ping_target: str | None = None
|
||||||
|
ping_message: str | None = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def dashboard_enabled(self) -> bool:
|
def dashboard_enabled(self) -> bool:
|
||||||
return all([self.web_user, self.web_password, self.secret_key])
|
return all([self.web_user, self.web_password, self.secret_key])
|
||||||
|
|||||||
@@ -44,6 +44,11 @@ class AnnounceRequest(BaseModel):
|
|||||||
position: int
|
position: int
|
||||||
|
|
||||||
|
|
||||||
|
class PingRequest(BaseModel):
|
||||||
|
target: str
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
def create_dashboard_router(
|
def create_dashboard_router(
|
||||||
db: Database,
|
db: Database,
|
||||||
manager: AnnounceManager,
|
manager: AnnounceManager,
|
||||||
@@ -53,6 +58,8 @@ def create_dashboard_router(
|
|||||||
secret_key: str,
|
secret_key: str,
|
||||||
show_day: int = 2,
|
show_day: int = 2,
|
||||||
show_hour: int = 22,
|
show_hour: int = 22,
|
||||||
|
ping_target: str = "",
|
||||||
|
ping_message: str = "",
|
||||||
) -> APIRouter:
|
) -> APIRouter:
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -98,6 +105,8 @@ def create_dashboard_router(
|
|||||||
return RedirectResponse(url="/login", status_code=303)
|
return RedirectResponse(url="/login", status_code=303)
|
||||||
html = (STATIC_DIR / "dashboard.html").read_text()
|
html = (STATIC_DIR / "dashboard.html").read_text()
|
||||||
html = html.replace("{{WS_TOKEN}}", admin_token)
|
html = html.replace("{{WS_TOKEN}}", admin_token)
|
||||||
|
html = html.replace("{{PING_TARGET}}", ping_target)
|
||||||
|
html = html.replace("{{PING_MESSAGE}}", ping_message)
|
||||||
return HTMLResponse(html)
|
return HTMLResponse(html)
|
||||||
|
|
||||||
@router.post("/admin/announce")
|
@router.post("/admin/announce")
|
||||||
@@ -119,6 +128,22 @@ def create_dashboard_router(
|
|||||||
await manager.broadcast({"type": "announce", "message": message})
|
await manager.broadcast({"type": "announce", "message": message})
|
||||||
return {"status": "announced", "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")
|
@router.websocket("/ws/announce")
|
||||||
async def ws_announce(websocket: WebSocket):
|
async def ws_announce(websocket: WebSocket):
|
||||||
await websocket.accept()
|
await websocket.accept()
|
||||||
|
|||||||
@@ -85,6 +85,8 @@ def run() -> None:
|
|||||||
web_user=settings.web_user,
|
web_user=settings.web_user,
|
||||||
web_password=settings.web_password,
|
web_password=settings.web_password,
|
||||||
secret_key=settings.secret_key,
|
secret_key=settings.secret_key,
|
||||||
|
ping_target=settings.ping_target,
|
||||||
|
ping_message=settings.ping_message,
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
|
|||||||
@@ -67,6 +67,21 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
.btn-group { display: flex; gap: 4px; }
|
.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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -89,6 +104,22 @@
|
|||||||
<p>Loading shows...</p>
|
<p>Loading shows...</p>
|
||||||
</section>
|
</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>
|
<div class="toast" id="toast"></div>
|
||||||
</main>
|
</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) {
|
function updateStatus(count, clients) {
|
||||||
subscriberCount = count;
|
subscriberCount = count;
|
||||||
const dot = document.getElementById("status-dot");
|
const dot = document.getElementById("status-dot");
|
||||||
@@ -288,6 +352,11 @@
|
|||||||
btn.title = "";
|
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;
|
let wsBackoff = 1000;
|
||||||
|
|||||||
Reference in New Issue
Block a user