Add dashboard media catalog and channel settings
This commit is contained in:
parent
65842e6d33
commit
74445a5c86
5 changed files with 857 additions and 50 deletions
|
|
@ -1,8 +1,10 @@
|
|||
DISCORD_BOT_TOKEN=replace-with-your-discord-bot-token
|
||||
DISCORD_CHANNEL_ID=1504278732070981683
|
||||
DISCORD_GATEWAY_ENABLED=true
|
||||
ARCHIVE_STATUS_CONFIG=services.json
|
||||
ARCHIVE_STATUS_STATE=state/status-message.json
|
||||
MEDIA_CATALOG_STATE=state/media-catalog.json
|
||||
BOT_SETTINGS_STATE=state/bot-settings.json
|
||||
MEDIA_CATALOG_MAX_ITEMS=240
|
||||
CHECK_INTERVAL_SECONDS=60
|
||||
HTTP_USER_AGENT=ArchiveStatusBot/1.0
|
||||
DISCORD_DRY_RUN=false
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
DISCORD_BOT_TOKEN=replace-with-your-discord-bot-token
|
||||
DISCORD_CHANNEL_ID=1504278732070981683
|
||||
DISCORD_GATEWAY_ENABLED=true
|
||||
ARCHIVE_STATUS_CONFIG=services.json
|
||||
ARCHIVE_STATUS_STATE=state/status-message.json
|
||||
MEDIA_CATALOG_STATE=state/media-catalog.json
|
||||
BOT_SETTINGS_STATE=state/bot-settings.json
|
||||
MEDIA_CATALOG_MAX_ITEMS=240
|
||||
CHECK_INTERVAL_SECONDS=60
|
||||
HTTP_USER_AGENT=ArchiveStatusBot/1.0
|
||||
DISCORD_DRY_RUN=false
|
||||
|
|
|
|||
43
README.md
43
README.md
|
|
@ -8,6 +8,8 @@ It does not need Discord gateway intents or slash commands for the status module
|
|||
|
||||
The bot also includes a small web dashboard for editing monitored services and forcing immediate Discord refreshes.
|
||||
|
||||
It also has a media catalog module. From the dashboard you can upload `Movies.csv` and/or `Shows.csv`, choose a Discord channel ID, and publish a formatted catalog embed set.
|
||||
|
||||
## Discord Bot Setup
|
||||
|
||||
Create a Discord application and bot in the Discord Developer Portal, then invite it with:
|
||||
|
|
@ -16,18 +18,14 @@ Create a Discord application and bot in the Discord Developer Portal, then invit
|
|||
https://discord.com/oauth2/authorize?client_id=YOUR_CLIENT_ID&permissions=84992&scope=bot
|
||||
```
|
||||
|
||||
Required channel permissions:
|
||||
Required channel permissions for each dashboard-selected channel:
|
||||
|
||||
- View Channel
|
||||
- Send Messages
|
||||
- Embed Links
|
||||
- Read Message History
|
||||
|
||||
The current `status` channel ID is:
|
||||
|
||||
```text
|
||||
1504278732070981683
|
||||
```
|
||||
Channel IDs are configured from the dashboard and stored in `state/bot-settings.json`.
|
||||
|
||||
## Local Setup
|
||||
|
||||
|
|
@ -95,6 +93,8 @@ http://127.0.0.1:8787
|
|||
|
||||
Sign in with `DASHBOARD_USERNAME` and the password you used when generating `DASHBOARD_PASSWORD_HASH`.
|
||||
|
||||
Set the Status and Media channel IDs from the dashboard before publishing Discord updates.
|
||||
|
||||
## Docker Setup
|
||||
|
||||
```sh
|
||||
|
|
@ -127,7 +127,7 @@ If `state/` or `services.json` were created by a previous container as another u
|
|||
sudo chown -R "$(id -u):$(id -g)" services.json state
|
||||
```
|
||||
|
||||
The bot stores the Discord message ID in `state/status-message.json`. Keep that file mounted so the bot edits the same message after restarts.
|
||||
The bot stores channel settings in `state/bot-settings.json` and Discord message IDs in `state/status-message.json` and `state/media-catalog.json`. Keep `state/` mounted so the bot edits the same messages after restarts.
|
||||
|
||||
The deploy compose joins your existing reverse-proxy network:
|
||||
|
||||
|
|
@ -181,13 +181,42 @@ http://127.0.0.1:8787
|
|||
The dashboard currently supports:
|
||||
|
||||
- viewing monitored services
|
||||
- selecting the Discord channel used by the status updater
|
||||
- adding/removing service rows
|
||||
- editing check URL, display URL, expected statuses, timeout, and keyword
|
||||
- saving `services.json`
|
||||
- forcing an immediate check and Discord message update
|
||||
- uploading `Movies.csv` and `Shows.csv`
|
||||
- publishing a paginated media catalog to a selected Discord channel
|
||||
|
||||
The sidebar leaves room for future modules like polls, webhooks, automations, and service integrations without changing the bot shape later.
|
||||
|
||||
## Media Catalog
|
||||
|
||||
Open the dashboard and switch to `Media`. Set the target Discord channel ID, choose `Movies.csv`, `Shows.csv`, or both, then publish.
|
||||
|
||||
Channel selections are stored in:
|
||||
|
||||
```env
|
||||
BOT_SETTINGS_STATE=state/bot-settings.json
|
||||
```
|
||||
|
||||
The parser accepts common column names such as `title`, `name`, `year`, `genre`, `genres`, `rating`, `runtime`, `summary`, `overview`, `season`, and `episode`. Show exports that contain one row per episode are grouped by show title where possible.
|
||||
|
||||
The bot stores media catalog message IDs in:
|
||||
|
||||
```env
|
||||
MEDIA_CATALOG_STATE=state/media-catalog.json
|
||||
```
|
||||
|
||||
Republishing edits the previous catalog messages in place. If the catalog gets shorter, the bot attempts to delete old extra messages.
|
||||
|
||||
By default, Discord output is capped at 240 movies and 240 shows to avoid flooding a channel. Change this with:
|
||||
|
||||
```env
|
||||
MEDIA_CATALOG_MAX_ITEMS=240
|
||||
```
|
||||
|
||||
## Service Config
|
||||
|
||||
Each service supports:
|
||||
|
|
|
|||
255
dashboard.html
255
dashboard.html
|
|
@ -107,9 +107,19 @@
|
|||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
padding: 8px 9px;
|
||||
color: var(--muted);
|
||||
font-weight: 650;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: #1b1d21;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
|
|
@ -265,6 +275,63 @@
|
|||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.view[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.media-form {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 0.8fr) minmax(220px, 1fr) minmax(220px, 1fr);
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.channel-form {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 360px);
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.media-field label {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.media-field input[type="file"] {
|
||||
padding: 7px;
|
||||
}
|
||||
|
||||
.media-status {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 1px;
|
||||
background: var(--line);
|
||||
}
|
||||
|
||||
.media-status div {
|
||||
background: var(--panel);
|
||||
padding: 14px;
|
||||
min-height: 74px;
|
||||
}
|
||||
|
||||
.media-status span {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.media-status strong {
|
||||
display: block;
|
||||
font-size: 16px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.token-screen {
|
||||
max-width: 420px;
|
||||
margin: 80px auto;
|
||||
|
|
@ -300,7 +367,9 @@
|
|||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.service-row {
|
||||
.service-row,
|
||||
.channel-form,
|
||||
.media-form {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
|
@ -321,7 +390,10 @@
|
|||
}
|
||||
|
||||
nav,
|
||||
.service-row {
|
||||
.service-row,
|
||||
.channel-form,
|
||||
.media-form,
|
||||
.media-status {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
|
@ -343,14 +415,15 @@
|
|||
<aside>
|
||||
<div class="brand">Archive Bot</div>
|
||||
<nav aria-label="Bot modules">
|
||||
<div class="nav-item active"><span>Status</span><span>Ready</span></div>
|
||||
<button class="nav-item active" type="button" data-view="status"><span>Status</span><span>Ready</span></button>
|
||||
<button class="nav-item" type="button" data-view="media"><span>Media</span><span>CSV</span></button>
|
||||
<div class="nav-item disabled"><span>Polls</span><span>Later</span></div>
|
||||
<div class="nav-item disabled"><span>Webhooks</span><span>Later</span></div>
|
||||
<div class="nav-item disabled"><span>Automations</span><span>Later</span></div>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<main>
|
||||
<section id="statusView" class="view">
|
||||
<div class="topbar">
|
||||
<h1>Status Services</h1>
|
||||
<div class="actions">
|
||||
|
|
@ -362,6 +435,13 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<section class="panel channel-form" aria-label="Status channel">
|
||||
<div class="media-field">
|
||||
<label for="statusChannelId">Status Channel ID</label>
|
||||
<input id="statusChannelId" inputmode="numeric" placeholder="Discord channel ID">
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="summary panel" aria-label="Current status summary">
|
||||
<div class="summary-item">
|
||||
<span>Services</span>
|
||||
|
|
@ -388,19 +468,73 @@
|
|||
</div>
|
||||
<div id="services" class="service-list"></div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section id="mediaView" class="view" hidden>
|
||||
<div class="topbar">
|
||||
<h1>Media Catalog</h1>
|
||||
<div class="actions">
|
||||
<button id="mediaRefresh" type="button">Refresh</button>
|
||||
<button id="publishMedia" class="primary" type="button">Publish to Discord</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<div class="panel-title">CSV Upload</div>
|
||||
<div id="mediaMessage" class="message"></div>
|
||||
</div>
|
||||
<div class="media-form">
|
||||
<div class="media-field">
|
||||
<label for="mediaChannelId">Channel ID</label>
|
||||
<input id="mediaChannelId" inputmode="numeric" placeholder="Discord channel ID">
|
||||
</div>
|
||||
<div class="media-field">
|
||||
<label for="moviesCsv">Movies.csv</label>
|
||||
<input id="moviesCsv" type="file" accept=".csv,text/csv">
|
||||
</div>
|
||||
<div class="media-field">
|
||||
<label for="showsCsv">Shows.csv</label>
|
||||
<input id="showsCsv" type="file" accept=".csv,text/csv">
|
||||
</div>
|
||||
</div>
|
||||
<div class="media-status" aria-label="Media catalog status">
|
||||
<div>
|
||||
<span>Movies</span>
|
||||
<strong id="mediaMovieCount">Not published</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Shows</span>
|
||||
<strong id="mediaShowCount">Not published</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Messages</span>
|
||||
<strong id="mediaMessageCount">0</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Published</span>
|
||||
<strong id="mediaPublishedAt">Never</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const appEl = document.querySelector("#app");
|
||||
const loginEl = document.querySelector("#login");
|
||||
const statusViewEl = document.querySelector("#statusView");
|
||||
const mediaViewEl = document.querySelector("#mediaView");
|
||||
const servicesEl = document.querySelector("#services");
|
||||
const messageEl = document.querySelector("#message");
|
||||
const mediaMessageEl = document.querySelector("#mediaMessage");
|
||||
const loginMessageEl = document.querySelector("#loginMessage");
|
||||
|
||||
let services = [];
|
||||
let results = new Map();
|
||||
let csrfToken = "";
|
||||
let channels = { statusChannelId: "", mediaChannelId: "" };
|
||||
|
||||
function headers(method = "GET") {
|
||||
const base = { "Content-Type": "application/json" };
|
||||
|
|
@ -432,6 +566,19 @@
|
|||
messageEl.classList.toggle("error", isError);
|
||||
}
|
||||
|
||||
function setMediaMessage(text, isError = false) {
|
||||
mediaMessageEl.textContent = text;
|
||||
mediaMessageEl.classList.toggle("error", isError);
|
||||
}
|
||||
|
||||
function showView(name) {
|
||||
statusViewEl.hidden = name !== "status";
|
||||
mediaViewEl.hidden = name !== "media";
|
||||
document.querySelectorAll("[data-view]").forEach((item) => {
|
||||
item.classList.toggle("active", item.dataset.view === name);
|
||||
});
|
||||
}
|
||||
|
||||
function showLogin(message = "") {
|
||||
appEl.hidden = true;
|
||||
loginEl.hidden = false;
|
||||
|
|
@ -462,6 +609,11 @@
|
|||
}
|
||||
|
||||
function renderSummary(payload) {
|
||||
channels = payload.channels || {
|
||||
statusChannelId: payload.channelId || channels.statusChannelId || "",
|
||||
mediaChannelId: channels.mediaChannelId || payload.channelId || ""
|
||||
};
|
||||
document.querySelector("#statusChannelId").value = channels.statusChannelId || "";
|
||||
const online = payload.results.filter((result) => result.ok).length;
|
||||
document.querySelector("#serviceCount").textContent = payload.services.length;
|
||||
document.querySelector("#onlineCount").textContent = online;
|
||||
|
|
@ -569,6 +721,80 @@
|
|||
showApp();
|
||||
}
|
||||
|
||||
function renderMediaStatus(payload) {
|
||||
channels.mediaChannelId = payload.channelId || channels.mediaChannelId || "";
|
||||
document.querySelector("#mediaChannelId").value = channels.mediaChannelId;
|
||||
document.querySelector("#mediaMovieCount").textContent = payload.movieCount == null ? "Not published" : payload.movieCount;
|
||||
document.querySelector("#mediaShowCount").textContent = payload.showCount == null ? "Not published" : payload.showCount;
|
||||
document.querySelector("#mediaMessageCount").textContent = Array.isArray(payload.messageIds) ? payload.messageIds.length : 0;
|
||||
document.querySelector("#mediaPublishedAt").textContent = payload.publishedAt
|
||||
? new Date(payload.publishedAt).toLocaleString([], { dateStyle: "short", timeStyle: "short" })
|
||||
: "Never";
|
||||
}
|
||||
|
||||
async function loadMediaStatus() {
|
||||
const payload = await api("/api/media");
|
||||
renderMediaStatus(payload);
|
||||
return payload;
|
||||
}
|
||||
|
||||
function currentChannelSettings() {
|
||||
const statusChannelId = document.querySelector("#statusChannelId").value.trim() || channels.statusChannelId || "";
|
||||
const mediaChannelId = document.querySelector("#mediaChannelId").value.trim() || channels.mediaChannelId || statusChannelId;
|
||||
return { statusChannelId, mediaChannelId };
|
||||
}
|
||||
|
||||
async function saveChannelSettings() {
|
||||
const payload = await api("/api/settings", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ channels: currentChannelSettings() })
|
||||
});
|
||||
channels = payload.channels || currentChannelSettings();
|
||||
document.querySelector("#statusChannelId").value = channels.statusChannelId || "";
|
||||
document.querySelector("#mediaChannelId").value = channels.mediaChannelId || "";
|
||||
return channels;
|
||||
}
|
||||
|
||||
function readFileText(input) {
|
||||
const file = input.files && input.files[0];
|
||||
if (!file) return Promise.resolve({ name: "", text: "" });
|
||||
return file.text().then((text) => ({ name: file.name, text }));
|
||||
}
|
||||
|
||||
async function publishMedia() {
|
||||
const moviesInput = document.querySelector("#moviesCsv");
|
||||
const showsInput = document.querySelector("#showsCsv");
|
||||
const [movies, shows] = await Promise.all([
|
||||
readFileText(moviesInput),
|
||||
readFileText(showsInput)
|
||||
]);
|
||||
|
||||
if (!movies.text.trim() && !shows.text.trim()) {
|
||||
throw new Error("Choose a Movies.csv or Shows.csv file before publishing.");
|
||||
}
|
||||
|
||||
setMediaMessage("Uploading CSV files...");
|
||||
const payload = await api("/api/media", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
channelId: document.querySelector("#mediaChannelId").value.trim() || channels.mediaChannelId,
|
||||
moviesCsv: movies.text,
|
||||
showsCsv: shows.text,
|
||||
movieFileName: movies.name || "Movies.csv",
|
||||
showFileName: shows.name || "Shows.csv"
|
||||
})
|
||||
});
|
||||
|
||||
renderMediaStatus({
|
||||
channelId: payload.channelId,
|
||||
messageIds: payload.messageIds,
|
||||
movieCount: payload.movieCount,
|
||||
showCount: payload.showCount,
|
||||
publishedAt: new Date().toISOString()
|
||||
});
|
||||
setMediaMessage(`Published ${payload.movieCount} movies and ${payload.showCount} shows.`);
|
||||
}
|
||||
|
||||
async function loadSession() {
|
||||
const session = await api("/api/session");
|
||||
csrfToken = session.csrfToken || "";
|
||||
|
|
@ -604,6 +830,7 @@
|
|||
|
||||
async function saveServices() {
|
||||
services = collectServices().map(normalizeService);
|
||||
await saveChannelSettings();
|
||||
const payload = await api("/api/services", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ services })
|
||||
|
|
@ -615,6 +842,7 @@
|
|||
}
|
||||
|
||||
async function checkNow() {
|
||||
await saveChannelSettings();
|
||||
const payload = await api("/api/check", { method: "POST", body: "{}" });
|
||||
results = new Map(payload.results.map((result) => [result.name, result]));
|
||||
renderSummary({ services, results: payload.results, lastCheckedAt: new Date().toISOString() });
|
||||
|
|
@ -648,10 +876,29 @@
|
|||
checkNow().catch((error) => setMessage(error.message, true));
|
||||
});
|
||||
|
||||
document.querySelector("#mediaRefresh").addEventListener("click", () => {
|
||||
loadMediaStatus()
|
||||
.then(() => setMediaMessage(""))
|
||||
.catch((error) => setMediaMessage(error.message, true));
|
||||
});
|
||||
|
||||
document.querySelector("#publishMedia").addEventListener("click", () => {
|
||||
publishMedia().catch((error) => setMediaMessage(error.message, true));
|
||||
});
|
||||
|
||||
document.querySelector("#logout").addEventListener("click", () => {
|
||||
logout();
|
||||
});
|
||||
|
||||
document.querySelectorAll("[data-view]").forEach((item) => {
|
||||
item.addEventListener("click", () => {
|
||||
showView(item.dataset.view);
|
||||
if (item.dataset.view === "media") {
|
||||
loadMediaStatus().catch((error) => setMediaMessage(error.message, true));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelector("#addService").addEventListener("click", () => {
|
||||
services.push(normalizeService({ name: "New Service", url: "https://example.com" }));
|
||||
renderServices();
|
||||
|
|
|
|||
539
status_bot.py
539
status_bot.py
|
|
@ -5,10 +5,13 @@ from __future__ import annotations
|
|||
|
||||
import base64
|
||||
import asyncio
|
||||
import csv
|
||||
import hashlib
|
||||
import hmac
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import signal
|
||||
import socket
|
||||
|
|
@ -32,9 +35,11 @@ DISCORD_API = "https://discord.com/api/v10"
|
|||
DEFAULT_INTERVAL_SECONDS = 60
|
||||
DEFAULT_TIMEOUT_SECONDS = 10
|
||||
MAX_DISCORD_EMBEDS = 10
|
||||
MAX_REQUEST_BYTES = 1_000_000
|
||||
MAX_REQUEST_BYTES = 8_000_000
|
||||
SESSION_COOKIE = "archive_bot_session"
|
||||
PBKDF2_ITERATIONS = 390_000
|
||||
MEDIA_ITEMS_PER_EMBED = 10
|
||||
MAX_MEDIA_ITEMS_PER_CATEGORY = 240
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
|
@ -60,6 +65,19 @@ class CheckResult:
|
|||
error: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MediaItem:
|
||||
title: str
|
||||
media_type: str
|
||||
year: str | None = None
|
||||
genres: str | None = None
|
||||
rating: str | None = None
|
||||
runtime: str | None = None
|
||||
summary: str | None = None
|
||||
seasons: int | None = None
|
||||
episodes: int | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DashboardAuthConfig:
|
||||
username: str
|
||||
|
|
@ -148,12 +166,16 @@ class BotRuntime:
|
|||
channel_id: str,
|
||||
config_path: Path,
|
||||
state_path: Path,
|
||||
media_state_path: Path,
|
||||
settings_path: Path,
|
||||
dry_run: bool = False,
|
||||
) -> None:
|
||||
self.token = token
|
||||
self.channel_id = channel_id
|
||||
self.default_channel_id = channel_id
|
||||
self.config_path = config_path
|
||||
self.state_path = state_path
|
||||
self.media_state_path = media_state_path
|
||||
self.settings_path = settings_path
|
||||
self.dry_run = dry_run
|
||||
self.lock = threading.Lock()
|
||||
self.last_results: list[CheckResult] = []
|
||||
|
|
@ -475,6 +497,13 @@ def discord_request(
|
|||
raise RuntimeError(f"Discord API {method} {path} failed: {exc.code} {detail}") from exc
|
||||
|
||||
|
||||
def discord_delete_message(token: str, channel_id: str, message_id: str) -> None:
|
||||
try:
|
||||
discord_request("DELETE", token, f"/channels/{channel_id}/messages/{message_id}")
|
||||
except RuntimeError as exc:
|
||||
print(f"Could not delete old media catalog message {message_id}: {exc}", file=sys.stderr)
|
||||
|
||||
|
||||
def discord_bot_identity(token: str) -> dict[str, Any]:
|
||||
return discord_request("GET", token, "/users/@me")
|
||||
|
||||
|
|
@ -499,6 +528,449 @@ def save_state(path: Path, state: dict[str, Any]) -> None:
|
|||
temporary.replace(path)
|
||||
|
||||
|
||||
def validate_channel_id(value: str, label: str) -> str:
|
||||
channel_id = value.strip()
|
||||
if not channel_id:
|
||||
raise ValueError(f"{label} channel ID is required")
|
||||
if not channel_id.isdigit():
|
||||
raise ValueError(f"{label} channel ID must be a Discord numeric channel ID")
|
||||
return channel_id
|
||||
|
||||
|
||||
def channel_settings(runtime: BotRuntime) -> dict[str, str]:
|
||||
data = load_state(runtime.settings_path)
|
||||
status_channel = str(data.get("status_channel_id", "")).strip() or runtime.default_channel_id
|
||||
media_channel = str(data.get("media_channel_id", "")).strip() or status_channel
|
||||
return {
|
||||
"statusChannelId": status_channel,
|
||||
"mediaChannelId": media_channel,
|
||||
}
|
||||
|
||||
|
||||
def save_channel_settings(runtime: BotRuntime, status_channel_id: str, media_channel_id: str) -> dict[str, str]:
|
||||
status_channel = validate_channel_id(status_channel_id, "Status")
|
||||
media_channel = validate_channel_id(media_channel_id or status_channel, "Media")
|
||||
state = load_state(runtime.settings_path)
|
||||
state.update(
|
||||
{
|
||||
"status_channel_id": status_channel,
|
||||
"media_channel_id": media_channel,
|
||||
"updated_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
)
|
||||
save_state(runtime.settings_path, state)
|
||||
return channel_settings(runtime)
|
||||
|
||||
|
||||
def save_media_channel_setting(runtime: BotRuntime, media_channel_id: str) -> dict[str, str]:
|
||||
media_channel = validate_channel_id(media_channel_id, "Media")
|
||||
state = load_state(runtime.settings_path)
|
||||
state["media_channel_id"] = media_channel
|
||||
state["updated_at"] = datetime.now(timezone.utc).isoformat()
|
||||
save_state(runtime.settings_path, state)
|
||||
return channel_settings(runtime)
|
||||
|
||||
|
||||
def normalize_csv_key(value: str) -> str:
|
||||
return "".join(character for character in value.lower() if character.isalnum())
|
||||
|
||||
|
||||
def value_from_row(row: dict[str, str], aliases: list[str]) -> str:
|
||||
for alias in aliases:
|
||||
value = row.get(alias, "").strip()
|
||||
if value:
|
||||
return value
|
||||
return ""
|
||||
|
||||
|
||||
def parse_int_text(value: str) -> int | None:
|
||||
digits = "".join(character for character in value if character.isdigit())
|
||||
if not digits:
|
||||
return None
|
||||
try:
|
||||
return int(digits)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def parse_year_text(value: str) -> str | None:
|
||||
match = re.search(r"(18|19|20|21)\d{2}", value)
|
||||
return match.group(0) if match else None
|
||||
|
||||
|
||||
def clean_media_text(value: str, limit: int = 220) -> str | None:
|
||||
cleaned = " ".join(str(value).replace("\r", " ").replace("\n", " ").split())
|
||||
if not cleaned:
|
||||
return None
|
||||
if len(cleaned) <= limit:
|
||||
return cleaned
|
||||
return cleaned[: limit - 1].rstrip() + "…"
|
||||
|
||||
|
||||
def format_runtime(value: str) -> str | None:
|
||||
cleaned = clean_media_text(value, 48)
|
||||
if not cleaned:
|
||||
return None
|
||||
if not cleaned.isdigit():
|
||||
return cleaned
|
||||
|
||||
minutes = int(cleaned)
|
||||
if minutes <= 0:
|
||||
return None
|
||||
hours, remainder = divmod(minutes, 60)
|
||||
if hours and remainder:
|
||||
return f"{hours}h {remainder}m"
|
||||
if hours:
|
||||
return f"{hours}h"
|
||||
return f"{remainder}m"
|
||||
|
||||
|
||||
def parse_media_csv(csv_text: str, media_type: str, filename: str) -> list[MediaItem]:
|
||||
text = csv_text.lstrip("\ufeff")
|
||||
if not text.strip():
|
||||
return []
|
||||
|
||||
try:
|
||||
dialect = csv.Sniffer().sniff(text[:4096], delimiters=",;\t|")
|
||||
except csv.Error:
|
||||
dialect = csv.excel
|
||||
|
||||
reader = csv.DictReader(io.StringIO(text), dialect=dialect)
|
||||
if not reader.fieldnames:
|
||||
raise ValueError(f"{filename} does not have a header row")
|
||||
|
||||
title_aliases = [
|
||||
"title",
|
||||
"name",
|
||||
"movie",
|
||||
"movietitle",
|
||||
"sorttitle",
|
||||
"originaltitle",
|
||||
]
|
||||
if media_type == "show":
|
||||
title_aliases = [
|
||||
"showtitle",
|
||||
"seriestitle",
|
||||
"series",
|
||||
"show",
|
||||
"grandparenttitle",
|
||||
"parenttitle",
|
||||
"title",
|
||||
"name",
|
||||
]
|
||||
|
||||
year_aliases = ["year", "releaseyear", "productionyear", "releasedate", "premiered", "date"]
|
||||
genre_aliases = ["genres", "genre", "tags", "categories"]
|
||||
rating_aliases = ["rating", "contentrating", "agerating", "certification", "mpaarating"]
|
||||
runtime_aliases = ["runtime", "duration", "runtimeminutes", "length", "durationminutes"]
|
||||
summary_aliases = ["summary", "overview", "description", "plot", "tagline"]
|
||||
season_count_aliases = ["seasoncount", "seasons"]
|
||||
season_number_aliases = ["season", "seasonnumber", "seasonindex", "parentindex"]
|
||||
episode_count_aliases = ["episodecount", "episodes"]
|
||||
episode_number_aliases = ["episode", "episodenumber", "episodeindex", "index"]
|
||||
|
||||
items: list[MediaItem] = []
|
||||
show_groups: dict[tuple[str, str], dict[str, Any]] = {}
|
||||
|
||||
for raw_row in reader:
|
||||
row = {
|
||||
normalize_csv_key(str(key)): str(value or "")
|
||||
for key, value in raw_row.items()
|
||||
if key is not None
|
||||
}
|
||||
title = clean_media_text(value_from_row(row, title_aliases), 120)
|
||||
if not title:
|
||||
continue
|
||||
|
||||
year = parse_year_text(value_from_row(row, year_aliases))
|
||||
genres = clean_media_text(value_from_row(row, genre_aliases), 120)
|
||||
rating = clean_media_text(value_from_row(row, rating_aliases), 32)
|
||||
runtime = format_runtime(value_from_row(row, runtime_aliases))
|
||||
summary = clean_media_text(value_from_row(row, summary_aliases))
|
||||
season_count = parse_int_text(value_from_row(row, season_count_aliases))
|
||||
season_number = parse_int_text(value_from_row(row, season_number_aliases))
|
||||
episode_count = parse_int_text(value_from_row(row, episode_count_aliases))
|
||||
episode_number = parse_int_text(value_from_row(row, episode_number_aliases))
|
||||
|
||||
if media_type == "show":
|
||||
key = (title.casefold(), year or "")
|
||||
group = show_groups.setdefault(
|
||||
key,
|
||||
{
|
||||
"title": title,
|
||||
"year": year,
|
||||
"genres": genres,
|
||||
"rating": rating,
|
||||
"runtime": runtime,
|
||||
"summary": summary,
|
||||
"seasons": set(),
|
||||
"episodes": 0,
|
||||
"explicit_season_count": None,
|
||||
"explicit_episode_count": None,
|
||||
},
|
||||
)
|
||||
if not group.get("genres") and genres:
|
||||
group["genres"] = genres
|
||||
if not group.get("rating") and rating:
|
||||
group["rating"] = rating
|
||||
if not group.get("runtime") and runtime:
|
||||
group["runtime"] = runtime
|
||||
if not group.get("summary") and summary:
|
||||
group["summary"] = summary
|
||||
if season_number is not None:
|
||||
group["seasons"].add(season_number)
|
||||
if episode_number is not None:
|
||||
group["episodes"] += 1
|
||||
if season_count is not None:
|
||||
current = group.get("explicit_season_count")
|
||||
group["explicit_season_count"] = max(current or 0, season_count)
|
||||
if episode_count is not None:
|
||||
current = group.get("explicit_episode_count")
|
||||
group["explicit_episode_count"] = max(current or 0, episode_count)
|
||||
continue
|
||||
|
||||
items.append(
|
||||
MediaItem(
|
||||
title=title,
|
||||
media_type=media_type,
|
||||
year=year,
|
||||
genres=genres,
|
||||
rating=rating,
|
||||
runtime=runtime,
|
||||
summary=summary,
|
||||
)
|
||||
)
|
||||
|
||||
if media_type == "show":
|
||||
for group in show_groups.values():
|
||||
seasons = group.get("explicit_season_count") or len(group["seasons"]) or None
|
||||
episodes = group.get("explicit_episode_count") or group["episodes"] or None
|
||||
items.append(
|
||||
MediaItem(
|
||||
title=group["title"],
|
||||
media_type=media_type,
|
||||
year=group["year"],
|
||||
genres=group["genres"],
|
||||
rating=group["rating"],
|
||||
runtime=group["runtime"],
|
||||
summary=group["summary"],
|
||||
seasons=seasons,
|
||||
episodes=episodes,
|
||||
)
|
||||
)
|
||||
|
||||
deduped: dict[tuple[str, str], MediaItem] = {}
|
||||
for item in items:
|
||||
deduped.setdefault((item.title.casefold(), item.year or ""), item)
|
||||
|
||||
parsed = sorted(deduped.values(), key=lambda item: (item.title.casefold(), item.year or ""))
|
||||
if not parsed:
|
||||
raise ValueError(f"{filename} did not contain any rows with a recognizable title/name column")
|
||||
return parsed
|
||||
|
||||
|
||||
def media_item_heading(item: MediaItem) -> str:
|
||||
if item.year:
|
||||
return f"{item.title} ({item.year})"
|
||||
return item.title
|
||||
|
||||
|
||||
def media_item_value(item: MediaItem) -> str:
|
||||
details: list[str] = []
|
||||
if item.genres:
|
||||
details.append(item.genres)
|
||||
if item.rating:
|
||||
details.append(item.rating)
|
||||
if item.runtime and item.media_type == "movie":
|
||||
details.append(item.runtime)
|
||||
if item.media_type == "show":
|
||||
counts = []
|
||||
if item.seasons:
|
||||
counts.append(f"{item.seasons} season{'s' if item.seasons != 1 else ''}")
|
||||
if item.episodes:
|
||||
counts.append(f"{item.episodes} episode{'s' if item.episodes != 1 else ''}")
|
||||
if counts:
|
||||
details.append(" · ".join(counts))
|
||||
|
||||
lines = []
|
||||
if details:
|
||||
lines.append(" • ".join(details))
|
||||
if item.summary:
|
||||
lines.append(item.summary)
|
||||
return "\n".join(lines)[:1024] or "No details provided."
|
||||
|
||||
|
||||
def media_category_embeds(
|
||||
title: str,
|
||||
items: list[MediaItem],
|
||||
color: int,
|
||||
total_count: int,
|
||||
source_name: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
embeds: list[dict[str, Any]] = []
|
||||
omitted = max(total_count - len(items), 0)
|
||||
page_count = max((len(items) + MEDIA_ITEMS_PER_EMBED - 1) // MEDIA_ITEMS_PER_EMBED, 1)
|
||||
|
||||
for page_index in range(page_count):
|
||||
start = page_index * MEDIA_ITEMS_PER_EMBED
|
||||
chunk = items[start : start + MEDIA_ITEMS_PER_EMBED]
|
||||
description = f"{total_count} total from `{source_name}`"
|
||||
if omitted:
|
||||
description += f" · showing first {len(items)}"
|
||||
if page_count > 1:
|
||||
description += f" · page {page_index + 1}/{page_count}"
|
||||
|
||||
fields = [
|
||||
{
|
||||
"name": media_item_heading(item)[:256],
|
||||
"value": media_item_value(item),
|
||||
"inline": False,
|
||||
}
|
||||
for item in chunk
|
||||
]
|
||||
embeds.append(
|
||||
{
|
||||
"title": title,
|
||||
"description": description,
|
||||
"color": color,
|
||||
"fields": fields,
|
||||
}
|
||||
)
|
||||
|
||||
return embeds
|
||||
|
||||
|
||||
def render_media_catalog_payloads(
|
||||
movies: list[MediaItem],
|
||||
shows: list[MediaItem],
|
||||
movie_total: int,
|
||||
show_total: int,
|
||||
movie_source: str,
|
||||
show_source: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
now = datetime.now(timezone.utc)
|
||||
embeds: list[dict[str, Any]] = [
|
||||
{
|
||||
"title": "The Mithral Archive Media Catalog",
|
||||
"description": "Current movies and shows available in the archive.",
|
||||
"color": 0x5865F2,
|
||||
"fields": [
|
||||
{"name": "Movies", "value": str(movie_total), "inline": True},
|
||||
{"name": "Shows", "value": str(show_total), "inline": True},
|
||||
{"name": "Updated", "value": now.strftime("%Y-%m-%d %H:%M UTC"), "inline": True},
|
||||
],
|
||||
"timestamp": now.isoformat(),
|
||||
}
|
||||
]
|
||||
if movies:
|
||||
embeds.extend(media_category_embeds("Movies", movies, 0xF59E0B, movie_total, movie_source))
|
||||
if shows:
|
||||
embeds.extend(media_category_embeds("Shows", shows, 0x10B981, show_total, show_source))
|
||||
|
||||
payloads = []
|
||||
for index in range(0, len(embeds), MAX_DISCORD_EMBEDS):
|
||||
payloads.append(
|
||||
{
|
||||
"content": "**The Mithral Archive Media Catalog**" if index == 0 else "",
|
||||
"embeds": embeds[index : index + MAX_DISCORD_EMBEDS],
|
||||
}
|
||||
)
|
||||
return payloads
|
||||
|
||||
|
||||
def publish_media_catalog(
|
||||
runtime: BotRuntime,
|
||||
channel_id: str,
|
||||
movies_csv: str,
|
||||
shows_csv: str,
|
||||
movie_filename: str,
|
||||
show_filename: str,
|
||||
) -> dict[str, Any]:
|
||||
if runtime.dry_run:
|
||||
raise RuntimeError("Discord dry run is enabled; media catalog was parsed but not sent")
|
||||
|
||||
settings = channel_settings(runtime)
|
||||
channel = validate_channel_id(channel_id.strip() or settings["mediaChannelId"], "Media")
|
||||
|
||||
movies_all = parse_media_csv(movies_csv, "movie", movie_filename) if movies_csv.strip() else []
|
||||
shows_all = parse_media_csv(shows_csv, "show", show_filename) if shows_csv.strip() else []
|
||||
if not movies_all and not shows_all:
|
||||
raise ValueError("Upload at least one Movies.csv or Shows.csv file")
|
||||
|
||||
limit = int(os.getenv("MEDIA_CATALOG_MAX_ITEMS", str(MAX_MEDIA_ITEMS_PER_CATEGORY)))
|
||||
movies = movies_all[:limit]
|
||||
shows = shows_all[:limit]
|
||||
payloads = render_media_catalog_payloads(
|
||||
movies=movies,
|
||||
shows=shows,
|
||||
movie_total=len(movies_all),
|
||||
show_total=len(shows_all),
|
||||
movie_source=movie_filename or "Movies.csv",
|
||||
show_source=show_filename or "Shows.csv",
|
||||
)
|
||||
|
||||
state = load_state(runtime.media_state_path)
|
||||
old_channel = str(state.get("channel_id", "")).strip()
|
||||
existing_ids = [
|
||||
str(message_id)
|
||||
for message_id in state.get("message_ids", [])
|
||||
if str(message_id).strip()
|
||||
]
|
||||
if old_channel and old_channel != channel:
|
||||
for old_id in existing_ids:
|
||||
discord_delete_message(runtime.token, old_channel, old_id)
|
||||
existing_ids = []
|
||||
|
||||
message_ids: list[str] = []
|
||||
for index, payload in enumerate(payloads):
|
||||
if index < len(existing_ids):
|
||||
discord_request("PATCH", runtime.token, f"/channels/{channel}/messages/{existing_ids[index]}", payload)
|
||||
message_ids.append(existing_ids[index])
|
||||
continue
|
||||
message = discord_request("POST", runtime.token, f"/channels/{channel}/messages", payload)
|
||||
message_id = str(message.get("id", "")).strip()
|
||||
if not message_id:
|
||||
raise RuntimeError("Discord did not return a message id for the media catalog")
|
||||
message_ids.append(message_id)
|
||||
|
||||
for old_id in existing_ids[len(payloads) :]:
|
||||
discord_delete_message(runtime.token, channel, old_id)
|
||||
|
||||
save_state(
|
||||
runtime.media_state_path,
|
||||
{
|
||||
"channel_id": channel,
|
||||
"message_ids": message_ids,
|
||||
"movie_count": len(movies_all),
|
||||
"show_count": len(shows_all),
|
||||
"published_at": datetime.now(timezone.utc).isoformat(),
|
||||
},
|
||||
)
|
||||
save_media_channel_setting(runtime, channel)
|
||||
|
||||
return {
|
||||
"channelId": channel,
|
||||
"messageIds": message_ids,
|
||||
"movieCount": len(movies_all),
|
||||
"showCount": len(shows_all),
|
||||
"displayedMovieCount": len(movies),
|
||||
"displayedShowCount": len(shows),
|
||||
}
|
||||
|
||||
|
||||
def media_catalog_status(runtime: BotRuntime) -> dict[str, Any]:
|
||||
state = load_state(runtime.media_state_path)
|
||||
settings = channel_settings(runtime)
|
||||
return {
|
||||
"channelId": str(state.get("channel_id", "")).strip() or settings["mediaChannelId"],
|
||||
"messageIds": state.get("message_ids", []) if isinstance(state.get("message_ids"), list) else [],
|
||||
"movieCount": state.get("movie_count"),
|
||||
"showCount": state.get("show_count"),
|
||||
"publishedAt": state.get("published_at"),
|
||||
"maxItemsPerCategory": int(os.getenv("MEDIA_CATALOG_MAX_ITEMS", str(MAX_MEDIA_ITEMS_PER_CATEGORY))),
|
||||
}
|
||||
|
||||
|
||||
def render_embeds(results: list[CheckResult]) -> list[dict[str, Any]]:
|
||||
checked_at = datetime.now(timezone.utc)
|
||||
online = sum(1 for result in results if result.ok)
|
||||
|
|
@ -631,7 +1103,8 @@ def run_check_cycle(runtime: BotRuntime) -> tuple[str, list[CheckResult]]:
|
|||
if runtime.dry_run:
|
||||
message_id = "dry-run"
|
||||
else:
|
||||
message_id = upsert_status_message(runtime.token, runtime.channel_id, runtime.state_path, results)
|
||||
channel_id = validate_channel_id(channel_settings(runtime)["statusChannelId"], "Status")
|
||||
message_id = upsert_status_message(runtime.token, channel_id, runtime.state_path, results)
|
||||
|
||||
with runtime.lock:
|
||||
runtime.last_results = results
|
||||
|
|
@ -644,6 +1117,7 @@ def run_check_cycle(runtime: BotRuntime) -> tuple[str, list[CheckResult]]:
|
|||
|
||||
def runtime_status(runtime: BotRuntime) -> dict[str, Any]:
|
||||
services = load_services(runtime.config_path)
|
||||
settings = channel_settings(runtime)
|
||||
with runtime.lock:
|
||||
results = list(runtime.last_results)
|
||||
return {
|
||||
|
|
@ -652,7 +1126,8 @@ def runtime_status(runtime: BotRuntime) -> dict[str, Any]:
|
|||
"lastError": runtime.last_error,
|
||||
"lastMessageId": runtime.last_message_id,
|
||||
"lastCheckedAt": runtime.last_checked_at.isoformat() if runtime.last_checked_at else None,
|
||||
"channelId": runtime.channel_id,
|
||||
"channelId": settings["statusChannelId"],
|
||||
"channels": settings,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -757,6 +1232,16 @@ def make_dashboard_handler(runtime: BotRuntime, auth: DashboardAuth | None) -> t
|
|||
return
|
||||
self.send_json(HTTPStatus.OK, runtime_status(runtime))
|
||||
return
|
||||
if self.path == "/api/media":
|
||||
if self.require_auth() is None:
|
||||
return
|
||||
self.send_json(HTTPStatus.OK, media_catalog_status(runtime))
|
||||
return
|
||||
if self.path == "/api/settings":
|
||||
if self.require_auth() is None:
|
||||
return
|
||||
self.send_json(HTTPStatus.OK, {"channels": channel_settings(runtime)})
|
||||
return
|
||||
self.send_error(HTTPStatus.NOT_FOUND)
|
||||
|
||||
def do_POST(self) -> None:
|
||||
|
|
@ -775,6 +1260,12 @@ def make_dashboard_handler(runtime: BotRuntime, auth: DashboardAuth | None) -> t
|
|||
if self.path == "/api/services":
|
||||
self.handle_services()
|
||||
return
|
||||
if self.path == "/api/media":
|
||||
self.handle_media_catalog()
|
||||
return
|
||||
if self.path == "/api/settings":
|
||||
self.handle_settings()
|
||||
return
|
||||
self.send_error(HTTPStatus.NOT_FOUND)
|
||||
|
||||
def require_auth(self, require_csrf: bool = False) -> tuple[str, DashboardSession] | None:
|
||||
|
|
@ -947,6 +1438,40 @@ def make_dashboard_handler(runtime: BotRuntime, auth: DashboardAuth | None) -> t
|
|||
},
|
||||
)
|
||||
|
||||
def handle_media_catalog(self) -> None:
|
||||
try:
|
||||
data = self.read_json()
|
||||
result = publish_media_catalog(
|
||||
runtime=runtime,
|
||||
channel_id=str(data.get("channelId", "")),
|
||||
movies_csv=str(data.get("moviesCsv", "")),
|
||||
shows_csv=str(data.get("showsCsv", "")),
|
||||
movie_filename=str(data.get("movieFileName", "Movies.csv") or "Movies.csv"),
|
||||
show_filename=str(data.get("showFileName", "Shows.csv") or "Shows.csv"),
|
||||
)
|
||||
except Exception as exc:
|
||||
self.send_json(HTTPStatus.BAD_REQUEST, {"error": str(exc)})
|
||||
return
|
||||
|
||||
self.send_json(HTTPStatus.OK, result)
|
||||
|
||||
def handle_settings(self) -> None:
|
||||
try:
|
||||
data = self.read_json()
|
||||
channels = data.get("channels", data)
|
||||
if not isinstance(channels, dict):
|
||||
raise ValueError("Settings payload must include a channels object")
|
||||
result = save_channel_settings(
|
||||
runtime,
|
||||
str(channels.get("statusChannelId", "")),
|
||||
str(channels.get("mediaChannelId", "")),
|
||||
)
|
||||
except Exception as exc:
|
||||
self.send_json(HTTPStatus.BAD_REQUEST, {"error": str(exc)})
|
||||
return
|
||||
|
||||
self.send_json(HTTPStatus.OK, {"channels": result})
|
||||
|
||||
return DashboardHandler
|
||||
|
||||
|
||||
|
|
@ -979,11 +1504,13 @@ def main() -> int:
|
|||
|
||||
token = env("DISCORD_BOT_TOKEN")
|
||||
token = normalize_discord_token(token)
|
||||
channel_id = env("DISCORD_CHANNEL_ID")
|
||||
channel_id = os.getenv("DISCORD_CHANNEL_ID", "").strip()
|
||||
config_path = Path(env("ARCHIVE_STATUS_CONFIG", "services.json"))
|
||||
state_path = Path(env("ARCHIVE_STATUS_STATE", "state/status-message.json"))
|
||||
media_state_path = Path(env("MEDIA_CATALOG_STATE", "state/media-catalog.json"))
|
||||
settings_path = Path(env("BOT_SETTINGS_STATE", "state/bot-settings.json"))
|
||||
interval = int(env("CHECK_INTERVAL_SECONDS", str(DEFAULT_INTERVAL_SECONDS)))
|
||||
runtime = BotRuntime(token, channel_id, config_path, state_path, dry_run=bool_env("DISCORD_DRY_RUN", False))
|
||||
runtime = BotRuntime(token, channel_id, config_path, state_path, media_state_path, settings_path, dry_run=bool_env("DISCORD_DRY_RUN", False))
|
||||
gateway = None
|
||||
if bool_env("DISCORD_GATEWAY_ENABLED", True) and not runtime.dry_run:
|
||||
gateway = DiscordGatewayManager(token)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue