diff options
| author | Pinapelz <yukais@pinapelz.com> | 2026-10-29 19:58:05 -0700 |
|---|---|---|
| committer | Pinapelz <yukais@pinapelz.com> | 2026-10-29 19:58:05 -0700 |
| commit | 96bfe2e1505b4ae250f2d5201f9541ceb01cec84 (patch) | |
| tree | b2164a76dc3aa5b69e160d1135d3385bcbe6108d | |
| parent | 150858067059ba615c548f826bb343589993b4bb (diff) | |
ts web slopify code
| -rw-r--r-- | .dockerignore | 9 | ||||
| -rw-r--r-- | .env.template | 11 | ||||
| -rw-r--r-- | .gitignore | 3 | ||||
| -rw-r--r-- | Dockerfile | 44 | ||||
| -rw-r--r-- | README.md | 55 | ||||
| -rw-r--r-- | __pycache__/app.cpython-313.pyc | bin | 15762 -> 25145 bytes | |||
| -rw-r--r-- | __pycache__/config.cpython-313.pyc | bin | 1494 -> 6022 bytes | |||
| -rw-r--r-- | app.py | 283 | ||||
| -rw-r--r-- | cache.json | 2 | ||||
| -rw-r--r-- | config.py | 170 | ||||
| -rw-r--r-- | config.toml | 93 | ||||
| -rw-r--r-- | docker-compose.yml | 25 | ||||
| -rw-r--r-- | gateway/package-lock.json | 107 | ||||
| -rw-r--r-- | gateway/package.json | 21 | ||||
| -rw-r--r-- | gateway/src/auth.js | 83 | ||||
| -rw-r--r-- | gateway/src/config.js | 97 | ||||
| -rw-r--r-- | gateway/src/index.js | 39 | ||||
| -rw-r--r-- | gateway/src/opus.js | 59 | ||||
| -rw-r--r-- | gateway/src/server.js | 128 | ||||
| -rw-r--r-- | gateway/src/voice-session.js | 500 | ||||
| -rw-r--r-- | index.css | 777 | ||||
| -rw-r--r-- | pyproject.toml | 1 | ||||
| -rw-r--r-- | requirements.txt | 3 | ||||
| -rw-r--r-- | run.py | 121 | ||||
| -rw-r--r-- | static/mic-worklet.js | 60 | ||||
| -rw-r--r-- | static/voice.js | 1145 | ||||
| -rw-r--r-- | uv.lock | 13 |
27 files changed, 3702 insertions, 147 deletions
diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8c7f8db --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +.git +.gitignore +.venv +__pycache__ +*.pyc +.env +cache.json +reference +gateway/node_modules diff --git a/.env.template b/.env.template index 648a522..80f0bab 100644 --- a/.env.template +++ b/.env.template @@ -1,4 +1,13 @@ +# Secrets and per-deployment overrides. Read by BOTH the dashboard and the +# voice gateway. Everything else lives in config.toml. TS_HOST= -TS_PORT= +TS_API_KEY= +# Must match the TeamSpeak server password. Required for web voice: visitors +# have to type it to join, and voice stays disabled while it is empty. +TS_SERVER_PASSWORD= + +# Optional overrides of config.toml values TS_WEBQUERY_PORT= TS_SERVER_ID= +TS_VOICE_PORT= +WEB_PORT= @@ -222,3 +222,6 @@ __marimo__/ .jpg .png cache.json + +# Voice gateway (Node) +gateway/node_modules/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f82982c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,44 @@ +# Multi-arch (linux/amd64, linux/arm64) image for the TeamSpeak 6 dashboard + browser voice gateway. +FROM node:22-bookworm-slim AS node-runtime + +FROM python:3.13-slim-bookworm + +# Copy Node.js 22 runtime (required by @honeybbq/teamspeak and process.loadEnvFile) +COPY --from=node-runtime /usr/local/bin/node /usr/local/bin/node +COPY --from=node-runtime /usr/local/lib/node_modules /usr/local/lib/node_modules +RUN ln -s /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm \ + && useradd --create-home --shell /bin/bash appuser + +WORKDIR /app + +# Install Python dependencies first (cached layer) +COPY pyproject.toml ./ +RUN pip install --no-cache-dir \ + "flask>=3.1.3" \ + "flask-cors>=6.0.5" \ + "python-dotenv>=1.0.1" \ + "requests>=2.34.2" + +# Install Node.js voice gateway dependencies (pure JS + WASM, no native compilation needed on arm64) +COPY gateway/package.json gateway/package-lock.json* ./gateway/ +RUN npm --prefix ./gateway ci --omit=dev --no-audit --no-fund \ + || npm --prefix ./gateway install --omit=dev --no-audit --no-fund + +# Copy application source +COPY app.py config.py run.py config.toml index.css ./ +COPY static ./static +COPY gateway/src ./gateway/src + +RUN chown -R appuser:appuser /app +USER appuser + +ENV PYTHONUNBUFFERED=1 \ + WEB_HOST=0.0.0.0 \ + WEB_PORT=5000 + +EXPOSE 5000 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD node -e "fetch('http://127.0.0.1:' + (process.env.WEB_PORT || 5000) + '/healthz').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))" + +CMD ["python", "run.py"] @@ -1,15 +1,40 @@ -# Teamspeak 6 Web Server Status
-A simple webview of your Teamspeak 6 Server. This is a Python re-write of [ts6-web-explorer](https://github.com/Guraz/ts6-web-explorer) along with a visual overhaul
-
-<img width="852" height="906" alt="image" src="https://github.com/user-attachments/assets/00cf9ea7-cfad-4633-8d3b-7a66039fb87b" />
-
-# Notes
-```
-uv sync
-uv run app.py
-```
-- Rename `.env.template` to `.env` and fill in the parameters as required
-- Spacer channels are hidden
-- Channels with the text `AFK` are shown, the number of users in this channel is used as the AFK users indicator
-- Edit and configure `config.py` for all other non-essential settings
-- Images in `config.py` are referenced based on the root of this repo
+# Teamspeak 6 Web Server Status +A simple webview of your Teamspeak 6 Server. This is a Python re-write of [ts6-web-explorer](https://github.com/Guraz/ts6-web-explorer) along with a visual overhaul + +<img width="852" height="906" alt="image" src="https://github.com/user-attachments/assets/00cf9ea7-cfad-4633-8d3b-7a66039fb87b" /> + +Visitors who know the server password can also **join voice straight from the browser**, no TeamSpeak client needed. + +# Running +Requires Python 3.13+ (uv) and, for browser voice, Node.js 22.12+. +``` +uv sync +uv run run.py +``` +That one command starts the dashboard **and** the voice gateway (gateway npm dependencies are installed automatically on first run). Open `http://<host>:5000`. + +- Copy `.env.template` to `.env` and fill in the secrets (`TS_HOST`, `TS_API_KEY`, `TS_SERVER_PASSWORD`) +- Everything else is configured in `config.toml`, which is shared by the dashboard and the voice gateway +- Images in `config.toml` are referenced based on the root of this repo +- Spacer channels are hidden +- Channels with the text `AFK` are shown, the number of users in this channel is used as the AFK users indicator +- `uv run app.py` still runs the dashboard alone (no voice) + +# Browser voice +``` +browser ──HTTP/WS :5000──▶ gateway (Node) ──proxy──▶ dashboard (Flask, 127.0.0.1:5001) + │ + └──UDP voice──▶ TeamSpeak server :9987 +``` +- The gateway owns the public port: it serves `/ws/voice` and forwards everything else to Flask, so there is only one port to expose / reverse-proxy. +- Each browser gets its own TeamSpeak client inside the gateway (via [`@honeybbq/teamspeak`](https://github.com/HoneyBBQ/teamspeak-js), pure JS). Mic audio is sent as 20 ms PCM frames over the WebSocket and encoded to Opus on the gateway; incoming Opus is decoded in the browser with WebCodecs (or on the gateway for browsers without it). No WebRTC. +- **Access control:** voice is only offered when `TS_SERVER_PASSWORD` is set. Visitors must type the server password; the gateway checks it (constant time) *before* opening any TeamSpeak connection, and the TeamSpeak server checks it again. Repeated wrong passwords lock the IP out (`[voice] max_failed_logins`, `lockout_minutes`). Cross-origin WebSocket connections are rejected. +- Limits for small machines: `[voice] max_sessions` and `max_sessions_per_ip`. +- While connected, the channel list is rendered live from the voice session (talking indicators, join buttons per channel, password-protected channels prompt for their password). +- Controls: mute, deafen, voice activity (with sensitivity) or push-to-talk (hold the button or `V`), output volume. + +## Deployment notes +- **Browsers only allow microphone access on HTTPS or `localhost`.** Put the app behind a TLS reverse proxy (Caddy, nginx, ...) and forward WebSocket upgrades for `/ws/voice`. If your proxy rewrites the `Host` header, add your public origin to `[voice] allowed_origins`; set `trust_proxy = true` so per-IP limits use `X-Forwarded-For`. +- **arm64 / SBCs:** everything is pure JS/WASM (Opus via `opusscript`), so `npm` needs no compiler. If you want lower CPU use, `cd gateway && npm install @discordjs/opus` (native addon) and the gateway will pick it up automatically. +- The gateway needs outbound UDP to the TeamSpeak voice port (`TS_VOICE_PORT`, default 9987). +- Gateway tests: `cd gateway && npm test`. diff --git a/__pycache__/app.cpython-313.pyc b/__pycache__/app.cpython-313.pyc Binary files differindex c01cd98..be184c3 100644 --- a/__pycache__/app.cpython-313.pyc +++ b/__pycache__/app.cpython-313.pyc diff --git a/__pycache__/config.cpython-313.pyc b/__pycache__/config.cpython-313.pyc Binary files differindex eda5147..afde1ca 100644 --- a/__pycache__/config.cpython-313.pyc +++ b/__pycache__/config.cpython-313.pyc @@ -1,4 +1,5 @@ import json +import os import time from pathlib import Path from urllib.parse import quote @@ -7,11 +8,24 @@ import requests from flask import Flask, Response, render_template_string, request, send_from_directory from flask_cors import CORS -from config import BASE_DIR, CONFIG +from config import BASE_DIR, CONFIG, VOICE_AVAILABLE -app = Flask(__name__) +app = Flask(__name__, static_folder=str(BASE_DIR / "static"), static_url_path="/static") CORS(app) +# Directories that must never be served by the catch-all asset route. +PRIVATE_DIRS = {"gateway", "reference", "static", ".git", ".venv", "__pycache__"} + +# Everything the browser voice client needs; never include secrets here. +VOICE_PUBLIC_CONFIG = { + "enabled": VOICE_AVAILABLE, + "wsPath": "/ws/voice", + "channelBanners": CONFIG["channel_banners"], + "channelIcons": CONFIG["channel_icons"], + "genericImages": CONFIG["generic_images"], + "roleIcons": CONFIG["role_icons"], +} + ALLOWED_STATIC_EXTENSIONS = { ".css", ".png", @@ -30,6 +44,72 @@ def safe_url(filename): +CLIENT_DETAIL_FIELDS = ("afk_text", "is_afk", "is_streaming", "stream_text") +# After a failed per-user clientinfo call, skip per-user calls for this long so a +# misbehaving server is not hammered on every refresh. +CLIENT_DETAILS_RETRY_SECONDS = 300 +_client_details_paused_until = 0.0 + + +class WebQueryError(Exception): + """The WebQuery answered, but with an error (HTTP or TeamSpeak status).""" + + def __init__(self, message, code=None): + super().__init__(message) + self.code = code + + +# TeamSpeak "invalid clientID": the user left between clientlist and clientinfo. +TS_ERROR_INVALID_CLIENT_ID = "512" + + +def webquery_get(url, headers): + res = requests.get(url, headers=headers, timeout=2) + res.raise_for_status() + payload = res.json() + status = payload.get("status") if isinstance(payload, dict) else None + if isinstance(status, dict) and str(status.get("code", 0)) != "0": + code = str(status.get("code")) + raise WebQueryError(f"{code}: {status.get('message', '')}", code=code) + return payload + + +def fetch_client_details(base_url, headers, client_list): + """Add AFK/streaming state per user via clientinfo. + + All or nothing: if any call fails, every user's details are dropped and the + plain channel/user list is used instead. Returns True on success. + """ + global _client_details_paused_until + try: + for client in client_list: + clid = client.get("clid") + if not clid or client.get("client_nickname") == "serveradmin": + continue + body = None + try: + body = webquery_get(f"{base_url}/clientinfo?clid={clid}", headers).get("body") + except WebQueryError as e: + if e.code != TS_ERROR_INVALID_CLIENT_ID: + raise + client_info = body[0] if isinstance(body, list) and body else {} + client["afk_text"] = client_info.get("client_away_message", "") + client["is_afk"] = client_info.get("client_away") == "1" + client["is_streaming"] = client_info.get("client_is_streaming") == "1" + client["stream_text"] = client_info.get("client_stream_text", "") + return True + except (requests.RequestException, ValueError, WebQueryError) as e: + print( + f"clientinfo failed ({e}); showing users without AFK/streaming details " + f"for the next {CLIENT_DETAILS_RETRY_SECONDS}s" + ) + _client_details_paused_until = time.time() + CLIENT_DETAILS_RETRY_SECONDS + for client in client_list: + for field in CLIENT_DETAIL_FIELDS: + client.pop(field, None) + return False + + def fetch_teamspeak_data(cfg): cache_file = Path(cfg["cache_file"]) if cache_file.exists() and (time.time() - cache_file.stat().st_mtime) < cfg["cache_time"]: @@ -42,43 +122,21 @@ def fetch_teamspeak_data(cfg): headers = {"x-api-key": cfg["api_key"]} try: - channels_res = requests.get(f"{base_url}/channellist", headers=headers, timeout=2) - clients_res = requests.get(f"{base_url}/clientlist?-groups", headers=headers, timeout=2) - channels_json = channels_res.json() - clients_json = clients_res.json() - - client_list = clients_json.get("body") or [] + channels_json = webquery_get(f"{base_url}/channellist", headers) + clients_json = webquery_get(f"{base_url}/clientlist?-groups", headers) + except (requests.RequestException, ValueError, WebQueryError) as e: + print(f"Failed to fetch channel/client list: {e}") + return {"channels": [], "clients": []} - afk_threshold = cfg.get("afk_status_max_clients") - fetch_afk_status = afk_threshold is None or len(client_list) <= afk_threshold + client_list = clients_json.get("body") or [] - for client in client_list: - clid = client.get("clid") - nickname = client.get("client_nickname") - if not fetch_afk_status: - continue - if clid and nickname != "serveradmin": - client_info_res = requests.get( - f"{base_url}/clientinfo?clid={clid}", - headers=headers, - timeout=2, - ) - body = client_info_res.json().get("body") - if isinstance(body, list): - client_info = body[0] if body else {} - else: - client_info = {} - client["afk_text"] = client_info.get("client_away_message", "") - client["is_afk"] = client_info.get("client_away") == "1" - client["is_streaming"] = client_info.get("client_is_streaming") == "1" - client["stream_text"] = client_info.get("client_stream_text", "") - - except requests.RequestException as e: - print(f"RequestException: failed to fetch client info: {e}") - return {"channels": [], "clients": []} - except ValueError as e: - print(f"ValueError: failed to parse client info: {e}") - return {"channels": [], "clients": []} + afk_threshold = cfg.get("afk_status_max_clients") + fetch_afk_status = ( + (afk_threshold is None or len(client_list) <= afk_threshold) + and time.time() >= _client_details_paused_until + ) + if fetch_afk_status: + fetch_client_details(base_url, headers, client_list) data = { "channels": channels_json.get("body") or [], @@ -163,6 +221,8 @@ def build_context(): "safe_url": safe_url, "total_real_clients": total_real_clients, "afk_count": afk_count, + "voice_available": VOICE_AVAILABLE, + "voice_public": VOICE_PUBLIC_CONFIG, } @@ -195,7 +255,7 @@ FRAGMENT_TEMPLATE = """ {% endif %} {% if not channel.is_spacer %} - <article class="channel"> + <article class="channel" data-cid="{{ cid }}"> <header class="channel-header" style="{{ bg_style }}"> <div class="channel-title-group"> {% if icon_img %} @@ -204,15 +264,20 @@ FRAGMENT_TEMPLATE = """ <h2>{{ c_name }}</h2> </div> {# Only show client count for non-spacer channels #} - <span class="channel-meta"> - {{ channel_clients|length }} - </span> + <div class="channel-actions"> + <span class="channel-meta"> + {{ channel_clients|length }} + </span> + {% if voice_available %} + <button type="button" class="channel-join" data-cid="{{ cid }}" data-name="{{ c_name }}" aria-label="Join {{ c_name }} from the browser">join</button> + {% endif %} + </div> </header> {# Only show client list for non-spacer channels #} <ul class="client-list"> {% for client in channel_clients %} - <li class="client"> + <li class="client" data-clid="{{ client.get('clid', '') }}"> {% set role_icon = namespace(path='') %} {% for group_id in (client.get('client_servergroups', '')|string).split(',') %} {% if not role_icon.path and group_id in config.role_icons %} @@ -270,11 +335,23 @@ PAGE_TEMPLATE = """ <main class="app-container"> {% if is_online %}<div class="progress-bar" id="refresh-bar"></div>{% endif %} + {% if voice_available %} + <section class="voice-topbar" id="voice-topbar" aria-label="Browser voice setup"> + <div class="voice-topbar-bar"> + <span class="voice-topbar-identity" id="voice-topbar-identity">[ not logged in ]</span> + <button type="button" class="voice-login-btn" id="voice-login-btn">browser connect</button> + </div> + <p class="voice-topbar-hint" id="voice-message" role="status" aria-live="polite" hidden></p> + </section> + {% endif %} + {% if config.ui.banner_url %} <img src="{{ safe_url(config.ui.banner_url) }}" alt="Header banner" class="banner"> {% endif %} <div class="content-wrapper"> + {% if voice_available %}<p class="voice-notice" id="voice-notice" role="status" aria-live="polite" hidden></p>{% endif %} + <section id="ajax-container" aria-live="polite"> {{ html_fragment|safe }} </section> @@ -300,14 +377,93 @@ PAGE_TEMPLATE = """ <a href="ts3server://{{ config.server.ts_domain }}" class="btn-primary">{{ config.buttons.join }}</a> <p class="help-text">{{ config.buttons.download_text }} <a href="{{ config.buttons.download_url }}" target="_blank" rel="noopener">{{ config.buttons.download_link }}</a></p> - <footer class="credits"> {{ config.footer.text }} <a href="{{ config.footer.author_url }}" target="_blank" rel="noopener">{{ config.footer.author }}</a> — Refresh scan: <span id="timer-text">{{ config.server.cache_time }}</span>s </footer> </div> </main> +{% if voice_available %} +<dialog class="voice-dialog" id="voice-dialog" aria-labelledby="voice-dialog-title"> + <form class="voice-dialog-form" id="voice-join-form" autocomplete="on"> + <header class="voice-dialog-header"> + <h2 id="voice-dialog-title">Browser connect</h2> + <span class="voice-dialog-target" id="voice-dialog-target">default channel</span> + </header> + <div class="voice-dialog-body"> + <label class="voice-field"> + <span class="voice-field-tag">[ nickname ]</span> + <input id="voice-nickname" name="nickname" minlength="3" maxlength="30" required autocomplete="nickname" spellcheck="false" placeholder="enter name..."> + </label> + <label class="voice-field"> + <span class="voice-field-tag">[ server password ]</span> + <input id="voice-password" name="password" type="password" required autocomplete="current-password" placeholder="enter password..."> + </label> + <p class="voice-dialog-message" id="voice-dialog-message" role="status" aria-live="polite" hidden></p> + </div> + <footer class="voice-dialog-actions"> + <button type="button" class="btn-voice" id="voice-cancel">cancel</button> + <button type="submit" class="btn-primary voice-submit" id="voice-submit">connect</button> + </footer> + </form> +</dialog> + +<section class="voice-bar" id="voice-bar" aria-label="Voice controls" hidden> + <div class="voice-meter" aria-hidden="true"> + <div class="voice-meter-fill" id="voice-meter"></div> + <div class="voice-meter-threshold" id="voice-threshold"></div> + </div> + <div class="voice-settings" id="voice-settings" hidden> + <div class="voice-setting"> + <span class="stat-label">Input mode</span> + <button type="button" class="btn-voice" id="voice-mode">voice activity</button> + </div> + <label class="voice-setting"> + <span class="stat-label">Mic sensitivity</span> + <input type="range" id="voice-sensitivity" min="0" max="100" value="45"> + </label> + <label class="voice-setting"> + <span class="stat-label">Volume</span> + <input type="range" id="voice-volume" min="0" max="150" value="100"> + </label> + </div> + <p class="voice-bar-message" id="voice-bar-message" role="status" aria-live="polite" hidden></p> + <div class="voice-bar-main"> + <div class="voice-bar-info"> + <span class="voice-live-badge"><span class="pulse-dot"></span>Live</span> + <span class="voice-where"><span id="voice-channel">-</span><span class="voice-as">as</span><span id="voice-self">-</span></span> + </div> + <div class="voice-buttons"> + <button type="button" class="btn-voice btn-voice-talk" id="voice-ptt" hidden>talk (V)</button> + <button type="button" class="btn-voice" id="voice-mute" aria-pressed="false">mute</button> + <button type="button" class="btn-voice" id="voice-deafen" aria-pressed="false">deafen</button> + <button type="button" class="btn-voice" id="voice-settings-toggle" aria-expanded="false" aria-controls="voice-settings">settings</button> + <button type="button" class="btn-voice btn-voice-danger" id="voice-leave">leave</button> + </div> + </div> +</section> +<script id="voice-config" type="application/json">{{ voice_public|tojson }}</script> +{% endif %} + <script> + // Reloads the server-rendered channel list. Skipped while the web voice + // client renders it live; voice.js calls it again after leaving. + window.refreshDashboard = function () { + const container = document.getElementById('ajax-container'); + if (!container || document.body.classList.contains('voice-live')) return Promise.resolve(); + container.style.opacity = '0.5'; + return fetch(window.location.href.split('?')[0] + '?ajax=1', { cache: 'no-store', headers: {'X-Requested-With': 'XMLHttpRequest'} }) + .then(r => { + if (!r.ok) throw new Error("HTTP error " + r.status); + return r.text(); + }) + .then(html => { + if (!document.body.classList.contains('voice-live')) container.innerHTML = html; + }) + .catch(err => console.error("Refresh failed:", err)) + .finally(() => { container.style.opacity = '1'; }); + }; + {% if is_online %} document.addEventListener("DOMContentLoaded", () => { let timeLeft = {{ config.server.cache_time|int }}; @@ -315,7 +471,6 @@ PAGE_TEMPLATE = """ const progressBar = document.getElementById('refresh-bar'); const timerText = document.getElementById('timer-text'); - const container = document.getElementById('ajax-container'); function resetProgressBar() { progressBar.style.transition = 'none'; @@ -328,36 +483,26 @@ PAGE_TEMPLATE = """ resetProgressBar(); setInterval(() => { + if (document.body.classList.contains('voice-live')) { + timerText.innerText = 'live'; + timeLeft = totalTime; + return; + } timeLeft--; timerText.innerText = timeLeft; if (timeLeft <= 0) { - container.style.opacity = '0.5'; - fetch(window.location.href.split('?')[0] + '?ajax=1', { cache: 'no-store', headers: {'X-Requested-With': 'XMLHttpRequest'} }) - .then(r => { - if (!r.ok) throw new Error("HTTP error " + r.status); - return r.text(); - }) - .then(html => { - container.innerHTML = html; - container.style.opacity = '1'; - - timeLeft = totalTime; - timerText.innerText = timeLeft; - - resetProgressBar(); - }) - .catch(err => { - console.error("Refresh failed:", err); - container.style.opacity = '1'; - timeLeft = totalTime; - resetProgressBar(); - }); + timeLeft = totalTime; + window.refreshDashboard().finally(() => { + timerText.innerText = timeLeft; + resetProgressBar(); + }); } }, 1000); }); {% endif %} </script> +{% if voice_available %}<script src="/static/voice.js" defer></script>{% endif %} </body> </html> """ @@ -384,6 +529,7 @@ def static_asset(filename): if ( path.is_file() and BASE_DIR in path.parents + and path.relative_to(BASE_DIR).parts[0] not in PRIVATE_DIRS and path.suffix.lower() in ALLOWED_STATIC_EXTENSIONS ): return send_from_directory(BASE_DIR, filename) @@ -391,4 +537,9 @@ def static_asset(filename): if __name__ == "__main__": - app.run(host="0.0.0.0", port=5000) + # run.py sets DASHBOARD_HOST/DASHBOARD_LISTEN_PORT (internal port when the + # voice gateway fronts the app). Run directly, the dashboard serves alone. + app.run( + host=os.environ.get("DASHBOARD_HOST", CONFIG["web"]["host"]), + port=int(os.environ.get("DASHBOARD_LISTEN_PORT", CONFIG["web"]["port"])), + ) @@ -1 +1 @@ -{"channels": [{"channel_name": "[Cspacer]INFORMATION", "channel_needed_subscribe_power": "0", "channel_order": "0", "cid": "13", "pid": "0", "total_clients": "0"}, {"channel_name": "[Cspacer]GAME SERVERS", "channel_needed_subscribe_power": "0", "channel_order": "13", "cid": "17", "pid": "0", "total_clients": "0"}, {"channel_name": "[Cspacer]\ud83d\udd07 AFK NPCs \ud83d\udd07", "channel_needed_subscribe_power": "0", "channel_order": "17", "cid": "4", "pid": "0", "total_clients": "1"}, {"channel_name": "General", "channel_needed_subscribe_power": "0", "channel_order": "4", "cid": "1", "pid": "0", "total_clients": "0"}, {"channel_name": "CRASHING OUT", "channel_needed_subscribe_power": "0", "channel_order": "1", "cid": "9", "pid": "0", "total_clients": "0"}, {"channel_name": "Wonhee HQ", "channel_needed_subscribe_power": "0", "channel_order": "9", "cid": "10", "pid": "0", "total_clients": "1"}], "clients": [{"cid": "4", "clid": "71", "client_channel_group_id": "8", "client_channel_group_inherited_channel_id": "4", "client_database_id": "1", "client_nickname": "serveradmin", "client_servergroups": "2", "client_type": "1"}, {"cid": "10", "clid": "1", "client_channel_group_id": "8", "client_channel_group_inherited_channel_id": "10", "client_database_id": "29", "client_nickname": "The Real Wonhee", "client_servergroups": "8", "client_type": "0"}]}
\ No newline at end of file +{"channels": [{"channel_name": "[Cspacer]INFORMATION", "channel_needed_subscribe_power": "0", "channel_order": "0", "cid": "13", "pid": "0", "total_clients": "0"}, {"channel_name": "[Cspacer]GAME SERVERS", "channel_needed_subscribe_power": "0", "channel_order": "13", "cid": "17", "pid": "0", "total_clients": "0"}, {"channel_name": "[Cspacer]\ud83d\udd07 AFK NPCs \ud83d\udd07", "channel_needed_subscribe_power": "0", "channel_order": "17", "cid": "4", "pid": "0", "total_clients": "1"}, {"channel_name": "General", "channel_needed_subscribe_power": "0", "channel_order": "4", "cid": "1", "pid": "0", "total_clients": "0"}, {"channel_name": "CRASHING OUT", "channel_needed_subscribe_power": "0", "channel_order": "1", "cid": "9", "pid": "0", "total_clients": "0"}, {"channel_name": "Wonhee HQ", "channel_needed_subscribe_power": "0", "channel_order": "9", "cid": "10", "pid": "0", "total_clients": "1"}], "clients": [{"cid": "4", "clid": "1433", "client_channel_group_id": "8", "client_channel_group_inherited_channel_id": "4", "client_database_id": "1", "client_nickname": "serveradmin", "client_servergroups": "2", "client_type": "1"}, {"cid": "10", "clid": "104", "client_channel_group_id": "5", "client_channel_group_inherited_channel_id": "10", "client_database_id": "29", "client_nickname": "The Real Wonhee", "client_servergroups": "8", "client_type": "0", "afk_text": "", "is_afk": false, "is_streaming": false, "stream_text": ""}]}
\ No newline at end of file @@ -1,78 +1,124 @@ -"""TeamSpeak Web Explorer configuration.""" +"""TeamSpeak Web Explorer configuration. +Settings live in ``config.toml`` (shared with the Node voice gateway in +``gateway/``) and secrets live in ``.env``. Environment variables override the +TOML values, using the same names the gateway reads (see ``ENV_OVERRIDES``). +""" + +import os +import tomllib from pathlib import Path + from dotenv import load_dotenv -import os -load_dotenv() BASE_DIR = Path(__file__).resolve().parent +CONFIG_FILE = Path(os.environ.get("TS6_CONFIG_FILE") or BASE_DIR / "config.toml") + +load_dotenv(BASE_DIR / ".env") + +# (section, key, ENV_VAR, type). Keep in sync with gateway/src/config.js. +ENV_OVERRIDES = [ + ("web", "host", "WEB_HOST", str), + ("web", "port", "WEB_PORT", int), + ("web", "dashboard_port", "DASHBOARD_PORT", int), + ("server", "host", "TS_HOST", str), + ("server", "webquery_port", "TS_WEBQUERY_PORT", int), + ("server", "server_id", "TS_SERVER_ID", str), + ("server", "voice_port", "TS_VOICE_PORT", int), + ("server", "voice_port", "TS_PORT", int), # legacy name from the old .env.template + ("server", "ts_domain", "TS_DOMAIN", str), + ("voice", "enabled", "VOICE_ENABLED", bool), + ("voice", "trust_proxy", "VOICE_TRUST_PROXY", bool), +] + + +def _env_value(raw, kind): + if kind is bool: + return raw.strip().lower() in {"1", "true", "yes", "on"} + return kind(raw.strip()) + + +def _load_toml(): + try: + with CONFIG_FILE.open("rb") as fh: + return tomllib.load(fh) + except FileNotFoundError: + print(f"[config] {CONFIG_FILE} not found, using defaults") + return {} + + +def _build_config(): + raw = _load_toml() + sections = { + name: dict(raw.get(name) or {}) + for name in ( + "web", "server", "voice", "ui", "buttons", "external_links", "footer", + "channel_banners", "channel_icons", "generic_images", "role_icons", + ) + } + + applied = set() + for section, key, env_name, kind in ENV_OVERRIDES: + value = os.environ.get(env_name) + if value is None or value.strip() == "" or (section, key) in applied: + continue + try: + sections[section][key] = _env_value(value, kind) + applied.add((section, key)) + except ValueError: + print(f"[config] ignoring invalid {env_name}={value!r}") + + web = sections["web"] + web.setdefault("host", "0.0.0.0") + web.setdefault("port", 5000) + web.setdefault("dashboard_port", 5001) + + server = sections["server"] + server.setdefault("host", "") + server["port"] = server.get("webquery_port", 8443) # name used by the WebQuery fetcher + server["server_id"] = str(server.get("server_id", 1)) + server.setdefault("voice_port", 9987) + server["ts_domain"] = server.get("ts_domain") or server["host"] + server.setdefault("cache_time", 30) + server["cache_file"] = BASE_DIR / server.get("cache_file", "cache.json") + server.setdefault("afk_status_max_clients", 3) + server["api_key"] = os.environ.get("TS_API_KEY", "") -CONFIG = { - # Server connection settings - "server": { - "host": os.environ.get("TS_HOST", ""), # Server IP or hostname, e.g. 192.168.1.50 - "port": os.environ.get("TS_WEBQUERY_PORT", "8443"), # HTTP WebQuery port (TS6) - "api_key": os.environ.get("TS_API_KEY", ""), - "server_id": os.environ.get("TS_SERVER_ID", "1"), - "ts_domain": os.environ.get("TS_HOST", ""), # Used by the connect button - "cache_time": 30, # Refresh interval in seconds (anti-spam) - "cache_file": BASE_DIR / "cache.json", - "afk_status_max_clients": 3, # Skip per-client clientinfo (AFK/streaming) calls when client count exceeds this (due to potential rate limits) - }, + voice = sections["voice"] + voice.setdefault("enabled", True) - # Main design and copy - "ui": { - "title": "", - "subtitle": "", - "background_color": "#07111f", # Main background color - "panel_color": "#101b2d", # Main panel and channel block color - "accent_color": "#2589ff", # Buttons, highlights, and refresh timer - "text_color": "#e8f2ff", - "banner_url": "banner.jpg", # Leave empty ('') to hide the header banner - "enable_stars": True, # Set to True to enable a subtle animated space background - }, + ui = sections["ui"] + for key, default in { + "title": "", "subtitle": "", "background_color": "#07111f", + "panel_color": "#101b2d", "accent_color": "#2589ff", "text_color": "#e8f2ff", + "banner_url": "", "enable_stars": False, + }.items(): + ui.setdefault(key, default) - # Interface buttons and links - "buttons": { - "join": "Connect to TeamSpeak", + buttons = sections["buttons"] + for key, default in { + "join": "Connect in TeamSpeak", "web_join": "Join from browser", "download_text": "Teamspeak is not installed yet?", "download_link": "Download Teamspeak", - "download_url": "https://www.teamspeak.com/en/downloads/", # Official download link - }, + "download_url": "https://www.teamspeak.com/en/downloads/", + }.items(): + buttons.setdefault(key, default) - # Useful links. Add or remove as many rows as you want. - "external_links": { - }, + footer = sections["footer"] + for key in ("text", "author", "author_url"): + footer.setdefault(key, "") - # Footer - "footer": { - "text": "another moekyun service", - "author": "", - "author_url": "", - }, + generic = sections["generic_images"] + generic.setdefault("background", "") + generic.setdefault("icon", "") - # Channel customization. Exact channel name => image filename. - "channel_banners": { - "INFORMATION": "info.png", - "GAME SERVERS": "game.jpeg", - "General": "general.jpg", - "CRASHING OUT": "crash.png", - "Wonhee HQ": "illit.jpg", - }, + sections["role_icons"] = {str(k): v for k, v in sections["role_icons"].items()} + return sections - "channel_icons": { - # "Lobby": "lobby_icon.png", - # "General": "general_icon.png", - }, - "generic_images": { - "background": "", # Default image applied to unknown channels - "icon": "", # Default icon applied to unknown channels - }, +CONFIG = _build_config() - # Server groups / roles: 'GROUP_ID' => 'Image' - "role_icons": { - # "6": "admin.png", - # "9": "vip.png", - }, -} +# Web voice is only offered when enabled AND a server password is configured: +# the gateway refuses every join without one, so nobody can join without it. +# The password itself is intentionally kept out of CONFIG (which templates see). +VOICE_AVAILABLE = bool(CONFIG["voice"].get("enabled")) and bool(os.environ.get("TS_SERVER_PASSWORD")) diff --git a/config.toml b/config.toml new file mode 100644 index 0000000..e314aed --- /dev/null +++ b/config.toml @@ -0,0 +1,93 @@ +# --------------------------------------------------------------------------- +# Shared configuration for the dashboard (Flask, config.py) and the browser +# voice gateway (Node, gateway/src/config.js). Both read this one file. +# +# Secrets do NOT belong here: put them in `.env` (see .env.template). +# Any environment variable listed next to a setting overrides the value here. +# --------------------------------------------------------------------------- + +# Where the app listens. When voice is enabled, the voice gateway owns the +# public port and forwards every non-voice request to the dashboard, which +# then listens on 127.0.0.1:dashboard_port only. One port to expose/proxy. +[web] +host = "0.0.0.0" # WEB_HOST +port = 5000 # WEB_PORT - public port +dashboard_port = 5001 # DASHBOARD_PORT - internal Flask port (voice enabled only) + +# TeamSpeak server connection settings +[server] +host = "" # TS_HOST - server IP or hostname, e.g. 192.168.1.50 +webquery_port = 8443 # TS_WEBQUERY_PORT - HTTP WebQuery port (TS6) +server_id = 1 # TS_SERVER_ID - virtual server id +voice_port = 9987 # TS_VOICE_PORT - UDP voice port the web voice gateway connects to +ts_domain = "" # TS_DOMAIN - address used by the "Connect" button; empty = host +cache_time = 30 # Refresh interval in seconds (anti-spam) +cache_file = "cache.json" # Relative to the repository root +afk_status_max_clients = 3 # Skip per-client clientinfo (AFK/streaming) calls when client count exceeds this (due to potential rate limits) +# TS_API_KEY (.env) - WebQuery API key +# TS_SERVER_PASSWORD (.env) - server password; required for web voice + +# Browser voice (join the server from the dashboard). +# Voice is only offered when `enabled = true` AND TS_SERVER_PASSWORD is set: +# visitors must type the server password, which the gateway checks before +# it opens any TeamSpeak connection (the TeamSpeak server checks it again). +[voice] +enabled = true # VOICE_ENABLED +max_sessions = 8 # Concurrent browser voice sessions (keep low on small SBCs) +max_sessions_per_ip = 2 # Concurrent sessions from one IP address +max_failed_logins = 5 # Wrong passwords per IP before a temporary lockout +lockout_minutes = 10 # Lockout duration after too many wrong passwords +nickname_prefix = "" # Prepended to web users' nicknames, e.g. "[web] " +default_channel = "" # Channel name (or path "Parent/Child") to join; empty = server default +identity_security_level = 8 # TeamSpeak identity security level for new web users +trust_proxy = false # Use X-Forwarded-For for per-IP limits (only behind your own reverse proxy) +allowed_origins = [] # Extra allowed Origins for /ws/voice, e.g. ["https://ts.example.com"]; same-host is always allowed + +# Main design and copy +[ui] +title = "" +subtitle = "" +background_color = "#07111f" # Main background color +panel_color = "#101b2d" # Main panel and channel block color +accent_color = "#2589ff" # Buttons, highlights, and refresh timer +text_color = "#e8f2ff" +banner_url = "banner.jpg" # Leave empty ("") to hide the header banner +enable_stars = true # Set to true to enable a subtle animated space background + +# Interface buttons and links +[buttons] +join = "Connect in TeamSpeak" +web_join = "Join from browser" +download_text = "Teamspeak is not installed yet?" +download_link = "Download Teamspeak" +download_url = "https://www.teamspeak.com/en/downloads/" # Official download link + +# Useful links. Add or remove as many rows as you want: "Name" = "https://..." +[external_links] + +# Footer +[footer] +text = "another moekyun service" +author = "" +author_url = "" + +# Channel customization. Exact channel name => image filename. +[channel_banners] +"INFORMATION" = "info.png" +"GAME SERVERS" = "game.jpeg" +"General" = "general.jpg" +"CRASHING OUT" = "crash.png" +"Wonhee HQ" = "illit.jpg" + +[channel_icons] +# "Lobby" = "lobby_icon.png" +# "General" = "general_icon.png" + +[generic_images] +background = "" # Default image applied to unknown channels +icon = "" # Default icon applied to unknown channels + +# Server groups / roles: "GROUP_ID" = "Image" +[role_icons] +# "6" = "admin.png" +# "9" = "vip.png" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ecc2380 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,25 @@ +services: + ts6-webview: + build: + context: . + dockerfile: Dockerfile + container_name: ts6-webview + restart: unless-stopped + env_file: + - .env + environment: + WEB_HOST: "0.0.0.0" + WEB_PORT: "5000" + # Set to true when running behind Nginx / Caddy on the same host so + # per-IP rate limits use X-Forwarded-For instead of the proxy IP. + VOICE_TRUST_PROXY: "true" + ports: + - "127.0.0.1:5000:5000" + volumes: + - ./config.toml:/app/config.toml:ro + - ./static:/app/static:ro + # Optional resource cap for small arm64 SBCs + deploy: + resources: + limits: + memory: 256M diff --git a/gateway/package-lock.json b/gateway/package-lock.json new file mode 100644 index 0000000..baf39c0 --- /dev/null +++ b/gateway/package-lock.json @@ -0,0 +1,107 @@ +{ + "name": "ts6-voice-gateway", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ts6-voice-gateway", + "version": "0.1.0", + "dependencies": { + "@honeybbq/teamspeak": "1.0.0-beta.19", + "opusscript": "^0.1.1", + "smol-toml": "^1.3.1", + "ws": "^8.20.0" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@honeybbq/teamspeak": { + "version": "1.0.0-beta.19", + "resolved": "https://registry.npmjs.org/@honeybbq/teamspeak/-/teamspeak-1.0.0-beta.19.tgz", + "integrity": "sha512-5srWgwbcMx1j4JpwYonxKkOYla8hx7ojX+dYOC0hiv5NCjozMRsy8aIYzwSpzHr2y3q/aKljnPyM426DTRekrA==", + "license": "MIT", + "dependencies": { + "@noble/curves": "^2.0.1" + }, + "engines": { + "node": ">=22.12" + }, + "peerDependencies": { + "ssh2": "^1.17.0" + }, + "peerDependenciesMeta": { + "ssh2": { + "optional": true + } + } + }, + "node_modules/@noble/curves": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.4.0.tgz", + "integrity": "sha512-P4/62zrgfH33CneE3Dn4WhJVA22YUU0eR51wKIan4NVRvwsA0YnPTwWGpNbpuacSujmSFLvyzpyuR30+fbq2Ew==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.4.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz", + "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/opusscript": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/opusscript/-/opusscript-0.1.1.tgz", + "integrity": "sha512-mL0fZZOUnXdZ78woRXp18lApwpp0lF5tozJOD1Wut0dgrA9WuQTgSels/CSmFleaAZrJi/nci5KOVtbuxeWoQA==", + "license": "MIT" + }, + "node_modules/smol-toml": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.9.1.tgz", + "integrity": "sha512-j7WyPdf0CJoutMzMoPLF/bVXHtKZIT9Rb4eGeEbWw8X2t5oirjPgpL+OpaENZrILYaXqYwnVH+qgUwVbl+pUJQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, + "node_modules/ws": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/gateway/package.json b/gateway/package.json new file mode 100644 index 0000000..8c8dba4 --- /dev/null +++ b/gateway/package.json @@ -0,0 +1,21 @@ +{ + "name": "ts6-voice-gateway", + "version": "0.1.0", + "private": true, + "description": "Browser-to-TeamSpeak voice gateway for the TeamSpeak 6 web dashboard", + "type": "module", + "main": "src/index.js", + "engines": { + "node": ">=22.12.0" + }, + "scripts": { + "start": "node src/index.js", + "test": "node --test \"test/*.test.js\"" + }, + "dependencies": { + "@honeybbq/teamspeak": "1.0.0-beta.19", + "opusscript": "^0.1.1", + "smol-toml": "^1.3.1", + "ws": "^8.20.0" + } +} diff --git a/gateway/src/auth.js b/gateway/src/auth.js new file mode 100644 index 0000000..288de90 --- /dev/null +++ b/gateway/src/auth.js @@ -0,0 +1,83 @@ +// Access control for browser voice: server-password check and brute-force limits. +import { createHash, timingSafeEqual } from "node:crypto"; + +const digest = (value) => createHash("sha256").update(String(value), "utf8").digest(); + +/** + * Constant-time comparison of the typed password against the configured one. + * An unset/empty configured password never matches: voice requires a password. + */ +export function passwordMatches(input, expected) { + if (typeof input !== "string" || typeof expected !== "string" || expected.length === 0) return false; + if (input.length > 512) return false; + return timingSafeEqual(digest(input), digest(expected)); +} + +/** Per-IP failed-login tracking with a temporary lockout. */ +export class LoginLimiter { + constructor({ maxFailures = 5, lockoutMs = 10 * 60_000, maxTracked = 4096 } = {}, clock = Date.now) { + this.maxFailures = maxFailures; + this.lockoutMs = lockoutMs; + this.maxTracked = maxTracked; + this.clock = clock; + this.peers = new Map(); // ip -> { failures, firstAt, lockedUntil } + } + + /** Milliseconds the peer is still locked out for (0 = allowed). */ + lockedFor(ip) { + const entry = this.peers.get(ip); + if (!entry) return 0; + const now = this.clock(); + if (entry.lockedUntil > now) return entry.lockedUntil - now; + if (now - entry.firstAt > this.lockoutMs) this.peers.delete(ip); + return 0; + } + + recordFailure(ip) { + const now = this.clock(); + let entry = this.peers.get(ip); + if (!entry || now - entry.firstAt > this.lockoutMs) { + this.prune(now); + entry = { failures: 0, firstAt: now, lockedUntil: 0 }; + this.peers.set(ip, entry); + } + entry.failures += 1; + if (entry.failures >= this.maxFailures) entry.lockedUntil = now + this.lockoutMs; + } + + recordSuccess(ip) { + this.peers.delete(ip); + } + + prune(now) { + for (const [ip, entry] of this.peers) { + if (entry.lockedUntil <= now && now - entry.firstAt > this.lockoutMs) this.peers.delete(ip); + } + while (this.peers.size >= this.maxTracked) { + const oldest = this.peers.keys().next().value; + if (oldest === undefined) break; + this.peers.delete(oldest); + } + } +} + +/** Same-host Origin check (plus configured extra origins) for the voice WebSocket. */ +export function originAllowed(origin, host, allowedOrigins = []) { + if (!origin) return false; // browsers always send Origin on WebSocket upgrades + const normalized = origin.replace(/\/+$/, "").toLowerCase(); + if (allowedOrigins.includes("*") || allowedOrigins.includes(normalized)) return true; + try { + return new URL(normalized).host === String(host ?? "").toLowerCase(); + } catch { + return false; + } +} + +/** Trim, drop control characters and enforce TeamSpeak's 3..30 character nickname rule. */ +export function sanitizeNickname(input, prefix = "") { + if (typeof input !== "string") return null; + const cleaned = input.replace(/[\u0000-\u001f\u007f]/g, "").replace(/\s+/g, " ").trim(); + if (!cleaned) return null; + const full = `${prefix}${cleaned}`.slice(0, 30).trim(); + return [...full].length >= 3 ? full : null; +} diff --git a/gateway/src/config.js b/gateway/src/config.js new file mode 100644 index 0000000..33cad84 --- /dev/null +++ b/gateway/src/config.js @@ -0,0 +1,97 @@ +// Loads the same config.toml + .env the Flask dashboard uses (see config.py). +import { existsSync, readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { parse as parseToml } from "smol-toml"; + +export const ROOT_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..", ".."); + +// [section, key, ENV_VAR, type]. Keep in sync with ENV_OVERRIDES in config.py. +const ENV_OVERRIDES = [ + ["web", "host", "WEB_HOST", "string"], + ["web", "port", "WEB_PORT", "int"], + ["web", "dashboard_port", "DASHBOARD_PORT", "int"], + ["server", "host", "TS_HOST", "string"], + ["server", "webquery_port", "TS_WEBQUERY_PORT", "int"], + ["server", "server_id", "TS_SERVER_ID", "string"], + ["server", "voice_port", "TS_VOICE_PORT", "int"], + ["server", "voice_port", "TS_PORT", "int"], // legacy name from the old .env.template + ["server", "ts_domain", "TS_DOMAIN", "string"], + ["voice", "enabled", "VOICE_ENABLED", "bool"], + ["voice", "trust_proxy", "VOICE_TRUST_PROXY", "bool"], +]; + +function envValue(raw, kind) { + const value = raw.trim(); + if (kind === "bool") return ["1", "true", "yes", "on"].includes(value.toLowerCase()); + if (kind === "int") { + if (!/^\d+$/.test(value)) throw new Error("not an integer"); + return Number(value); + } + return value; +} + +function toInt(value, fallback, min = 0, max = Number.MAX_SAFE_INTEGER) { + const n = Number(value); + return Number.isInteger(n) && n >= min && n <= max ? n : fallback; +} + +export function loadConfig(env = process.env) { + const envFile = resolve(ROOT_DIR, ".env"); + // Like python-dotenv's load_dotenv(): existing variables are not overridden. + if (env === process.env && existsSync(envFile)) process.loadEnvFile(envFile); + + const configFile = env.TS6_CONFIG_FILE || resolve(ROOT_DIR, "config.toml"); + let raw = {}; + if (existsSync(configFile)) raw = parseToml(readFileSync(configFile, "utf8")); + else console.warn(`[config] ${configFile} not found, using defaults`); + + const section = (name) => ({ ...(raw[name] ?? {}) }); + const web = section("web"); + const server = section("server"); + const voice = section("voice"); + + const sections = { web, server, voice }; + const applied = new Set(); + for (const [name, key, envName, kind] of ENV_OVERRIDES) { + const value = env[envName]; + const id = `${name}.${key}`; + if (value === undefined || value.trim() === "" || applied.has(id)) continue; + try { + sections[name][key] = envValue(value, kind); + applied.add(id); + } catch { + console.warn(`[config] ignoring invalid ${envName}=${JSON.stringify(value)}`); + } + } + + const allowedOrigins = Array.isArray(voice.allowed_origins) + ? voice.allowed_origins.filter((o) => typeof o === "string" && o).map((o) => o.replace(/\/+$/, "").toLowerCase()) + : []; + + return { + web: { + host: String(web.host ?? "0.0.0.0"), + port: toInt(web.port, 5000, 1, 65535), + dashboardPort: toInt(web.dashboard_port, 5001, 1, 65535), + }, + server: { + host: String(server.host ?? "").trim(), + voicePort: toInt(server.voice_port, 9987, 1, 65535), + // Secret: only ever read from the environment / .env. + password: env.TS_SERVER_PASSWORD ?? "", + }, + voice: { + enabled: voice.enabled !== false, + maxSessions: toInt(voice.max_sessions, 8, 1, 1000), + maxSessionsPerIp: toInt(voice.max_sessions_per_ip, 2, 1, 1000), + maxFailedLogins: toInt(voice.max_failed_logins, 5, 1, 1000), + lockoutMs: toInt(voice.lockout_minutes, 10, 1, 24 * 60) * 60_000, + nicknamePrefix: String(voice.nickname_prefix ?? ""), + defaultChannel: String(voice.default_channel ?? ""), + identitySecurityLevel: toInt(voice.identity_security_level, 8, 0, 40), + trustProxy: voice.trust_proxy === true, + allowedOrigins, + }, + }; +} diff --git a/gateway/src/index.js b/gateway/src/index.js new file mode 100644 index 0000000..28beec5 --- /dev/null +++ b/gateway/src/index.js @@ -0,0 +1,39 @@ +import { loadConfig } from "./config.js"; +import { createGatewayServer } from "./server.js"; +import { opusBackendName } from "./opus.js"; + +const config = loadConfig(); +const log = console; + +const { server, shutdown, voiceAvailable } = createGatewayServer(config, log); + +server.listen(config.web.port, config.web.host, () => { + log.info(`[gateway] listening on http://${config.web.host}:${config.web.port} -> dashboard 127.0.0.1:${config.web.dashboardPort}`); + if (voiceAvailable()) { + log.info(`[gateway] web voice enabled for ${config.server.host}:${config.server.voicePort} (opus: ${opusBackendName()})`); + } else if (!config.server.password) { + log.warn("[gateway] web voice disabled: set TS_SERVER_PASSWORD in .env (visitors must know it to join)"); + } else if (!config.server.host) { + log.warn("[gateway] web voice disabled: set TS_HOST"); + } else { + log.warn("[gateway] web voice disabled in config.toml ([voice] enabled = false)"); + } +}); + +server.on("error", (error) => { + log.error(`[gateway] ${error.message}`); + process.exit(1); +}); + +let stopping = false; +async function stop(signal) { + if (stopping) return; + stopping = true; + log.info(`[gateway] ${signal} received, disconnecting voice sessions`); + const force = setTimeout(() => process.exit(0), 3000); + force.unref(); + await shutdown(); + process.exit(0); +} +process.on("SIGINT", () => void stop("SIGINT")); +process.on("SIGTERM", () => void stop("SIGTERM")); diff --git a/gateway/src/opus.js b/gateway/src/opus.js new file mode 100644 index 0000000..b1805d6 --- /dev/null +++ b/gateway/src/opus.js @@ -0,0 +1,59 @@ +// Opus codec wrapper. Uses the native @discordjs/opus addon when it happens to +// be installed (faster), otherwise opusscript (WASM): no compiler needed, so it +// installs cleanly on arm64 SBCs. +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); + +export const SAMPLE_RATE = 48_000; +export const FRAME_SAMPLES = 960; // 20 ms @ 48 kHz +export const FRAME_BYTES = FRAME_SAMPLES * 2; // mono Int16 + +let backend = null; + +function loadBackend() { + if (backend) return backend; + try { + const { OpusEncoder } = require("@discordjs/opus"); + backend = { + name: "@discordjs/opus (native)", + create(channels) { + const codec = new OpusEncoder(SAMPLE_RATE, channels); + return { + encode: (pcm) => codec.encode(pcm), + decode: (data) => codec.decode(data), + setBitrate: (bps) => codec.setBitrate(bps), + dispose() {}, + }; + }, + }; + } catch { + const OpusScript = require("opusscript"); + backend = { + name: "opusscript (wasm)", + create(channels) { + const codec = new OpusScript(SAMPLE_RATE, channels, OpusScript.Application.VOIP); + return { + encode: (pcm) => Buffer.from(codec.encode(pcm, pcm.length / (2 * channels))), + decode: (data) => Buffer.from(codec.decode(data)), + setBitrate: (bps) => codec.setBitrate(bps), + dispose: () => codec.delete(), + }; + }, + }; + } + return backend; +} + +export function opusBackendName() { + return loadBackend().name; +} + +/** Mono 48 kHz Opus codec (encoder + decoder state). */ +export function createOpusCodec({ bitrate } = {}) { + const codec = loadBackend().create(1); + if (bitrate) { + try { codec.setBitrate(bitrate); } catch { /* keep the library default */ } + } + return codec; +} diff --git a/gateway/src/server.js b/gateway/src/server.js new file mode 100644 index 0000000..b4e716e --- /dev/null +++ b/gateway/src/server.js @@ -0,0 +1,128 @@ +// Public HTTP front: serves /ws/voice itself and reverse-proxies everything +// else to the Flask dashboard, so the whole app is one origin on one port. +import http from "node:http"; +import { WebSocketServer } from "ws"; +import { LoginLimiter, originAllowed } from "./auth.js"; +import { VoiceSession } from "./voice-session.js"; + +const VOICE_PATH = "/ws/voice"; +const STATUS_PATH = "/voice-gateway/status"; +const HEARTBEAT_MS = 30_000; +const HOP_BY_HOP = ["connection", "keep-alive", "proxy-connection", "proxy-authenticate", "proxy-authorization", "te", "trailer", "upgrade"]; + +function clientIp(req, trustProxy) { + if (trustProxy) { + const forwarded = String(req.headers["x-forwarded-for"] ?? "").split(",")[0].trim(); + if (forwarded) return forwarded; + } + return (req.socket.remoteAddress ?? "unknown").replace(/^::ffff:/, ""); +} + +function rejectUpgrade(socket, status, text) { + socket.end(`HTTP/1.1 ${status} ${text}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`); +} + +export function createGatewayServer(config, log = console) { + const limiter = new LoginLimiter({ maxFailures: config.voice.maxFailedLogins, lockoutMs: config.voice.lockoutMs }); + const sessions = new Set(); + const perIp = new Map(); + const upstreamAgent = new http.Agent({ keepAlive: true, maxSockets: 64 }); + const voiceAvailable = () => config.voice.enabled && Boolean(config.server.password) && Boolean(config.server.host); + + function proxyToDashboard(req, res) { + const headers = { ...req.headers }; + for (const name of HOP_BY_HOP) delete headers[name]; + const ip = clientIp(req, false); + headers["x-forwarded-for"] = config.voice.trustProxy && req.headers["x-forwarded-for"] + ? `${req.headers["x-forwarded-for"]}, ${ip}` : ip; + headers["x-forwarded-host"] ??= req.headers.host ?? ""; + headers["x-forwarded-proto"] = config.voice.trustProxy && req.headers["x-forwarded-proto"] + ? req.headers["x-forwarded-proto"] : "http"; + + const upstream = http.request({ + host: "127.0.0.1", + port: config.web.dashboardPort, + method: req.method, + path: req.url, + headers, + agent: upstreamAgent, + }, (upstreamRes) => { + const responseHeaders = { ...upstreamRes.headers }; + for (const name of HOP_BY_HOP) delete responseHeaders[name]; + res.writeHead(upstreamRes.statusCode ?? 502, upstreamRes.statusMessage, responseHeaders); + upstreamRes.pipe(res); + }); + upstream.setTimeout(30_000, () => upstream.destroy(new Error("upstream timeout"))); + upstream.on("error", () => { + if (!res.headersSent) res.writeHead(502, { "content-type": "text/plain; charset=utf-8" }); + res.end("Dashboard is unavailable (is it still starting?)"); + }); + req.pipe(upstream); + } + + const server = http.createServer((req, res) => { + if (req.url === STATUS_PATH) { + res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" }); + res.end(JSON.stringify({ available: voiceAvailable(), sessions: sessions.size, maxSessions: config.voice.maxSessions })); + return; + } + proxyToDashboard(req, res); + }); + + const wss = new WebSocketServer({ noServer: true, maxPayload: 16 * 1024, perMessageDeflate: false }); + + server.on("upgrade", (req, socket, head) => { + const pathname = (req.url ?? "").split("?")[0]; + if (pathname !== VOICE_PATH) return rejectUpgrade(socket, 404, "Not Found"); + if (!originAllowed(req.headers.origin, req.headers["x-forwarded-host"] && config.voice.trustProxy + ? req.headers["x-forwarded-host"] : req.headers.host, config.voice.allowedOrigins)) { + log.warn(`[voice] rejected origin ${req.headers.origin ?? "(none)"}`); + return rejectUpgrade(socket, 403, "Forbidden"); + } + if (!voiceAvailable()) return rejectUpgrade(socket, 503, "Service Unavailable"); + const ip = clientIp(req, config.voice.trustProxy); + if (sessions.size >= config.voice.maxSessions) return rejectUpgrade(socket, 503, "Service Unavailable"); + if ((perIp.get(ip) ?? 0) >= config.voice.maxSessionsPerIp) return rejectUpgrade(socket, 429, "Too Many Requests"); + + wss.handleUpgrade(req, socket, head, (ws) => { + perIp.set(ip, (perIp.get(ip) ?? 0) + 1); + ws.isAlive = true; + ws.on("pong", () => { ws.isAlive = true; }); + let released = false; + const session = new VoiceSession(ws, { + ip, + config, + limiter, + log, + onClose() { + if (released) return; + released = true; + sessions.delete(session); + const left = (perIp.get(ip) ?? 1) - 1; + if (left > 0) perIp.set(ip, left); else perIp.delete(ip); + }, + }); + sessions.add(session); + }); + }); + + const heartbeat = setInterval(() => { + for (const ws of wss.clients) { + if (!ws.isAlive) { ws.terminate(); continue; } + ws.isAlive = false; + try { ws.ping(); } catch { /* closing */ } + } + }, HEARTBEAT_MS); + heartbeat.unref(); + + async function shutdown() { + clearInterval(heartbeat); + for (const session of [...sessions]) session.close(); + await new Promise((resolve) => setTimeout(resolve, 300)); // let TeamSpeak goodbyes go out + wss.close(); + server.close(); + upstreamAgent.destroy(); + } + + return { server, sessions, shutdown, voiceAvailable }; +} diff --git a/gateway/src/voice-session.js b/gateway/src/voice-session.js new file mode 100644 index 0000000..039df7b --- /dev/null +++ b/gateway/src/voice-session.js @@ -0,0 +1,500 @@ +// One browser <-> TeamSpeak voice session. +// +// Wire protocol on /ws/voice (no WebRTC; everything rides on this WebSocket): +// browser -> gateway JSON {type:"join", nickname, password, channelId?, channelPassword?, identity?, serverDecode?} +// JSON {type:"move", channelId, password?} | {type:"mute", muted} | {type:"deafen", deafened} +// {type:"chat", scope?:"channel"|"server"|"private", message, targetId?} +// {type:"whisper", targetIds:number[], active:boolean} +// {type:"leave"} +// binary 1920 bytes = 20 ms of 48 kHz mono Int16LE PCM from the microphone +// gateway -> browser JSON {type:"joined"|"state"|"chatMessage"|"whisperTargets"|"error"|"disconnected", ...} +// binary [kind u8][clientId u16BE][payload] +// kind 4/5 = TeamSpeak Opus (voice/music) frame, decoded by WebCodecs in the browser +// kind 0xF0 = 48 kHz mono Int16LE PCM (gateway-side decode fallback) +// an empty payload marks the end of that speaker's transmission +import { WebSocket } from "ws"; +import { + asChannelID, + asClientID, + moveClient, + sendChannelMessage, + sendPrivateMessage, + sendServerMessage, + updateClient, +} from "@honeybbq/teamspeak"; +import { Client, generateIdentity, parseIdentity, subscribeAllChannels } from "@honeybbq/teamspeak/client"; +import { createOpusCodec, FRAME_BYTES } from "./opus.js"; +import { passwordMatches, sanitizeNickname } from "./auth.js"; + +const CONNECT_TIMEOUT_MS = 15_000; +const STATE_THROTTLE_MS = 150; +const END_OF_SPEECH_MS = 100; +const OPUS_BITRATE = 40_000; +const MAX_BUFFERED_OPUS_BYTES = 8 * 1024; +const MAX_BUFFERED_PCM_BYTES = 32 * 1024; +const MAX_SPEAKER_DECODERS = 16; +const MAX_CHAT_LENGTH = 1024; +const MAX_WHISPER_TARGETS = 32; +const KIND_PCM = 0xf0; +// Token bucket for microphone frames: 50 frames/s nominal, small burst allowance. +const FRAME_RATE = 55; +const FRAME_BURST = 25; + +const ERROR_TEXT = { + 1028: ["BAD_PASSWORD", "The TeamSpeak server rejected the password."], + 781: ["CHANNEL_PASSWORD", "Wrong channel password."], + 513: ["NICKNAME_IN_USE", "That nickname is already in use."], + 514: ["NICKNAME_INVALID", "That nickname is not allowed."], + 521: ["TOO_MANY_CLONES", "Too many connections from this gateway."], + 524: ["FLOODING", "Slow down, the server thinks you are flooding."], + 777: ["CHANNEL_FULL", "That channel is full."], + 770: ["ALREADY_IN_CHANNEL", "You are already in that channel."], + 2568: ["NO_PERMISSION", "You do not have permission to do that."], + 3329: ["BANNED", "You are banned from this server."], +}; + +function describeError(error) { + for (let e = error, depth = 0; e && depth < 5; e = e.cause, depth++) { + if (typeof e.code === "number" && ERROR_TEXT[e.code]) { + const [code, message] = ERROR_TEXT[e.code]; + return { code, message }; + } + } + const message = error instanceof Error ? error.message : String(error); + if (/abort|timeout|timed out/i.test(message)) return { code: "TIMEOUT", message: "Timed out reaching the TeamSpeak server." }; + return { code: "TEAMSPEAK_ERROR", message: message.slice(0, 200) || "TeamSpeak error" }; +} + +function snapshot(client) { + const view = client.view; + return { + serverName: view.serverName ?? "", + self: { id: Number(client.clientID), channelId: String(client.channelID) }, + channels: [...view.channels.values()].map((c) => ({ + id: String(c.id), + parentId: String(c.parentID), + order: String(c.order), + name: c.name, + hasPassword: Boolean(c.hasPassword), + })), + clients: [...view.clients.values()].map((e) => ({ + id: Number(e.id), + nickname: e.nickname, + channelId: String(e.channelID), + type: e.type, + away: Boolean(e.isAway), + awayMessage: e.awayMessage ?? "", + inputMuted: Boolean(e.isInputMuted), + outputMuted: Boolean(e.isOutputMuted), + serverGroups: (e.serverGroups ?? []).map(String), + })), + }; +} + +const CHANNEL_ID = /^\d{1,20}$/; + +export class VoiceSession { + /** + * @param {WebSocket} ws + * @param {{ip: string, config: ReturnType<import("./config.js").loadConfig>, limiter: import("./auth.js").LoginLimiter, log: Console, onClose: () => void}} ctx + */ + constructor(ws, ctx) { + this.ws = ws; + this.ctx = ctx; + this.client = null; + this.state = "awaiting-join"; // -> connecting -> ready -> closed + this.muted = false; + this.deafened = false; + this.serverDecode = false; + this.encoder = null; + this.decoders = new Map(); + this.whisperTargetIds = new Set(); + this.whisperActive = false; + this.stateTimer = null; + this.eosTimer = null; + this.tokens = FRAME_BURST; + this.tokensAt = Date.now(); + this.badFrames = 0; + this.kickReason = ""; + this.unsubscribers = []; + this.closing = false; + + this.joinTimer = setTimeout(() => this.fail("JOIN_TIMEOUT", "No join request received.", 4408), 20_000); + ws.on("message", (data, isBinary) => { + if (isBinary) this.onPcm(data); + else this.onJson(data); + }); + ws.on("close", () => this.close()); + ws.on("error", () => this.close()); + } + + send(message) { + if (this.ws.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(message)); + } + + fail(code, message, closeCode = 4400) { + this.send({ type: "error", code, message, fatal: true }); + try { this.ws.close(closeCode, code.slice(0, 60)); } catch { /* already closed */ } + this.close(); + } + + error(code, message) { + this.send({ type: "error", code, message, fatal: false }); + } + + onJson(data) { + let msg; + try { + msg = JSON.parse(String(data)); + } catch { + return this.fail("BAD_MESSAGE", "Malformed message."); + } + if (!msg || typeof msg !== "object") return this.fail("BAD_MESSAGE", "Malformed message."); + + if (msg.type === "join") return void this.join(msg); + if (this.state !== "ready") return this.error("NOT_READY", "Not connected yet."); + switch (msg.type) { + case "move": return void this.move(msg); + case "mute": return void this.setMuted(Boolean(msg.muted)); + case "deafen": return void this.setDeafened(Boolean(msg.deafened)); + case "chat": return void this.sendChat(msg); + case "whisper": return void this.setWhisper(msg); + case "leave": return void this.close(); + default: return this.error("UNKNOWN_MESSAGE", "Unknown message type."); + } + } + + async join(msg) { + if (this.state !== "awaiting-join") return this.error("ALREADY_JOINED", "Already joined."); + clearTimeout(this.joinTimer); + const { config, limiter, ip, log } = this.ctx; + + if (!config.server.password) return this.fail("VOICE_DISABLED", "Web voice is disabled: no server password is configured.", 4403); + if (!config.server.host) return this.fail("NOT_CONFIGURED", "The TeamSpeak host is not configured.", 4503); + + const lockedMs = limiter.lockedFor(ip); + if (lockedMs > 0) { + return this.fail("LOCKED_OUT", `Too many wrong passwords. Try again in ${Math.ceil(lockedMs / 60_000)} min.`, 4429); + } + // The gateway itself verifies the server password before spending any + // resources on a TeamSpeak connection; the TeamSpeak server checks it again. + if (!passwordMatches(msg.password, config.server.password)) { + limiter.recordFailure(ip); + log.warn(`[voice] wrong password from ${ip}`); + return this.fail("BAD_PASSWORD", "Wrong server password.", 4401); + } + limiter.recordSuccess(ip); + + const nickname = sanitizeNickname(msg.nickname, config.voice.nicknamePrefix); + if (!nickname) return this.fail("NICKNAME_INVALID", "Nickname must be 3-30 characters."); + + let targetChannel = null; + if (msg.channelId !== undefined && msg.channelId !== null && msg.channelId !== "") { + if (!CHANNEL_ID.test(String(msg.channelId))) return this.fail("BAD_CHANNEL", "Invalid channel."); + targetChannel = String(msg.channelId); + } + this.serverDecode = msg.serverDecode === true; + this.state = "connecting"; + + let identity = null; + if (typeof msg.identity === "string" && msg.identity.length > 0 && msg.identity.length < 512) { + try { identity = parseIdentity(msg.identity); } catch { identity = null; } + } + try { + identity ??= await generateIdentity(config.voice.identitySecurityLevel, { signal: AbortSignal.timeout(CONNECT_TIMEOUT_MS) }); + } catch { + return this.fail("IDENTITY_FAILED", "Could not create a TeamSpeak identity.", 4500); + } + if (this.closing) return; + + const client = new Client(identity, `${config.server.host}:${config.server.voicePort}`, nickname, { + serverPassword: config.server.password, + defaultChannel: config.voice.defaultChannel || undefined, + logger: { + debug() {}, + info() {}, + warn: (m) => log.warn(`[ts ${nickname}] ${m}`), + error: (m) => log.error(`[ts ${nickname}] ${m}`), + }, + }); + this.client = client; + this.attachClientEvents(client); + + try { + await client.connect({ signal: AbortSignal.timeout(CONNECT_TIMEOUT_MS) }); + await client.view.whenReady({ signal: AbortSignal.timeout(CONNECT_TIMEOUT_MS) }); + } catch (error) { + if (this.closing) return; + const { code, message } = describeError(error); + log.warn(`[voice] ${ip} could not connect as "${nickname}": ${code} ${error?.message ?? ""}`); + return this.fail(code, message, 4502); + } + if (this.closing) return; + + try { + // Without this the server only reports clients of our own channel. + await subscribeAllChannels(client, { timeoutMs: 5000 }); + } catch (error) { + log.warn(`[voice] could not subscribe to all channels: ${error?.message ?? error}`); + } + + this.encoder = createOpusCodec({ bitrate: OPUS_BITRATE }); + this.state = "ready"; + log.info(`[voice] ${ip} joined as "${nickname}" (clid ${client.clientID})`); + this.send({ type: "joined", identity: identity.export(), nickname, ...snapshot(client) }); + + if (targetChannel && targetChannel !== String(client.channelID)) { + await this.move({ channelId: targetChannel, password: msg.channelPassword }); + } + } + + attachClientEvents(client) { + const on = (event, fn) => this.unsubscribers.push(client.on(event, fn)); + on("viewChanged", () => this.scheduleState()); + on("clientMoved", () => this.scheduleState()); + on("clientLeave", (event) => { + const id = Number(event?.clientID); + const decoder = this.decoders.get(id); + if (decoder) { decoder.dispose(); this.decoders.delete(id); } + if (this.whisperTargetIds.delete(id)) { + if (!this.whisperTargetIds.size) this.whisperActive = false; + this.send({ type: "whisperTargets", targetIds: [...this.whisperTargetIds], active: this.whisperActive }); + } + }); + on("textMessage", (event) => { + const scope = event.targetMode === 1 ? "private" : event.targetMode === 2 ? "channel" : "server"; + if (scope === "private" && Number(event.invokerID) === Number(client.clientID)) return; + const targetId = scope === "channel" + ? String(client.channelID) + : event.targetID ? String(event.targetID) : undefined; + this.send({ + type: "chatMessage", + scope, + ...(targetId ? { targetId } : {}), + invokerId: Number(event.invokerID), + invokerName: event.invokerName, + senderUid: String(event.invokerUID ?? ""), + message: event.message, + timestamp: Date.now(), + }); + }); + on("kicked", (event) => { this.kickReason = event?.reasonMessage ?? ""; }); + on("voiceData", (event) => this.onTeamSpeakVoice(event)); + on("disconnected", (event) => { + if (this.closing || this.state !== "ready") return; + const reason = this.kickReason + ? `Kicked: ${this.kickReason}` + : event?.error ? describeError(event.error).message : "Disconnected from TeamSpeak."; + this.send({ type: "disconnected", reason }); + try { this.ws.close(1000, "disconnected"); } catch { /* ignore */ } + this.close(); + }); + } + + scheduleState() { + if (this.stateTimer || this.state !== "ready") return; + this.stateTimer = setTimeout(() => { + this.stateTimer = null; + if (this.state === "ready" && this.client) this.send({ type: "state", ...snapshot(this.client) }); + }, STATE_THROTTLE_MS); + } + + async move(msg) { + const id = String(msg.channelId ?? ""); + if (!CHANNEL_ID.test(id)) return this.error("BAD_CHANNEL", "Invalid channel."); + const password = typeof msg.password === "string" && msg.password ? msg.password.slice(0, 512) : undefined; + try { + await moveClient(this.client, this.client.clientID, asChannelID(BigInt(id)), { password, timeoutMs: 5000 }); + this.scheduleState(); + } catch (error) { + const { code, message } = describeError(error); + this.send({ type: "error", code, message, fatal: false, channelId: id }); + } + } + + async sendChat(msg) { + const text = typeof msg.message === "string" ? msg.message.trim().slice(0, MAX_CHAT_LENGTH) : ""; + if (!text) return; + const scope = msg.scope === "private" ? "private" : msg.scope === "server" ? "server" : "channel"; + try { + if (scope === "private") { + const targetId = Number(msg.targetId); + if (!Number.isInteger(targetId) || targetId <= 0 || targetId > 65535) { + return this.error("BAD_TARGET", "Invalid private message recipient."); + } + await sendPrivateMessage(this.client, asClientID(targetId), text, { timeoutMs: 5000 }); + const selfId = Number(this.client.clientID); + const selfEntry = this.client.view?.clients?.get?.(this.client.clientID); + this.send({ + type: "chatMessage", + scope: "private", + targetId: String(targetId), + invokerId: selfId, + invokerName: selfEntry?.nickname || "you", + senderUid: "", + message: text, + timestamp: Date.now(), + }); + } else if (scope === "server") { + await sendServerMessage(this.client, text, { timeoutMs: 5000 }); + } else { + await sendChannelMessage(this.client, text, { timeoutMs: 5000 }); + } + } catch (error) { + const { code, message } = describeError(error); + this.error(code, message); + } + } + + setWhisper(msg) { + const selfId = Number(this.client?.clientID ?? 0); + if (Array.isArray(msg.targetIds)) { + const clean = []; + for (const raw of msg.targetIds) { + const id = Number(raw); + if (Number.isInteger(id) && id > 0 && id <= 65535 && id !== selfId && !clean.includes(id)) { + clean.push(id); + if (clean.length >= MAX_WHISPER_TARGETS) break; + } + } + this.whisperTargetIds = new Set(clean); + } + if (typeof msg.active === "boolean") { + this.whisperActive = msg.active && this.whisperTargetIds.size > 0; + } else if (!this.whisperTargetIds.size) { + this.whisperActive = false; + } + this.send({ type: "whisperTargets", targetIds: [...this.whisperTargetIds], active: this.whisperActive }); + } + + async setMuted(muted) { + this.muted = muted; + if (muted) this.endOfSpeech(); + try { await updateClient(this.client, { isInputMuted: muted }, { timeoutMs: 5000 }); } + catch (error) { this.error(describeError(error).code, "Could not update mute state."); } + } + + async setDeafened(deafened) { + this.deafened = deafened; + try { await updateClient(this.client, { isOutputMuted: deafened }, { timeoutMs: 5000 }); } + catch (error) { this.error(describeError(error).code, "Could not update deafen state."); } + } + + // ---- audio ---------------------------------------------------------------- + + takeToken() { + const now = Date.now(); + this.tokens = Math.min(FRAME_BURST, this.tokens + ((now - this.tokensAt) / 1000) * FRAME_RATE); + this.tokensAt = now; + if (this.tokens < 1) return false; + this.tokens -= 1; + return true; + } + + sendVoiceOrWhisper(payload) { + if (this.whisperActive && this.whisperTargetIds.size > 0) { + this.client.sendWhisper(payload, { clients: [...this.whisperTargetIds].map(asClientID) }, 4 /* Codec.OpusVoice */); + } else { + this.client.sendVoice(payload, 4 /* Codec.OpusVoice */); + } + } + + onPcm(frame) { + if (this.state !== "ready" || this.muted || !this.encoder) return; + if (!Buffer.isBuffer(frame) || frame.length !== FRAME_BYTES) { + if (++this.badFrames > 100) this.fail("BAD_AUDIO", "Malformed audio frames."); + return; + } + if (!this.takeToken()) return; + try { + const encoded = this.encoder.encode(frame); + this.sendVoiceOrWhisper(encoded); + } catch { + return; + } + clearTimeout(this.eosTimer); + this.eosTimer = setTimeout(() => this.endOfSpeech(), END_OF_SPEECH_MS); + } + + /** An empty voice packet tells TeamSpeak clients the transmission ended. */ + endOfSpeech() { + clearTimeout(this.eosTimer); + this.eosTimer = null; + if (this.state !== "ready") return; + try { this.sendVoiceOrWhisper(new Uint8Array(0)); } catch { /* not connected */ } + } + + speakerDecoder(clientId) { + let decoder = this.decoders.get(clientId); + if (decoder) { + this.decoders.delete(clientId); // refresh LRU position + } else { + decoder = createOpusCodec(); + while (this.decoders.size >= MAX_SPEAKER_DECODERS) { + const [oldestId, oldest] = this.decoders.entries().next().value; + oldest.dispose(); + this.decoders.delete(oldestId); + } + } + this.decoders.set(clientId, decoder); + return decoder; + } + + onTeamSpeakVoice(event) { + if (this.state !== "ready" || this.deafened || this.ws.readyState !== WebSocket.OPEN) return; + const clientId = Number(event.clientID); + if (clientId === Number(this.client.clientID) || clientId < 1 || clientId > 65535) return; + if (event.codec !== 4 && event.codec !== 5) return; // legacy Speex/CELT are not supported + const data = event.data instanceof Uint8Array ? event.data : new Uint8Array(0); + + let kind = event.codec; + let payload = data; + if (this.serverDecode) { + if (this.ws.bufferedAmount > MAX_BUFFERED_PCM_BYTES) return; + kind = KIND_PCM; + if (data.length > 0) { + try { payload = this.speakerDecoder(clientId).decode(Buffer.from(data.buffer, data.byteOffset, data.byteLength)); } + catch { return; } + } + } else if (this.ws.bufferedAmount > MAX_BUFFERED_OPUS_BYTES) { + return; + } + + const packet = Buffer.allocUnsafe(3 + payload.length); + packet[0] = kind; + packet.writeUInt16BE(clientId, 1); + packet.set(payload, 3); + this.ws.send(packet); + } + + // ---- teardown ------------------------------------------------------------- + + close() { + if (this.closing) return; + this.closing = true; + const wasReady = this.state === "ready"; + this.state = "closed"; + clearTimeout(this.joinTimer); + clearTimeout(this.stateTimer); + clearTimeout(this.eosTimer); + for (const off of this.unsubscribers) { try { off(); } catch { /* ignore */ } } + this.unsubscribers = []; + try { this.encoder?.dispose(); } catch { /* ignore */ } + this.encoder = null; + for (const decoder of this.decoders.values()) { try { decoder.dispose(); } catch { /* ignore */ } } + this.decoders.clear(); + this.whisperTargetIds.clear(); + this.whisperActive = false; + const client = this.client; + this.client = null; + if (client) { + client.disconnect({ reasonMessage: "Left from the web", signal: AbortSignal.timeout(2000) }) + .catch(() => undefined); + } + if (wasReady) this.ctx.log.info(`[voice] ${this.ctx.ip} left`); + if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) { + try { this.ws.close(1000, "bye"); } catch { /* ignore */ } + } + this.ctx.onClose(); + } +} @@ -374,6 +374,757 @@ body { transition: none; } +/* --------------------------------------------------------------------------- + Web voice + --------------------------------------------------------------------------- */ + +body.voice-live { + padding-bottom: 6.5rem; +} + +/* Top login bar (left = status, right = [ login ]) */ + +.voice-topbar { + border-bottom: 2px solid var(--cl-gray-2); + background-color: var(--cl-gray-1); +} + +.voice-topbar-bar { + padding: 0.65rem 1.25rem; + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.voice-topbar-identity { + color: var(--cl-gray-7); + font-size: 0.85rem; + font-weight: bold; + text-transform: uppercase; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.voice-topbar-identity[data-ready="1"] { + color: var(--cl-green-6); +} + +.voice-login-btn { + padding: 4px 10px; + color: var(--cl-orange-6); + font-family: inherit; + font-size: 0.85rem; + font-weight: bold; + text-transform: uppercase; + border: 2px solid var(--cl-orange-6); + background-color: var(--cl-gray-0); + cursor: pointer; + flex-shrink: 0; +} + +.voice-login-btn::before { + content: "[ "; +} + +.voice-login-btn::after { + content: " ]"; +} + +.voice-login-btn:hover { + color: var(--cl-black); + background-color: var(--cl-magenta-7); + border-color: transparent; +} + +.voice-topbar-hint { + margin: 0; + padding: 0.5rem 1.25rem 0.75rem; + color: var(--cl-gray-7); + font-size: 0.85rem; + border-top: 2px solid var(--cl-gray-2); + background-color: var(--cl-gray-0); +} + +.voice-topbar-hint[data-kind="error"] { + color: var(--cl-red-6); +} + +/* Join buttons on every channel card */ + +.channel-actions { + display: flex; + align-items: center; + gap: 0.75rem; + flex-shrink: 0; +} + +.channel-join, +.channel-here { + padding: 4px 10px; + font-family: inherit; + font-size: 0.85rem; + font-weight: bold; + text-transform: uppercase; + border: 2px solid; + background-color: transparent; +} + +.channel-join { + color: var(--cl-orange-6); + border-color: var(--cl-orange-6); + background-color: var(--cl-gray-0); + cursor: pointer; +} + +.channel-join::before, +.channel-here::before { + content: "[ "; +} + +.channel-join::after, +.channel-here::after { + content: " ]"; +} + +.channel-join:hover, +.channel-join:focus-visible { + color: var(--cl-black); + background-color: var(--cl-magenta-7); + border-color: transparent; + outline: none; +} + +.voice-connecting .channel-join { + opacity: 0.5; + pointer-events: none; +} + +.channel-here { + color: var(--cl-green-6); + border-color: var(--cl-green-6); + background-color: var(--cl-gray-0); +} + +.channel-lock { + margin-left: 0.75rem; + padding: 0 4px; + color: var(--cl-yellow); + font-size: 0.75rem; + font-weight: bold; + text-transform: uppercase; + border: 2px solid var(--cl-yellow); +} + +.channel.is-current { + border-color: var(--cl-green-6); +} + +.channel.is-current .channel-header { + border-bottom-color: var(--cl-green-6); +} + +/* Live clients, per-user volume controls & direct message buttons */ + +.voice-live #refresh-bar { + display: none; +} + +.client.is-self .client-name { + color: var(--cl-white); + font-weight: bold; +} + +.client-you { + color: var(--cl-gray-6); + font-size: 0.85rem; + margin-left: 0.5rem; +} + +.client.is-talking::before { + content: ">"; + color: var(--cl-green-6); +} + +.client.is-talking .client-name { + color: var(--cl-green-6); +} + +.client-muted { + color: var(--cl-gray-6); + font-size: 0.85rem; + margin-left: 0.5rem; +} + +.client-controls { + margin-left: auto; + display: flex; + align-items: center; + gap: 0.65rem; + flex-shrink: 0; +} + +.client-volume { + display: inline-flex; + align-items: center; + gap: 0.4rem; + color: var(--cl-gray-6); + font-size: 0.75rem; + font-weight: bold; +} + +.client-volume-slider { + width: 80px; + margin: 0; + accent-color: var(--cl-orange-6); + cursor: pointer; +} + +.client-volume-value { + min-width: 3.1rem; + text-align: right; + color: var(--cl-gray-7); + font-variant-numeric: tabular-nums; +} + +.client-msg-btn { + padding: 2px 6px; + color: var(--cl-gray-6); + font-family: inherit; + font-size: 0.75rem; + font-weight: bold; + text-transform: uppercase; + border: 2px solid var(--cl-gray-3); + background-color: var(--cl-gray-0); + cursor: pointer; +} + +.client-msg-btn::before { + content: "[ "; +} + +.client-msg-btn::after { + content: " ]"; +} + +.client-msg-btn:hover { + color: var(--cl-black); + background-color: var(--cl-magenta-7); + border-color: transparent; +} + +.client-msg-btn.is-active { + color: var(--cl-cyan-6); + border-color: var(--cl-cyan-6); +} + +.client-msg-btn.has-unread { + color: var(--cl-orange-6); + border-color: var(--cl-orange-6); +} + +/* Inline channel & private message chat underneath the joined channel */ + +.channel-chat { + border-top: 2px solid var(--cl-gray-2); + background-color: var(--cl-gray-0); +} + +.channel-chat-header { + padding: 0.45rem 0.75rem; + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + border-bottom: 2px solid var(--cl-gray-2); + background-color: var(--cl-gray-1); +} + +.channel-chat-tabs { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.45rem; +} + +.channel-chat-tab { + padding: 2px 8px; + color: var(--cl-gray-6); + font-family: inherit; + font-size: 0.78rem; + font-weight: bold; + text-transform: uppercase; + border: 2px solid var(--cl-gray-3); + background-color: var(--cl-gray-0); + cursor: pointer; +} + +.channel-chat-tab::before { + content: "[ "; +} + +.channel-chat-tab::after { + content: " ]"; +} + +.channel-chat-tab:hover { + color: var(--cl-black); + background-color: var(--cl-magenta-7); + border-color: transparent; +} + +.channel-chat-tab.is-active { + color: var(--cl-cyan-6); + border-color: var(--cl-cyan-6); +} + +.channel-chat-tab.is-dm.is-active { + color: var(--cl-magenta-7); + border-color: var(--cl-magenta-7); +} + +.channel-chat-tab.has-unread { + color: var(--cl-orange-6); + border-color: var(--cl-orange-6); +} + +.channel-chat-messages { + max-height: 190px; + overflow-y: auto; + padding: 0.65rem 1rem; + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.channel-chat-empty { + color: var(--cl-gray-5); + font-size: 0.85rem; +} + +.channel-chat-msg { + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: 0.45rem; + font-size: 0.88rem; + line-height: 1.4; + word-break: break-word; +} + +.channel-chat-time { + color: var(--cl-gray-5); + font-size: 0.75rem; + flex-shrink: 0; +} + +.channel-chat-author { + color: var(--cl-orange-6); + font-weight: bold; + flex-shrink: 0; +} + +.channel-chat-msg.is-self .channel-chat-author { + color: var(--cl-green-6); +} + +.channel-chat-msg.is-private .channel-chat-author { + color: var(--cl-magenta-7); +} + +.channel-chat-msg.is-server .channel-chat-author { + color: var(--cl-cyan-6); +} + +.channel-chat-text { + color: var(--cl-white); +} + +.channel-chat-form { + padding: 0.55rem 0.75rem; + display: flex; + align-items: stretch; + gap: 0.5rem; + border-top: 2px solid var(--cl-gray-2); + background-color: var(--cl-gray-1); +} + +.channel-chat-input { + flex: 1 1 auto; + min-width: 0; + padding: 0.45rem 0.65rem; + color: var(--cl-white); + font-family: inherit; + font-size: 0.9rem; + border: 2px solid var(--cl-gray-3); + background-color: var(--cl-gray-0); + outline: none; +} + +.channel-chat-input:focus { + border-color: var(--cl-orange-6); +} + +.channel-chat-send { + padding: 0.45rem 0.85rem; + color: var(--cl-orange-6); + font-family: inherit; + font-size: 0.85rem; + font-weight: bold; + text-transform: uppercase; + border: 2px solid var(--cl-orange-6); + background-color: var(--cl-gray-0); + cursor: pointer; +} + +.channel-chat-send::before { + content: "[ "; +} + +.channel-chat-send::after { + content: " ]"; +} + +.channel-chat-send:hover { + color: var(--cl-black); + background-color: var(--cl-magenta-7); + border-color: transparent; +} + +/* Fixed voice controls bar at the bottom of the screen while connected */ + +.voice-bar { + position: fixed; + left: 0; + right: 0; + bottom: 0; + z-index: 50; + border-top: 2px solid var(--cl-green-6); + background-color: var(--cl-gray-0); + box-shadow: 0 -6px 24px rgba(0, 0, 0, 0.65); +} + +.voice-bar-main { + max-width: 760px; + margin: 0 auto; + padding: 0.75rem 1.5rem; + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 1rem; +} + +.voice-bar-info { + display: flex; + align-items: center; + gap: 1rem; + min-width: 0; +} + +.voice-live-badge { + display: inline-flex; + align-items: center; + padding: 4px 10px; + color: var(--cl-green-6); + font-size: 0.8rem; + font-weight: bold; + text-transform: uppercase; + border: 2px solid var(--cl-green-6); +} + +.voice-live-badge .pulse-dot { + width: 8px; + height: 8px; + margin-right: 0.6rem; +} + +.voice-where { + display: flex; + align-items: baseline; + gap: 0.6rem; + min-width: 0; + font-weight: bold; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.voice-where #voice-channel::before { + content: "> "; + color: var(--cl-orange-6); +} + +.voice-as { + color: var(--cl-gray-6); + font-weight: normal; +} + +.voice-buttons { + display: flex; + flex-wrap: wrap; + gap: 0.6rem; +} + +.btn-voice { + padding: 6px 12px; + color: var(--cl-gray-8); + font-family: inherit; + font-size: 0.85rem; + font-weight: bold; + text-transform: uppercase; + border: 2px solid var(--cl-gray-4); + background-color: transparent; + cursor: pointer; + user-select: none; + touch-action: none; +} + +.btn-voice::before { + content: "[ "; +} + +.btn-voice::after { + content: " ]"; +} + +.btn-voice:hover { + color: var(--cl-black); + background-color: var(--cl-magenta-7); + border-color: transparent; +} + +.btn-voice[aria-pressed="true"] { + color: var(--cl-red-6); + border-color: var(--cl-red-6); +} + +.btn-voice[aria-expanded="true"] { + color: var(--cl-orange-6); + border-color: var(--cl-orange-6); +} + +.btn-voice.is-active { + color: var(--cl-black); + background-color: var(--cl-green-6); + border-color: var(--cl-green-6); +} + +.btn-voice-talk { + color: var(--cl-green-6); + border-color: var(--cl-green-6); +} + +.btn-voice-danger { + color: var(--cl-red-6); + border-color: var(--cl-red-6); +} + +.voice-meter { + position: relative; + height: 4px; + background-color: var(--cl-gray-2); +} + +.voice-meter-fill { + width: 0%; + height: 100%; + background-color: var(--cl-gray-5); +} + +.voice-meter-fill.is-open { + background-color: var(--cl-green-6); +} + +.voice-meter-threshold { + position: absolute; + top: -3px; + bottom: -3px; + width: 2px; + background-color: var(--cl-orange-6); +} + +.voice-settings { + max-width: 760px; + margin: 0 auto; + padding: 1rem 1.5rem; + display: grid; + grid-template-columns: auto minmax(0, 1fr) minmax(0, 1fr); + align-items: end; + gap: 1.5rem; + border-bottom: 2px solid var(--cl-gray-2); + background-color: var(--cl-gray-1); +} + +.voice-setting { + display: flex; + flex-direction: column; + align-items: flex-start; +} + +.voice-setting input[type="range"] { + width: 100%; + margin: 0.35rem 0; + accent-color: var(--cl-orange-6); +} + +.voice-bar-message { + max-width: 760px; + margin: 0 auto; + padding: 0.5rem 1.5rem; + color: var(--cl-gray-7); + font-size: 0.85rem; + border-bottom: 2px solid var(--cl-gray-2); +} + +.voice-bar-message[data-kind="error"] { + color: var(--cl-red-6); +} + +/* Notice shown above the channel list after a call ends */ + +.voice-notice { + margin: 0 0 1.5rem; + padding: 0.75rem 1rem; + color: var(--cl-gray-8); + font-size: 0.9rem; + border: 2px solid var(--cl-gray-4); + background-color: var(--cl-gray-1); +} + +.voice-notice::before { + content: "! "; + color: var(--cl-orange-6); + font-weight: bold; +} + +.voice-notice[data-kind="error"] { + color: var(--cl-red-6); + border-color: var(--cl-red-6); +} + +/* Login modal ([ login ] button dialog) */ + +.voice-dialog { + width: min(460px, calc(100vw - 2rem)); + padding: 0; + color: var(--cl-white); + font-family: inherit; + border: 2px solid var(--cl-orange-6); + background-color: var(--cl-gray-0); +} + +.voice-dialog::backdrop { + background-color: hsla(140, 1%, 6%, 0.82); +} + +.voice-dialog-header { + padding: 0.85rem 1.25rem; + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + border-bottom: 2px solid var(--cl-gray-2); + background-color: var(--cl-gray-2); +} + +.voice-dialog-header h2 { + margin: 0; + font-size: 0.95rem; + text-transform: uppercase; +} + +.voice-dialog-header h2::before { + content: "> "; + color: var(--cl-orange-6); +} + +.voice-dialog-target { + padding: 3px 8px; + color: var(--cl-cyan-6); + font-size: 0.8rem; + font-weight: bold; + text-transform: uppercase; + border: 2px solid var(--cl-cyan-6); + max-width: 55%; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.voice-dialog-body { + padding: 1.25rem; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.voice-field { + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.voice-field-tag { + color: var(--cl-orange-6); + font-size: 0.85rem; + font-weight: bold; + text-transform: uppercase; +} + +.voice-field input { + width: 100%; + padding: 0.65rem 0.8rem; + color: var(--cl-white); + font-family: inherit; + font-size: 0.95rem; + border: 2px solid var(--cl-gray-3); + background-color: var(--cl-gray-1); + outline: none; +} + +.voice-field input:focus { + border-color: var(--cl-orange-6); +} + +.voice-dialog-message { + margin: 0; + color: var(--cl-gray-7); + font-size: 0.85rem; +} + +.voice-dialog-message[data-kind="error"] { + color: var(--cl-red-6); +} + +.voice-dialog-actions { + padding: 0.85rem 1.25rem 1.25rem; + display: flex; + justify-content: flex-end; + align-items: stretch; + gap: 0.75rem; +} + +.voice-dialog-actions .voice-submit { + width: auto; + min-width: 8rem; + margin: 0; + font-family: inherit; + cursor: pointer; +} + +.voice-dialog-actions .voice-submit:disabled { + color: var(--cl-gray-5); + border-color: var(--cl-gray-4); + background-color: transparent; + cursor: wait; +} + +[hidden] { + display: none !important; +} + @media (max-width: 640px) { body { margin: 2vh 4vw; @@ -392,4 +1143,30 @@ body { flex-direction: column; gap: 0.75rem; } + + .channel-actions { + width: 100%; + justify-content: space-between; + } + + .client { + flex-wrap: wrap; + gap: 0.4rem; + } + + .voice-settings { + grid-template-columns: 1fr; + gap: 1rem; + } + + .voice-bar-main, + .voice-settings, + .voice-bar-message { + padding-left: 1rem; + padding-right: 1rem; + } + + .voice-buttons { + width: 100%; + } } diff --git a/pyproject.toml b/pyproject.toml index ffeca58..0f44c61 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,5 +7,6 @@ requires-python = ">=3.13" dependencies = [ "flask>=3.1.3", "flask-cors>=6.0.5", + "python-dotenv>=1.0.1", "requests>=2.34.2", ] diff --git a/requirements.txt b/requirements.txt index 0062c2f..123d5e7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ Flask>=3.0,<4 -flask-cors>=4.0,<5 +flask-cors>=4.0,<7 +python-dotenv>=1.0,<2 requests>=2.31,<3 @@ -0,0 +1,121 @@ +"""Start the dashboard and the browser voice gateway with one command. + + uv run run.py + +With web voice enabled (config.toml [voice] enabled = true) the Node gateway +listens on the public port (web.port), handles /ws/voice and forwards every +other request to Flask on 127.0.0.1:web.dashboard_port. With voice disabled +(or Node missing) only the dashboard runs, directly on the public port. +""" + +import os +import shutil +import signal +import subprocess +import sys +import time + +from config import BASE_DIR, CONFIG + +GATEWAY_DIR = BASE_DIR / "gateway" +MIN_NODE = (22, 12) + + +def log(message): + print(f"[run] {message}", flush=True) + + +def node_ok(node): + try: + out = subprocess.run([node, "--version"], capture_output=True, text=True, check=True).stdout + major, minor = (int(part) for part in out.strip().lstrip("v").split(".")[:2]) + return (major, minor) >= MIN_NODE, out.strip() + except (OSError, ValueError, subprocess.CalledProcessError): + return False, "unknown" + + +def ensure_gateway_deps(): + if (GATEWAY_DIR / "node_modules" / "@honeybbq" / "teamspeak").is_dir(): + return True + npm = shutil.which("npm") + if not npm: + log("npm not found; cannot install the voice gateway dependencies") + return False + lock = (GATEWAY_DIR / "package-lock.json").exists() + cmd = [npm, "ci" if lock else "install", "--omit=dev", "--no-audit", "--no-fund"] + log(f"installing voice gateway dependencies: {' '.join(cmd[1:])}") + return subprocess.run(cmd, cwd=GATEWAY_DIR).returncode == 0 + + +def gateway_command(): + """The Node command for the gateway, or None when voice cannot run.""" + if not CONFIG["voice"].get("enabled"): + log("web voice disabled in config.toml; starting the dashboard only") + return None + node = shutil.which("node") + if not node: + log("Node.js not found; starting the dashboard only (install Node >= 22.12 for web voice)") + return None + ok, version = node_ok(node) + if not ok: + log(f"Node.js {version} is too old (need >= {'.'.join(map(str, MIN_NODE))}); starting the dashboard only") + return None + if not ensure_gateway_deps(): + log("voice gateway dependencies missing; starting the dashboard only") + return None + return [node, str(GATEWAY_DIR / "src" / "index.js")] + + +def main(): + web = CONFIG["web"] + gateway_cmd = gateway_command() + env = dict(os.environ) + + if gateway_cmd: + env.update(DASHBOARD_HOST="127.0.0.1", DASHBOARD_LISTEN_PORT=str(web["dashboard_port"])) + else: + env.update(DASHBOARD_HOST=str(web["host"]), DASHBOARD_LISTEN_PORT=str(web["port"])) + + procs = [("dashboard", subprocess.Popen([sys.executable, str(BASE_DIR / "app.py")], cwd=BASE_DIR, env=env))] + if gateway_cmd: + procs.append(("gateway", subprocess.Popen(gateway_cmd, cwd=BASE_DIR, env=env))) + log(f"open http://{'localhost' if web['host'] in ('0.0.0.0', '::') else web['host']}:{web['port']}") + + stopping = False + + def stop(*_): + nonlocal stopping + if stopping: + return + stopping = True + for _, proc in procs: + if proc.poll() is None: + proc.send_signal(signal.SIGTERM) + + signal.signal(signal.SIGINT, stop) + signal.signal(signal.SIGTERM, stop) + + exit_code = 0 + try: + while not stopping: + for name, proc in procs: + code = proc.poll() + if code is not None: + log(f"{name} exited with code {code}; shutting everything down") + exit_code = code or 1 + stop() + break + time.sleep(0.5) + finally: + stop() + deadline = time.time() + 5 + for _, proc in procs: + try: + proc.wait(timeout=max(0.1, deadline - time.time())) + except subprocess.TimeoutExpired: + proc.kill() + sys.exit(exit_code) + + +if __name__ == "__main__": + main() diff --git a/static/mic-worklet.js b/static/mic-worklet.js new file mode 100644 index 0000000..814bf6a --- /dev/null +++ b/static/mic-worklet.js @@ -0,0 +1,60 @@ +// Microphone capture worklet: resamples the context rate to 48 kHz mono and +// posts 20 ms frames (960 Float32 samples) together with their RMS level. +const OUT_RATE = 48000; +const FRAME_SAMPLES = 960; + +class Ts6MicCapture extends AudioWorkletProcessor { + constructor() { + super(); + this.ratio = sampleRate / OUT_RATE; // input samples per output sample + this.t = 0; // fractional read position; -1 refers to `last` + this.last = 0; + this.frame = new Float32Array(FRAME_SAMPLES); + this.offset = 0; + } + + push(sample) { + this.frame[this.offset++] = sample; + if (this.offset < FRAME_SAMPLES) return; + let sum = 0; + for (let i = 0; i < FRAME_SAMPLES; i++) sum += this.frame[i] * this.frame[i]; + const frame = this.frame; + this.frame = new Float32Array(FRAME_SAMPLES); + this.offset = 0; + this.port.postMessage({ samples: frame, rms: Math.sqrt(sum / FRAME_SAMPLES) }, [frame.buffer]); + } + + process(inputs, outputs) { + const output = outputs[0] && outputs[0][0]; + if (output) output.fill(0); + const channels = inputs[0]; + if (!channels || channels.length === 0) return true; + + // Downmix to mono. + let input = channels[0]; + if (channels.length > 1) { + input = new Float32Array(input.length); + for (const ch of channels) for (let i = 0; i < input.length; i++) input[i] += ch[i] / channels.length; + } + + if (this.ratio === 1) { + for (let i = 0; i < input.length; i++) this.push(input[i]); + return true; + } + // Linear interpolation resampler. + const len = input.length; + while (this.t < len - 1) { + const i0 = Math.floor(this.t); + const frac = this.t - i0; + const a = i0 < 0 ? this.last : input[i0]; + const b = input[i0 + 1]; + this.push(a + (b - a) * frac); + this.t += this.ratio; + } + this.t -= len; + this.last = input[len - 1]; + return true; + } +} + +registerProcessor("ts6-mic-capture", Ts6MicCapture); diff --git a/static/voice.js b/static/voice.js new file mode 100644 index 0000000..9cfa74b --- /dev/null +++ b/static/voice.js @@ -0,0 +1,1145 @@ +// Browser voice client for the TeamSpeak dashboard. +// Talks to the Node gateway over /ws/voice (see gateway/src/voice-session.js +// for the wire protocol). No WebRTC: mic PCM goes up, Opus frames come down. +// +// UI layout: +// - Top bar: shows current identity on the left and a [ browser connect ] button +// on the right that opens a modal for [ nickname ] and [ server password ] +// (both saved in localStorage). +// - Each channel card has a [ join ] button; while connected, non-self users +// show a per-user volume slider and a [ msg ] button for direct messages, +// and the joined channel expands an inline chat box (with tabs for #channel +// and @user direct messages) directly underneath its user list. +// - Fixed bottom bar: mute / deafen / settings / leave controls. +(() => { + "use strict"; + + const cfgEl = document.getElementById("voice-config"); + const topbar = document.getElementById("voice-topbar"); + const dialog = document.getElementById("voice-dialog"); + if (!cfgEl || !topbar || !dialog) return; + const cfg = JSON.parse(cfgEl.textContent || "{}"); + if (!cfg.enabled) return; + + const $ = (id) => document.getElementById(id); + const ui = { + topbar, + topbarIdentity: $("voice-topbar-identity"), + loginBtn: $("voice-login-btn"), + dialog, + dialogTarget: $("voice-dialog-target"), + dialogMessage: $("voice-dialog-message"), + form: $("voice-join-form"), + nickname: $("voice-nickname"), + password: $("voice-password"), + submit: $("voice-submit"), + cancel: $("voice-cancel"), + message: $("voice-message"), + notice: $("voice-notice"), + bar: $("voice-bar"), + barMessage: $("voice-bar-message"), + channel: $("voice-channel"), + self: $("voice-self"), + meter: $("voice-meter"), + threshold: $("voice-threshold"), + mute: $("voice-mute"), + deafen: $("voice-deafen"), + settingsToggle: $("voice-settings-toggle"), + settings: $("voice-settings"), + mode: $("voice-mode"), + ptt: $("voice-ptt"), + leave: $("voice-leave"), + sensitivity: $("voice-sensitivity"), + volume: $("voice-volume"), + container: $("ajax-container"), + }; + + const STORE = { + nickname: "ts6voice.nickname", + password: "ts6voice.password", + identity: "ts6voice.identity", + sensitivity: "ts6voice.sensitivity", + volume: "ts6voice.volume", + mode: "ts6voice.mode", + userVolumes: "ts6voice.userVolumes", + }; + const store = { + get: (k) => { try { return localStorage.getItem(k); } catch { return null; } }, + set: (k, v) => { try { localStorage.setItem(k, v); } catch { /* private mode */ } }, + }; + + const KIND_PCM = 0xf0; + const PTT_KEY = "KeyV"; + const VAD_HANG_MS = 350; + const TALK_TIMEOUT_MS = 300; + const START_LATENCY_S = 0.06; + const MAX_AHEAD_S = 0.25; + const MAX_SEND_BUFFER = 16 * 1024; + const MAX_CHAT_HISTORY = 100; + + let session = null; // active session, see startJoin() + let pendingJoin = null; // undefined = save only; null = join default channel; {cid,name} = join specific channel + + // ---- per-user volume storage ----------------------------------------------- + + let savedUserVolumes = {}; + try { + const raw = store.get(STORE.userVolumes); + if (raw) { + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === "object") savedUserVolumes = parsed; + } + } catch { + savedUserVolumes = {}; + } + + function clampUserVolume(val) { + const num = Number(val); + if (!Number.isFinite(num)) return 100; + return Math.max(0, Math.min(200, Math.round(num))); + } + + function getUserVolumePercent(clientId, nickname) { + if (session && session.userVolumes.has(clientId)) { + return session.userVolumes.get(clientId); + } + if (nickname && Object.prototype.hasOwnProperty.call(savedUserVolumes, nickname)) { + return clampUserVolume(savedUserVolumes[nickname]); + } + return 100; + } + + function setUserVolumePercent(clientId, nickname, percent) { + const clamped = clampUserVolume(percent); + if (session) { + session.userVolumes.set(clientId, clamped); + if (session.playback) session.playback.setUserVolume(clientId, clamped / 100); + } + if (nickname) { + savedUserVolumes[nickname] = clamped; + store.set(STORE.userVolumes, JSON.stringify(savedUserVolumes)); + } + return clamped; + } + + // ---- messages & topbar summary --------------------------------------------- + + function setText(node, text, kind = "info") { + if (!node) return; + node.textContent = text || ""; + node.dataset.kind = kind; + node.hidden = !text; + } + + /** Route a message to wherever the user is looking right now. */ + function say(text, kind = "info") { + if (session && session.ready) setText(ui.barMessage, text, kind); + else if (ui.dialog.open) setText(ui.dialogMessage, text, kind); + else setText(ui.message, text, kind); + } + + function updateTopbarSummary() { + const name = (ui.nickname.value || "").trim(); + const pass = ui.password.value || ""; + if (session && session.ready) { + ui.topbarIdentity.textContent = `[ ${session.nickname} // connected ]`; + ui.topbarIdentity.dataset.ready = "1"; + } else if (name.length >= 3 && pass) { + ui.topbarIdentity.textContent = `[ ${name} // password saved ]`; + ui.topbarIdentity.dataset.ready = "1"; + } else if (name.length >= 3) { + ui.topbarIdentity.textContent = `[ ${name} // no password ]`; + ui.topbarIdentity.dataset.ready = ""; + } else { + ui.topbarIdentity.textContent = "[ not logged in ]"; + ui.topbarIdentity.dataset.ready = ""; + } + } + + // ---- browser connect modal -------------------------------------------------- + + function openLoginModal(joinIntent = null, errorMsg = "") { + pendingJoin = joinIntent; + if (joinIntent === undefined) { + ui.dialogTarget.textContent = "credentials"; + ui.submit.textContent = "save"; + } else if (joinIntent && joinIntent.name) { + ui.dialogTarget.textContent = joinIntent.name; + ui.submit.textContent = "connect"; + } else { + ui.dialogTarget.textContent = "default channel"; + ui.submit.textContent = "connect"; + } + setText(ui.dialogMessage, errorMsg, errorMsg ? "error" : "info"); + if (!ui.dialog.open) ui.dialog.showModal(); + (ui.nickname.value.trim().length >= 3 ? ui.password : ui.nickname).focus(); + } + + function closeLoginModal() { + pendingJoin = null; + if (ui.dialog.open) ui.dialog.close(); + } + + ui.loginBtn.addEventListener("click", () => { + if (session && session.ready) openLoginModal(undefined); + else openLoginModal(null); + }); + ui.cancel.addEventListener("click", () => { + if (session && !session.ready) teardown("", "info"); + closeLoginModal(); + }); + ui.dialog.addEventListener("cancel", (e) => { + if (session && !session.ready) { + e.preventDefault(); + teardown("", "info"); + closeLoginModal(); + } + }); + ui.dialog.addEventListener("click", (e) => { + if (e.target === ui.dialog && (!session || session.ready)) closeLoginModal(); + }); + + // ---- preferences -------------------------------------------------------------- + + ui.nickname.value = store.get(STORE.nickname) || ""; + ui.password.value = store.get(STORE.password) || ""; + if (store.get(STORE.sensitivity) !== null) ui.sensitivity.value = store.get(STORE.sensitivity); + if (store.get(STORE.volume) !== null) ui.volume.value = store.get(STORE.volume); + let mode = store.get(STORE.mode) === "ptt" ? "ptt" : "vad"; + updateTopbarSummary(); + + ui.nickname.addEventListener("input", () => { + store.set(STORE.nickname, ui.nickname.value.trim()); + updateTopbarSummary(); + setText(ui.dialogMessage, ""); + }); + ui.password.addEventListener("input", () => { + store.set(STORE.password, ui.password.value); + updateTopbarSummary(); + setText(ui.dialogMessage, ""); + }); + + function levelFromRms(rms) { + const db = 20 * Math.log10(Math.max(rms, 1e-6)); + return Math.min(1, Math.max(0, (db + 60) / 60)); + } + const threshold = () => 1 - Number(ui.sensitivity.value) / 100; + function updateThresholdMark() { ui.threshold.style.left = `${threshold() * 100}%`; } + function applyModeUi() { + ui.mode.textContent = mode === "ptt" ? "push to talk" : "voice activity"; + ui.ptt.hidden = mode !== "ptt"; + ui.threshold.hidden = mode === "ptt"; + } + updateThresholdMark(); + applyModeUi(); + + // ---- live channel list & chat (channel + individual DMs) -------------------- + + function sortChannels(channels, pid = "0", depth = 0) { + const byOrder = new Map(); + for (const c of channels) if (c.parentId === pid) byOrder.set(c.order, c); + const ordered = []; + let current = "0"; + let guard = 0; + while (byOrder.has(current) && guard++ <= channels.length && depth < 32) { + const channel = byOrder.get(current); + ordered.push(channel); + ordered.push(...sortChannels(channels, channel.id, depth + 1)); + current = channel.id; + } + return ordered; + } + + function el(tag, className, text) { + const node = document.createElement(tag); + if (className) node.className = className; + if (text !== undefined) node.textContent = text; + return node; + } + + function safeUrl(path) { + return encodeURI(String(path || "")).replace(/["'()\\]/g, (c) => "%" + c.charCodeAt(0).toString(16)); + } + + function statItem(label, value) { + const item = el("div", "stat-item"); + item.append(el("span", "stat-label", label), el("span", "stat-value", String(value))); + return item; + } + + const displayName = (name) => name.replace("[Cspacer]", ""); + + function formatTime(ts) { + try { + const d = new Date(ts || Date.now()); + return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`; + } catch { + return ""; + } + } + + function findClientName(clientId, fallback = "") { + if (session && session.snapshot) { + const found = session.snapshot.clients.find((c) => c.id === clientId); + if (found && found.nickname) return found.nickname; + } + const existing = session && session.dmByUser.get(clientId); + if (existing && existing.peerName) return existing.peerName; + return fallback || `user #${clientId}`; + } + + function ensureDmEntry(peerId, peerName) { + let dm = session.dmByUser.get(peerId); + const resolvedName = findClientName(peerId, peerName); + if (!dm) { + dm = { peerId, peerName: resolvedName, messages: [], unread: 0 }; + session.dmByUser.set(peerId, dm); + } else if (resolvedName && dm.peerName !== resolvedName) { + dm.peerName = resolvedName; + } + return dm; + } + + function openDmConversation(peerId, peerName) { + if (!session || !session.ready || !peerId || peerId === session.selfId) return; + const dm = ensureDmEntry(peerId, peerName); + dm.unread = 0; + session.activeChatTab = { type: "dm", peerId, peerName: dm.peerName }; + render(); + const input = ui.container.querySelector(".channel-chat-input"); + if (input) { + input.focus(); + input.scrollIntoView({ behavior: "smooth", block: "nearest" }); + } + } + + function buildChatMsgRow(m, selfId) { + const isSelf = m.invokerId === selfId; + const row = el( + "div", + "channel-chat-msg" + + (isSelf ? " is-self" : "") + + (m.scope === "private" ? " is-private" : m.scope === "server" ? " is-server" : "") + ); + row.append(el("span", "channel-chat-time", formatTime(m.timestamp))); + const authorLabel = m.scope === "server" ? `[server] ${m.invokerName}` : m.invokerName; + row.append(el("span", "channel-chat-author", `${authorLabel}:`)); + row.append(el("span", "channel-chat-text", m.message)); + return row; + } + + function buildChannelChat(channelId, channelTitle) { + const wrap = el("section", "channel-chat"); + wrap.dataset.chatCid = channelId; + + const activeTab = session ? session.activeChatTab : { type: "channel" }; + const isDmTab = activeTab && activeTab.type === "dm"; + const activeDm = isDmTab && session ? session.dmByUser.get(activeTab.peerId) : null; + if (isDmTab && !activeDm && session) { + session.activeChatTab = { type: "channel" }; + } + const showingDm = Boolean(isDmTab && activeDm); + + const head = el("header", "channel-chat-header"); + const tabs = el("div", "channel-chat-tabs"); + + const chTab = el( + "button", + "channel-chat-tab" + (!showingDm ? " is-active" : ""), + `# ${channelTitle}` + ); + chTab.type = "button"; + chTab.dataset.chatTab = "channel"; + tabs.append(chTab); + + if (session) { + for (const dm of session.dmByUser.values()) { + const isCurrentDm = showingDm && activeDm.peerId === dm.peerId; + const dmBtn = el( + "button", + "channel-chat-tab is-dm" + (isCurrentDm ? " is-active" : "") + (dm.unread > 0 ? " has-unread" : ""), + dm.unread > 0 ? `@ ${dm.peerName} (${dm.unread})` : `@ ${dm.peerName}` + ); + dmBtn.type = "button"; + dmBtn.dataset.chatTab = "dm"; + dmBtn.dataset.dmPeer = String(dm.peerId); + tabs.append(dmBtn); + } + } + head.append(tabs); + + const log = el("div", "channel-chat-messages"); + log.id = "voice-chat-log"; + if (showingDm) { + const msgs = activeDm.messages; + if (msgs.length === 0) { + log.append(el("div", "channel-chat-empty", `No private messages with ${activeDm.peerName} yet.`)); + } else { + for (const m of msgs) log.append(buildChatMsgRow(m, session.selfId)); + } + } else { + const msgs = (session && session.chatByChannel.get(channelId)) || []; + if (msgs.length === 0) { + log.append(el("div", "channel-chat-empty", "No messages in this channel yet.")); + } else { + for (const m of msgs) log.append(buildChatMsgRow(m, session.selfId)); + } + } + + const draftKey = showingDm ? `dm:${activeDm.peerId}` : `channel:${channelId}`; + const form = el("form", "channel-chat-form"); + form.dataset.chatScope = showingDm ? "private" : "channel"; + form.dataset.chatCid = channelId; + if (showingDm) form.dataset.chatTargetId = String(activeDm.peerId); + + const input = el("input", "channel-chat-input"); + input.type = "text"; + input.name = "message"; + input.maxLength = 1024; + input.autocomplete = "off"; + input.placeholder = showingDm ? `message @${activeDm.peerName}...` : `message #${channelTitle}...`; + input.value = (session && session.chatDrafts.get(draftKey)) || ""; + input.addEventListener("input", () => { + if (session) session.chatDrafts.set(draftKey, input.value); + }); + const sendBtn = el("button", "channel-chat-send", "send"); + sendBtn.type = "submit"; + form.append(input, sendBtn); + + wrap.append(head, log, form); + queueMicrotask(() => { log.scrollTop = log.scrollHeight; }); + return wrap; + } + + function appendChatMessage(msg) { + if (!session || !session.snapshot) return; + + if (msg.scope === "private") { + const isFromSelf = msg.invokerId === session.selfId; + const peerId = isFromSelf ? Number(msg.targetId) : Number(msg.invokerId); + if (!peerId) return; + const peerName = isFromSelf ? findClientName(peerId) : (msg.invokerName || findClientName(peerId)); + const dm = ensureDmEntry(peerId, peerName); + dm.messages.push(msg); + if (dm.messages.length > MAX_CHAT_HISTORY) dm.messages.shift(); + + const viewingThisDm = session.activeChatTab + && session.activeChatTab.type === "dm" + && session.activeChatTab.peerId === peerId; + + if (viewingThisDm) { + dm.unread = 0; + const log = document.getElementById("voice-chat-log"); + if (log) { + const empty = log.querySelector(".channel-chat-empty"); + if (empty) empty.remove(); + log.append(buildChatMsgRow(msg, session.selfId)); + log.scrollTop = log.scrollHeight; + return; + } + } else if (!isFromSelf) { + dm.unread += 1; + } + render(); + return; + } + + const cid = msg.scope === "channel" && msg.targetId + ? String(msg.targetId) + : String(session.snapshot.self.channelId); + let list = session.chatByChannel.get(cid); + if (!list) { + list = []; + session.chatByChannel.set(cid, list); + } + list.push(msg); + if (list.length > MAX_CHAT_HISTORY) list.shift(); + + const viewingChannel = !session.activeChatTab || session.activeChatTab.type === "channel"; + if (cid !== String(session.snapshot.self.channelId) || !viewingChannel) return; + const log = document.getElementById("voice-chat-log"); + if (!log) return; + const empty = log.querySelector(".channel-chat-empty"); + if (empty) empty.remove(); + log.append(buildChatMsgRow(msg, session.selfId)); + log.scrollTop = log.scrollHeight; + } + + function render() { + if (!session || !session.snapshot) return; + const activeEl = document.activeElement; + const restoreChatFocus = Boolean(activeEl && activeEl.classList && activeEl.classList.contains("channel-chat-input")); + const selectionStart = restoreChatFocus ? activeEl.selectionStart : null; + const selectionEnd = restoreChatFocus ? activeEl.selectionEnd : null; + const activeVolClid = activeEl && activeEl.classList && activeEl.classList.contains("client-volume-slider") + ? activeEl.dataset.volClid + : null; + + const snap = session.snapshot; + const channels = sortChannels(snap.channels); + const realClients = snap.clients.filter((c) => c.type !== 1); + const byChannel = new Map(); + for (const c of realClients) { + if (!byChannel.has(c.channelId)) byChannel.set(c.channelId, []); + byChannel.get(c.channelId).push(c); + if (c.id !== snap.self.id && !session.userVolumes.has(c.id)) { + const vol = getUserVolumePercent(c.id, c.nickname); + session.userVolumes.set(c.id, vol); + if (session.playback) session.playback.setUserVolume(c.id, vol / 100); + } + if (session.dmByUser.has(c.id)) { + session.dmByUser.get(c.id).peerName = c.nickname; + } + } + + // Stats mirror build_context() in app.py. + let afk = 0; + let afkChannel = null; + for (const ch of snap.channels) if (ch.name.includes("AFK")) afkChannel = ch; + if (afkChannel) afk = Math.max(0, snap.clients.filter((c) => c.channelId === afkChannel.id).length - 1); + afk += realClients.filter((c) => c.away).length; + + const stats = el("div", "server-stats"); + stats.append(statItem("Users Connected", realClients.length), statItem("AFK Users", afk)); + + const list = el("div", "channel-list"); + for (const ch of channels) { + if (ch.name.includes("[Cspacer]") && !ch.name.includes("AFK")) continue; // spacer + const name = displayName(ch.name); + const members = byChannel.get(ch.id) || []; + const isCurrent = ch.id === snap.self.channelId; + + const article = el("article", "channel" + (isCurrent ? " is-current" : "")); + article.dataset.cid = ch.id; + + const header = el("header", "channel-header"); + const bg = (cfg.channelBanners || {})[name] || (cfg.genericImages || {}).background || ""; + if (bg) { + header.style.backgroundImage = + `linear-gradient(90deg, rgba(9, 18, 32, 0.96) 0%, rgba(9, 18, 32, 0.78) 45%, rgba(9, 18, 32, 0.94) 100%), url("${safeUrl(bg)}")`; + } + const title = el("div", "channel-title-group"); + const icon = (cfg.channelIcons || {})[name] || (cfg.genericImages || {}).icon || ""; + if (icon) { + const img = el("img", "channel-icon"); + img.src = safeUrl(icon); + img.alt = ""; + img.setAttribute("aria-hidden", "true"); + title.append(img); + } + title.append(el("h2", null, name)); + if (ch.hasPassword) title.append(el("span", "channel-lock", "pw")); + + const actions = el("div", "channel-actions"); + actions.append(el("span", "channel-meta", String(members.length))); + if (isCurrent) { + actions.append(el("span", "channel-here", "joined")); + } else { + const joinBtn = el("button", "channel-join", "join"); + joinBtn.type = "button"; + joinBtn.dataset.cid = ch.id; + joinBtn.dataset.name = name; + joinBtn.dataset.locked = ch.hasPassword ? "1" : ""; + joinBtn.setAttribute("aria-label", `Move to ${name}`); + actions.append(joinBtn); + } + header.append(title, actions); + + const ul = el("ul", "client-list"); + for (const c of members) { + const li = el("li", "client"); + li.dataset.clid = String(c.id); + const isSelf = c.id === snap.self.id; + if (isSelf) li.classList.add("is-self"); + if (session.talking.has(c.id)) li.classList.add("is-talking"); + + let roleIcon = ""; + for (const g of c.serverGroups) if (!roleIcon && cfg.roleIcons && cfg.roleIcons[g]) roleIcon = cfg.roleIcons[g]; + if (roleIcon) { + const img = el("img", "role-icon"); + img.src = safeUrl(roleIcon); + img.alt = "Role"; + li.append(img); + } else { + li.append(el("span", "client-no-role-icon")); + } + li.append(el("span", "client-name", c.nickname)); + if (isSelf) li.append(el("span", "client-you", "(you)")); + if (c.away) li.append(el("span", "client-afk", c.awayMessage ? `(AFK: ${c.awayMessage})` : "(AFK)")); + if (c.outputMuted) li.append(el("span", "client-muted", "(deafened)")); + else if (c.inputMuted) li.append(el("span", "client-muted", "(muted)")); + + if (!isSelf) { + const controls = el("div", "client-controls"); + + const volPct = getUserVolumePercent(c.id, c.nickname); + const volWrap = el("label", "client-volume"); + volWrap.title = `Volume for ${c.nickname}`; + const slider = el("input", "client-volume-slider"); + slider.type = "range"; + slider.min = "0"; + slider.max = "200"; + slider.step = "5"; + slider.value = String(volPct); + slider.dataset.volClid = String(c.id); + slider.dataset.volName = c.nickname; + slider.setAttribute("aria-label", `Volume for ${c.nickname}`); + const volVal = el("span", "client-volume-value", `${volPct}%`); + volWrap.append(slider, volVal); + + const dmEntry = session.dmByUser.get(c.id); + const unread = dmEntry ? dmEntry.unread : 0; + const isDmOpen = session.activeChatTab + && session.activeChatTab.type === "dm" + && session.activeChatTab.peerId === c.id; + const msgBtn = el( + "button", + "client-msg-btn" + (isDmOpen ? " is-active" : "") + (unread > 0 ? " has-unread" : ""), + unread > 0 ? `msg (${unread})` : "msg" + ); + msgBtn.type = "button"; + msgBtn.dataset.msgClid = String(c.id); + msgBtn.dataset.msgName = c.nickname; + msgBtn.title = `Message ${c.nickname}`; + + controls.append(volWrap, msgBtn); + li.append(controls); + } + ul.append(li); + } + article.append(header, ul); + if (isCurrent) { + article.append(buildChannelChat(ch.id, name)); + } + list.append(article); + } + + ui.container.replaceChildren(stats, list); + + if (restoreChatFocus) { + const nextInput = ui.container.querySelector(".channel-chat-input"); + if (nextInput) { + nextInput.focus(); + try { + if (selectionStart !== null && selectionEnd !== null) nextInput.setSelectionRange(selectionStart, selectionEnd); + } catch { /* ignore */ } + } + } else if (activeVolClid) { + const nextSlider = ui.container.querySelector(`.client-volume-slider[data-vol-clid="${activeVolClid}"]`); + if (nextSlider) nextSlider.focus(); + } + + const current = snap.channels.find((c) => c.id === snap.self.channelId); + ui.channel.textContent = current ? displayName(current.name) : "-"; + const me = snap.clients.find((c) => c.id === snap.self.id); + ui.self.textContent = me ? me.nickname : session.nickname; + updateTopbarSummary(); + } + + // ---- talking indicators ------------------------------------------------------- + + function setTalking(clientId, talking) { + if (!session) return; + const timers = session.talkTimers; + clearTimeout(timers.get(clientId)); + if (talking) { + timers.set(clientId, setTimeout(() => setTalking(clientId, false), TALK_TIMEOUT_MS)); + if (session.talking.has(clientId)) return; + session.talking.add(clientId); + } else { + timers.delete(clientId); + if (!session.talking.delete(clientId)) return; + } + for (const li of ui.container.querySelectorAll(`.client[data-clid="${clientId}"]`)) { + li.classList.toggle("is-talking", talking); + } + } + + // ---- playback (with per-user GainNode) ---------------------------------------- + + function createPlayback(ctx, output, getInitialGain) { + const speakers = new Map(); + + function stream(clientId) { + let s = speakers.get(clientId); + if (!s) { + const gain = ctx.createGain(); + gain.gain.value = getInitialGain ? getInitialGain(clientId) : 1; + gain.connect(output); + s = { playTime: 0, timestamp: 0, decoder: null, gain }; + speakers.set(clientId, s); + } + return s; + } + + function schedule(s, buffer) { + const now = ctx.currentTime; + if (s.playTime < now) s.playTime = now + START_LATENCY_S; + if (s.playTime > now + MAX_AHEAD_S) return; + const src = ctx.createBufferSource(); + src.buffer = buffer; + src.connect(s.gain); + src.onended = () => src.disconnect(); + src.start(s.playTime); + s.playTime += buffer.duration; + } + + function playPcm(clientId, bytes) { + const samples = new Int16Array(bytes.buffer, bytes.byteOffset, bytes.byteLength >> 1); + const buffer = ctx.createBuffer(1, samples.length, 48000); + const data = buffer.getChannelData(0); + for (let i = 0; i < samples.length; i++) data[i] = samples[i] / 32768; + schedule(stream(clientId), buffer); + } + + function playOpus(clientId, bytes) { + const s = stream(clientId); + if (!s.decoder || s.decoder.state === "closed") { + s.decoder = new AudioDecoder({ + output: (audio) => { + try { + const n = audio.numberOfFrames; + const buffer = ctx.createBuffer(1, n, audio.sampleRate); + const tmp = new Float32Array(n); + audio.copyTo(tmp, { planeIndex: 0, format: "f32-planar" }); + buffer.copyToChannel(tmp, 0); + schedule(s, buffer); + } catch { /* drop frame */ } finally { + audio.close(); + } + }, + error: () => { s.decoder = null; }, + }); + s.decoder.configure({ codec: "opus", sampleRate: 48000, numberOfChannels: 1 }); + s.timestamp = 0; + } + if (s.decoder.decodeQueueSize > 5) return; + s.decoder.decode(new EncodedAudioChunk({ type: "key", timestamp: s.timestamp, duration: 20000, data: bytes })); + s.timestamp += 20000; + } + + return { + play(kind, clientId, bytes) { + try { + if (kind === KIND_PCM) playPcm(clientId, bytes); + else playOpus(clientId, bytes); + } catch { /* ignore broken frames */ } + }, + setUserVolume(clientId, gainValue) { + const s = speakers.get(clientId); + if (s && s.gain) s.gain.gain.value = gainValue; + }, + close() { + for (const s of speakers.values()) { + try { s.decoder && s.decoder.close(); } catch { /* closed */ } + try { s.gain && s.gain.disconnect(); } catch { /* disconnected */ } + } + speakers.clear(); + }, + }; + } + + async function browserDecodesOpus() { + if (typeof AudioDecoder === "undefined" || typeof EncodedAudioChunk === "undefined") return false; + try { + const res = await AudioDecoder.isConfigSupported({ codec: "opus", sampleRate: 48000, numberOfChannels: 1 }); + return Boolean(res.supported); + } catch { + return false; + } + } + + // ---- microphone --------------------------------------------------------------- + + function gateOpen(s, rms) { + if (s.muted) return false; + if (mode === "ptt") return s.pttDown; + const now = performance.now(); + if (levelFromRms(rms) >= threshold()) s.vadUntil = now + VAD_HANG_MS; + return now < s.vadUntil; + } + + function onMicFrame(s, { samples, rms }) { + if (session !== s) return; + ui.meter.style.width = `${levelFromRms(rms) * 100}%`; + const open = s.ready && gateOpen(s, rms); + ui.meter.classList.toggle("is-open", open); + if (!open) { + if (s.wasTalking) { s.wasTalking = false; setTalking(s.selfId, false); } + return; + } + if (s.ws.readyState !== WebSocket.OPEN || s.ws.bufferedAmount > MAX_SEND_BUFFER) return; + const pcm = new Int16Array(samples.length); + for (let i = 0; i < samples.length; i++) { + const v = Math.max(-1, Math.min(1, samples[i])); + pcm[i] = v < 0 ? v * 0x8000 : v * 0x7fff; + } + s.ws.send(pcm.buffer); + s.wasTalking = true; + setTalking(s.selfId, true); + } + + // ---- session lifecycle -------------------------------------------------------- + + function setPhase(phase) { + document.body.classList.toggle("voice-connecting", phase === "connecting"); + document.body.classList.toggle("voice-live", phase === "live"); + ui.bar.hidden = phase !== "live"; + ui.submit.disabled = phase === "connecting"; + } + + async function startJoin(targetChannel = null) { + if (session) return; + const nickname = ui.nickname.value.trim(); + const password = ui.password.value; + if (nickname.length < 3 || !password) { + openLoginModal(targetChannel); + return; + } + if (!window.isSecureContext || !navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { + return say("Browser voice needs HTTPS (or localhost) for microphone access.", "error"); + } + + store.set(STORE.nickname, nickname); + store.set(STORE.password, password); + updateTopbarSummary(); + setText(ui.notice, ""); + + const s = { + nickname, target: targetChannel, ws: null, ctx: null, stream: null, worklet: null, playback: null, output: null, + ready: false, muted: false, deafened: false, pttDown: false, vadUntil: 0, wasTalking: false, + selfId: 0, snapshot: null, talking: new Set(), talkTimers: new Map(), + userVolumes: new Map(), + chatByChannel: new Map(), dmByUser: new Map(), activeChatTab: { type: "channel" }, chatDrafts: new Map(), + closed: false, + }; + session = s; + setPhase("connecting"); + say(`Requesting microphone to join ${targetChannel && targetChannel.name ? targetChannel.name : "voice"}…`); + + try { + // The AudioContext must be created inside the click handler's turn. + s.ctx = new AudioContext({ latencyHint: "interactive" }); + const resume = s.ctx.resume(); + s.stream = await navigator.mediaDevices.getUserMedia({ + audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true, channelCount: 1 }, + }); + await resume; + await s.ctx.audioWorklet.addModule("/static/mic-worklet.js"); + if (s.closed) return; + + const source = s.ctx.createMediaStreamSource(s.stream); + s.worklet = new AudioWorkletNode(s.ctx, "ts6-mic-capture", { numberOfInputs: 1, numberOfOutputs: 1, outputChannelCount: [1] }); + const sink = s.ctx.createGain(); + sink.gain.value = 0; + source.connect(s.worklet).connect(sink).connect(s.ctx.destination); + s.worklet.port.onmessage = (e) => onMicFrame(s, e.data); + + s.output = s.ctx.createGain(); + s.output.gain.value = Number(ui.volume.value) / 100; + s.output.connect(s.ctx.destination); + s.playback = createPlayback(s.ctx, s.output, (clientId) => { + const nick = findClientName(clientId, ""); + return getUserVolumePercent(clientId, nick) / 100; + }); + } catch (error) { + if (s.closed) return; + const denied = error && (error.name === "NotAllowedError" || error.name === "SecurityError"); + teardown(denied ? "Microphone access was denied." : `Could not start audio: ${error && error.message ? error.message : error}`, "error"); + return; + } + + const serverDecode = !(await browserDecodesOpus()); + if (s.closed) return; + say("Connecting to TeamSpeak…"); + + const proto = location.protocol === "https:" ? "wss:" : "ws:"; + const ws = new WebSocket(`${proto}//${location.host}${cfg.wsPath || "/ws/voice"}`); + ws.binaryType = "arraybuffer"; + s.ws = ws; + let fatalMessage = ""; + let fatalCode = ""; + + ws.onopen = () => { + ws.send(JSON.stringify({ + type: "join", + nickname, + password, + channelId: s.target && s.target.cid ? s.target.cid : undefined, + identity: store.get(STORE.identity) || undefined, + serverDecode, + })); + }; + + ws.onmessage = (e) => { + if (session !== s) return; + if (typeof e.data !== "string") { + const bytes = new Uint8Array(e.data); + if (bytes.length < 3) return; + const kind = bytes[0]; + const clientId = (bytes[1] << 8) | bytes[2]; + const payload = bytes.subarray(3); + if (payload.length === 0) return setTalking(clientId, false); + setTalking(clientId, true); + s.playback.play(kind, clientId, payload); + return; + } + let msg; + try { msg = JSON.parse(e.data); } catch { return; } + switch (msg.type) { + case "joined": + s.ready = true; + s.selfId = msg.self.id; + s.snapshot = msg; + if (msg.identity) store.set(STORE.identity, msg.identity); + store.set(STORE.nickname, nickname); + store.set(STORE.password, password); + updateTopbarSummary(); + closeLoginModal(); + setText(ui.message, ""); + setText(ui.dialogMessage, ""); + setPhase("live"); + setText(ui.barMessage, serverDecode ? "Compatibility audio mode (your browser lacks WebCodecs)." : ""); + render(); + break; + case "state": + s.snapshot = msg; + s.selfId = msg.self.id; + render(); + break; + case "chatMessage": + appendChatMessage(msg); + break; + case "error": + if (msg.fatal) { + fatalMessage = msg.message; + fatalCode = msg.code || ""; + break; + } + if (msg.code === "CHANNEL_PASSWORD" && msg.channelId) { + promptChannelPassword(msg.channelId); + break; + } + say(msg.message, "error"); + break; + case "disconnected": + fatalMessage = msg.reason || "Disconnected."; + break; + } + }; + + ws.onclose = (e) => { + if (session !== s) return; + let reason = fatalMessage; + if (!reason) { + if (s.ready) reason = e.code === 1006 ? "Connection to the voice gateway was lost." : "Disconnected."; + else reason = "Voice gateway unreachable, full, or this origin is not allowed."; + } + const targetBeforeClose = s.target; + teardown(reason, s.ready && !fatalMessage ? "info" : "error"); + if (fatalCode === "BAD_PASSWORD" || fatalCode === "NICKNAME_INVALID" || fatalCode === "NICKNAME_IN_USE") { + openLoginModal(targetBeforeClose, reason); + } + }; + } + + function teardown(message, kind = "info") { + const s = session; + if (!s) return; + s.closed = true; + const wasReady = s.ready; + session = null; + for (const t of s.talkTimers.values()) clearTimeout(t); + if (s.ws) { + s.ws.onclose = null; + try { + if (s.ws.readyState === WebSocket.OPEN) s.ws.send(JSON.stringify({ type: "leave" })); + s.ws.close(1000); + } catch { /* closed */ } + } + if (s.stream) for (const track of s.stream.getTracks()) track.stop(); + if (s.worklet) s.worklet.port.onmessage = null; + if (s.playback) s.playback.close(); + if (s.ctx) s.ctx.close().catch(() => undefined); + + setPhase("idle"); + updateTopbarSummary(); + ui.meter.style.width = "0%"; + ui.settings.hidden = true; + ui.settingsToggle.setAttribute("aria-expanded", "false"); + setMuteUi(false); + setDeafenUi(false); + setText(ui.barMessage, ""); + if (!wasReady && ui.dialog.open) setText(ui.dialogMessage, message, kind); + else if (!wasReady) setText(ui.message, message, kind); + else setText(ui.notice, message, kind); + if (wasReady && typeof window.refreshDashboard === "function") window.refreshDashboard(); + } + + // ---- channel moves, per-user volume & chat ------------------------------------ + + function sendJson(message) { + if (session && session.ready && session.ws.readyState === WebSocket.OPEN) session.ws.send(JSON.stringify(message)); + } + + function channelName(cid) { + const ch = session && session.snapshot && session.snapshot.channels.find((c) => c.id === cid); + return ch ? displayName(ch.name) : "this channel"; + } + + function promptChannelPassword(cid) { + const password = window.prompt(`Password for ${channelName(cid)}`); + if (password === null) return; + sendJson({ type: "move", channelId: cid, password }); + } + + ui.container.addEventListener("input", (e) => { + const slider = e.target.closest(".client-volume-slider"); + if (!slider) return; + const clid = Number(slider.dataset.volClid); + const name = slider.dataset.volName || ""; + const pct = setUserVolumePercent(clid, name, slider.value); + const label = slider.parentElement && slider.parentElement.querySelector(".client-volume-value"); + if (label) label.textContent = `${pct}%`; + }); + + ui.container.addEventListener("click", (e) => { + const msgBtn = e.target.closest(".client-msg-btn"); + if (msgBtn) { + const clid = Number(msgBtn.dataset.msgClid); + const name = msgBtn.dataset.msgName || ""; + if (clid) openDmConversation(clid, name); + return; + } + + const tabBtn = e.target.closest(".channel-chat-tab"); + if (tabBtn && session && session.ready) { + if (tabBtn.dataset.chatTab === "dm") { + const peerId = Number(tabBtn.dataset.dmPeer); + if (peerId) openDmConversation(peerId); + } else { + session.activeChatTab = { type: "channel" }; + render(); + const input = ui.container.querySelector(".channel-chat-input"); + if (input) input.focus(); + } + return; + } + + const button = e.target.closest(".channel-join"); + if (!button) return; + const cid = button.dataset.cid; + if (!session) return void startJoin({ cid, name: button.dataset.name || "" }); + if (!session.ready) return; + setText(ui.barMessage, ""); + if (button.dataset.locked) return promptChannelPassword(cid); + sendJson({ type: "move", channelId: cid }); + }); + + ui.container.addEventListener("submit", (e) => { + const form = e.target.closest(".channel-chat-form"); + if (!form || !session || !session.ready) return; + e.preventDefault(); + const input = form.querySelector(".channel-chat-input"); + if (!input) return; + const text = input.value.trim(); + if (!text) return; + const scope = form.dataset.chatScope || "channel"; + if (scope === "private") { + const targetId = Number(form.dataset.chatTargetId); + if (!targetId) return; + input.value = ""; + session.chatDrafts.set(`dm:${targetId}`, ""); + sendJson({ type: "chat", scope: "private", targetId, message: text }); + } else { + const cid = form.dataset.chatCid; + input.value = ""; + session.chatDrafts.set(`channel:${cid}`, ""); + sendJson({ type: "chat", scope: "channel", message: text }); + } + }); + + // ---- controls ----------------------------------------------------------------- + + function setMuteUi(on) { + ui.mute.setAttribute("aria-pressed", String(on)); + ui.mute.textContent = on ? "unmute" : "mute"; + } + + function setDeafenUi(on) { + ui.deafen.setAttribute("aria-pressed", String(on)); + ui.deafen.textContent = on ? "undeafen" : "deafen"; + } + + ui.form.addEventListener("submit", (e) => { + e.preventDefault(); + const nickname = ui.nickname.value.trim(); + const password = ui.password.value; + if (nickname.length < 3) return setText(ui.dialogMessage, "Nickname must be 3-30 characters.", "error"); + if (!password) return setText(ui.dialogMessage, "The server password is required.", "error"); + store.set(STORE.nickname, nickname); + store.set(STORE.password, password); + updateTopbarSummary(); + const targetToJoin = pendingJoin; + closeLoginModal(); + if (targetToJoin !== undefined && !session) { + void startJoin(targetToJoin); + } + }); + + ui.leave.addEventListener("click", () => teardown("You left voice.")); + + ui.mute.addEventListener("click", () => { + if (!session) return; + session.muted = !session.muted; + setMuteUi(session.muted); + sendJson({ type: "mute", muted: session.muted }); + }); + + ui.deafen.addEventListener("click", () => { + if (!session) return; + session.deafened = !session.deafened; + setDeafenUi(session.deafened); + if (session.output) session.output.gain.value = session.deafened ? 0 : Number(ui.volume.value) / 100; + sendJson({ type: "deafen", deafened: session.deafened }); + }); + + ui.settingsToggle.addEventListener("click", () => { + const open = ui.settings.hidden; + ui.settings.hidden = !open; + ui.settingsToggle.setAttribute("aria-expanded", String(open)); + }); + + ui.mode.addEventListener("click", () => { + mode = mode === "ptt" ? "vad" : "ptt"; + store.set(STORE.mode, mode); + applyModeUi(); + }); + + ui.sensitivity.addEventListener("input", () => { + store.set(STORE.sensitivity, ui.sensitivity.value); + updateThresholdMark(); + }); + + ui.volume.addEventListener("input", () => { + store.set(STORE.volume, ui.volume.value); + if (session && session.output && !session.deafened) session.output.gain.value = Number(ui.volume.value) / 100; + }); + + const setPtt = (down) => { + if (!session) return; + session.pttDown = down; + ui.ptt.classList.toggle("is-active", down); + }; + ui.ptt.addEventListener("pointerdown", (e) => { e.preventDefault(); setPtt(true); }); + for (const ev of ["pointerup", "pointerleave", "pointercancel"]) ui.ptt.addEventListener(ev, () => setPtt(false)); + + const typingTarget = (t) => t && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName)); + document.addEventListener("keydown", (e) => { + if (e.code === PTT_KEY && mode === "ptt" && session && !e.repeat && !typingTarget(e.target)) setPtt(true); + }); + document.addEventListener("keyup", (e) => { if (e.code === PTT_KEY) setPtt(false); }); + window.addEventListener("blur", () => setPtt(false)); + + window.addEventListener("pagehide", () => { if (session) teardown(""); }); +})(); @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 5 requires-python = ">=3.13" [[package]] @@ -211,6 +211,15 @@ wheels = [ ] [[package]] +name = "python-dotenv" +version = "1.2.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/74/26/2fbeedb218a787a5eea551c7532cac4e009f83d689dd2faa0d0353473f86/python_dotenv-1.2.4.tar.gz", hash = "sha256:f0d53e69935a851c0dcc78f3ab7aaccd8cabef0b92382b576b824212902873c0", size = 60824, upload-time = "2026-10-01T05:36:10Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/d1/38f3a3405989a89ac18390803e70c6ad7c7760da4f9b83cbeca0c44a0c72/python_dotenv-1.2.4-py3-none-any.whl", hash = "sha256:42269a8a5b3fd54ffa6f3d84b18abed50064717576b4ecf03dc4a55d8aa04fdc", size = 23266, upload-time = "2026-10-01T05:36:08.633Z" }, +] + +[[package]] name = "requests" version = "2.34.2" source = { registry = "https://pypi.org/simple" } @@ -232,6 +241,7 @@ source = { virtual = "." } dependencies = [ { name = "flask" }, { name = "flask-cors" }, + { name = "python-dotenv" }, { name = "requests" }, ] @@ -239,6 +249,7 @@ dependencies = [ requires-dist = [ { name = "flask", specifier = ">=3.1.3" }, { name = "flask-cors", specifier = ">=6.0.5" }, + { name = "python-dotenv", specifier = ">=1.0.1" }, { name = "requests", specifier = ">=2.34.2" }, ] |
