From 5819f32d139a4cfc172b03a8e647af1445f7223b Mon Sep 17 00:00:00 2001 From: Ray Zhang Date: Fri, 14 Aug 2026 22:13:53 -0400 Subject: [PATCH] add plugin Discord Voice --- discord-voice/README.md | 73 ++ discord-voice/bar.luau | 143 +++ discord-voice/discord_bridge.py | 1411 ++++++++++++++++++++++++++++ discord-voice/panel.luau | 614 ++++++++++++ discord-voice/plugin.toml | 54 ++ discord-voice/service.luau | 118 +++ discord-voice/thumbnail.webp | Bin 0 -> 40540 bytes discord-voice/translations/en.json | 79 ++ 8 files changed, 2492 insertions(+) create mode 100644 discord-voice/README.md create mode 100644 discord-voice/bar.luau create mode 100644 discord-voice/discord_bridge.py create mode 100644 discord-voice/panel.luau create mode 100644 discord-voice/plugin.toml create mode 100644 discord-voice/service.luau create mode 100644 discord-voice/thumbnail.webp create mode 100644 discord-voice/translations/en.json diff --git a/discord-voice/README.md b/discord-voice/README.md new file mode 100644 index 00000000..e6e52618 --- /dev/null +++ b/discord-voice/README.md @@ -0,0 +1,73 @@ +# Discord Voice + +Monitor and control a local Discord desktop voice session without leaving Noctalia. The bar widget and panel expose voice state and controls through Discord's local RPC interface. + +## Plugin + +| Field | Value | +| --- | --- | +| ID | `raycursive/discord-voice` | +| Entries | Bar widget: `bar`; panel: `panel`; service: `bridge` | + +## Requirements + +- Noctalia with plugin API 9 or newer. +- `python3` 3.10 or newer on `PATH` for the local Discord RPC bridge. +- The `discord` desktop client. The default executable is `/usr/bin/discord`; change **Discord executable** if yours is elsewhere. + +Discord must be running locally. The bridge detects native, Flatpak, Snap, and common temporary Discord IPC socket locations; the Discord web app alone is not sufficient. + +## Usage + +Enable `raycursive/discord-voice` in **Settings → Plugins**, then add the `bar` entry from **Settings → Bar → Widgets**. + +1. Click the bar widget to open the `panel` entry. If Discord is not running, the first click launches the configured Discord executable. +2. Select **Authorize Discord** and accept Discord's local consent prompt. +3. Use the panel to mute, deafen, hang up, adjust your input volume from 0–100%, or adjust another participant's listening volume from 0–200%. +4. Select saved recent or favorite channels to rejoin them in Discord. + +The `bridge` service starts automatically when the plugin is enabled and keeps the bar widget and panel synchronized. Open the panel without the bar widget with: + +```sh +noctalia msg panel-toggle raycursive/discord-voice:panel +``` + +## Settings + +| Setting | Type | Default | Description | +| --- | --- | --- | --- | +| `discord_binary` | `file` | `/usr/bin/discord` | Executable used to launch Discord and open channel deep links. | +| `glyph` | `glyph` | `brand-discord` | Glyph used by this bar-widget instance when no participant avatar is shown. | +| `show_channel_name` | `bool` | `true` | Shows the active voice-channel name beside the glyph. | +| `hide_when_disconnected` | `bool` | `false` | Hides the bar widget while no voice channel is connected. | + +## IPC + +The `bridge` service accepts these events: + +```sh +# Start Discord's local authorization flow. +noctalia msg plugin raycursive/discord-voice:bridge all authorize + +# Refresh current voice and channel state. +noctalia msg plugin raycursive/discord-voice:bridge all refresh + +# Leave the current voice channel. +noctalia msg plugin raycursive/discord-voice:bridge all hangup + +# Write a token-free state summary to the Noctalia log. +noctalia msg plugin raycursive/discord-voice:bridge all dump +``` + +## Notes + +- The plugin requests only the Discord OAuth scopes `rpc`, `rpc.voice.read`, and `rpc.voice.write`. It does not request message, notification, relationship, or guild-member scopes. +- Authorization sends the code to `https://streamkit.discord.com/overlay/token`. Participant avatars are downloaded from `https://cdn.discordapp.com`; no downloaded content is executed. +- The Python bridge connects to Discord and to its own control channel over local Unix sockets. Noctalia spawns the bridge, and the bar or panel may spawn the configured Discord executable. +- The access token is stored at `$NOCTALIA_STATE_HOME/noctalia/discord-voice/token.json`, or under `$XDG_STATE_HOME` when the Noctalia override is unset. Recent and favorite channel metadata is stored beside it in `channels.json`. The directory uses mode `0700` and both files use mode `0600`. +- Participant avatars are cached under `$XDG_CACHE_HOME/noctalia/discord-voice/avatars/`. The bridge's control socket is created under `$XDG_RUNTIME_DIR`, with a per-user `/tmp` fallback, and removed on a clean shutdown. +- Discord's StreamKit token exchange does not provide a refresh token. Authorize again after the access token expires. +- Voice levels changed through Discord RPC belong to the active integration; Discord may restore previous levels when the bridge disconnects. +- The bridge reconnects after Discord restarts. The integration depends on Discord's public StreamKit identity and token broker, which Discord may change. + +The authorization design was from [`PandorasFox/dms-discord-widget`](https://github.com/PandorasFox/dms-discord-widget). diff --git a/discord-voice/bar.luau b/discord-voice/bar.luau new file mode 100644 index 00000000..0d88c14d --- /dev/null +++ b/discord-voice/bar.luau @@ -0,0 +1,143 @@ +--!nonstrict + +local PANEL_ID = "raycursive/discord-voice:panel" +local snapshot = noctalia.state.get("voice_snapshot") or { + status = "starting", + status_message = "", + channel = nil, + recent_channels = {}, + favorite_channels = {}, + participants = {}, + voice = { mute = false, deaf = false, input_volume = 100 }, + connection = { state = "DISCONNECTED", average_ping = 0 }, + connected_since = nil, +} + +local tr = noctalia.tr + +local function shortened(text, limit) + local length = utf8.len(text) + if length <= limit then + return text + end + local cut = utf8.offset(text, limit + 1) + return text:sub(1, cut - 1) .. "…" +end + +local function activeSpeaker() + for _, participant in ipairs(snapshot.participants) do + if participant.speaking then + return participant + end + end + return nil +end + +local function statusLabel() + if snapshot.status == "auth_required" then + return tr("bar.authorize") + elseif snapshot.status == "authorizing" or snapshot.status == "authenticating" then + return tr("bar.authorizing") + elseif snapshot.status == "discord_unavailable" then + return tr("bar.offline") + elseif snapshot.status == "error" then + return "!" + end + return "" +end + +local function render() + local disconnected = snapshot.channel == nil + if disconnected and noctalia.getConfig("hide_when_disconnected") == true then + barWidget.setVisible(false) + return + end + barWidget.setVisible(true) + + local glyph = noctalia.getConfig("glyph") + if type(glyph) ~= "string" or glyph == "" then + glyph = "brand-discord" + end + + local children = {} + local voice = snapshot.voice + local speaker = activeSpeaker() + local glyphColor = "on_surface_variant" + if speaker ~= nil then + glyphColor = "primary" + elseif not disconnected then + glyphColor = (voice.mute or voice.deaf) and "error" or "primary" + elseif snapshot.status == "error" then + glyphColor = "error" + end + if speaker and speaker.avatar_path ~= "" then + table.insert(children, ui.image({ + key = "speaker:" .. speaker.id, + path = speaker.avatar_path, + width = 20, + height = 20, + radius = 10, + fit = "cover", + border = "primary", + borderWidth = 2, + })) + elseif speaker then + table.insert(children, ui.glyph({ name = "volume", size = 16, color = glyphColor })) + else + table.insert(children, ui.glyph({ name = glyph, size = 15, color = glyphColor })) + end + + if disconnected then + local label = statusLabel() + if label ~= "" then + table.insert(children, ui.label({ text = label, color = "on_surface_variant", fontSize = 12 })) + end + else + if noctalia.getConfig("show_channel_name") ~= false then + table.insert(children, ui.label({ + text = shortened(snapshot.channel.name, 18), + color = "on_surface", + fontWeight = "bold", + })) + end + table.insert(children, ui.label({ + text = tostring(#snapshot.participants), + color = "on_surface_variant", + fontSize = 12, + })) + end + + local container = barWidget.isVertical() and ui.column or ui.row + barWidget.render(container({ gap = 6, align = "center", paddingH = 4, paddingV = 2 }, children)) + barWidget.setTooltip({}) +end + +noctalia.state.watch("voice_snapshot", function(value) + if type(value) == "table" then + snapshot = value + render() + end +end) + +noctalia.setUpdateInterval(1000) +render() + +function update() + render() +end + +function onClick() + if snapshot.status == "discord_unavailable" then + local binary = noctalia.getConfig("discord_binary") + if type(binary) ~= "string" or binary == "" then + binary = "/usr/bin/discord" + end + noctalia.runAsync("'" .. binary:gsub("'", "'\\''") .. "'") + return + end + noctalia.togglePanel(PANEL_ID) +end + +function onConfigChanged() + render() +end diff --git a/discord-voice/discord_bridge.py b/discord-voice/discord_bridge.py new file mode 100644 index 00000000..5a8c294b --- /dev/null +++ b/discord-voice/discord_bridge.py @@ -0,0 +1,1411 @@ +#!/usr/bin/env python3 +"""Discord desktop RPC bridge for the Noctalia Discord Voice plugin. + +The daemon connects to Discord's local IPC socket and writes state snapshots as +JSON lines to stdout. Short-lived ``command`` invocations talk to the daemon's +private control socket, which lets Noctalia buttons control the persistent RPC +session without exposing the OAuth token to Luau or process arguments. + +Python 3.10+ standard library only. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import math +import os +import signal +import socket +import struct +import sys +import tempfile +import time +import urllib.request +from pathlib import Path +from typing import Any, TypedDict + + +STREAMKIT_CLIENT_ID = "207646673902501888" +OAUTH_SCOPES = ("rpc", "rpc.voice.read", "rpc.voice.write") +TOKEN_EXCHANGE_URL = "https://streamkit.discord.com/overlay/token" +HTTP_USER_AGENT = "Noctalia-Discord-Voice" + +OP_HANDSHAKE = 0 +OP_FRAME = 1 +OP_CLOSE = 2 +OP_PING = 3 +OP_PONG = 4 + +RECONNECT_SECONDS = 3 +HEARTBEAT_SECONDS = 5 +MAX_AVATAR_BYTES = 2 * 1024 * 1024 +MAX_RPC_PAYLOAD_BYTES = 8 * 1024 * 1024 +MAX_RECENT_CHANNELS = 5 +MAX_FAVORITE_CHANNELS = 5 +VOICE_STATE_EVENTS = ( + "VOICE_STATE_CREATE", + "VOICE_STATE_UPDATE", + "VOICE_STATE_DELETE", +) +SPEAKING_EVENTS = ("SPEAKING_START", "SPEAKING_STOP") + + +class VoiceSettings(TypedDict): + mute: bool + deaf: bool + input_volume: int + + +def normalized_volume(value: Any, default: int, maximum: int) -> int: + """Return a finite Discord volume value clamped to its documented range.""" + + try: + number = float(value) + except (TypeError, ValueError, OverflowError): + return default + if not math.isfinite(number): + return default + return max(0, min(maximum, int(round(number)))) + + +logging.basicConfig( + level=logging.INFO, + format="[noctalia-discord-voice] %(levelname)s: %(message)s", + stream=sys.stderr, +) +log = logging.getLogger("discord-voice") + + +def _xdg_dir(name: str, fallback: str) -> Path: + value = os.environ.get(name) + return Path(value).expanduser() if value else Path(fallback).expanduser() + + +def control_socket_path() -> Path: + runtime = os.environ.get("XDG_RUNTIME_DIR") + if runtime: + return Path(runtime) / "noctalia-discord-voice.sock" + return Path("/tmp") / f"noctalia-discord-voice-{os.getuid()}.sock" + + +def state_directory() -> Path: + root = os.environ.get("NOCTALIA_STATE_HOME") + base = ( + Path(root).expanduser() + if root + else _xdg_dir("XDG_STATE_HOME", "~/.local/state") + ) + return base / "noctalia" / "discord-voice" + + +def avatar_directory() -> Path: + return ( + _xdg_dir("XDG_CACHE_HOME", "~/.cache") + / "noctalia" + / "discord-voice" + / "avatars" + ) + + +def write_private_json(path: Path, payload: dict[str, Any]) -> None: + """Atomically write JSON with private directory and file permissions.""" + + path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + try: + os.chmod(path.parent, 0o700) + except OSError: + pass + + descriptor, temporary_name = tempfile.mkstemp( + prefix=f".{path.name}.", suffix=".tmp", dir=path.parent + ) + temporary = Path(temporary_name) + try: + with os.fdopen(descriptor, "w", encoding="utf-8") as handle: + json.dump(payload, handle) + os.replace(temporary, path) + os.chmod(path, 0o600) + finally: + temporary.unlink(missing_ok=True) + + +class TokenStore: + """Small 0600 token store. Tokens are never included in bridge output.""" + + def __init__(self) -> None: + self.path = state_directory() / "token.json" + + def load(self) -> str | None: + try: + payload = json.loads(self.path.read_text(encoding="utf-8")) + token = payload.get("access_token") + return token if isinstance(token, str) and token else None + except (FileNotFoundError, json.JSONDecodeError, OSError): + return None + + def save(self, token: str) -> None: + write_private_json(self.path, {"access_token": token}) + + def clear(self) -> None: + try: + self.path.unlink() + except FileNotFoundError: + pass + except OSError as error: + log.warning("Could not remove expired Discord token: %s", error) + + +class ChannelStore: + """Private local history for recent and favorite voice channels.""" + + def __init__(self) -> None: + self.path = state_directory() / "channels.json" + + @staticmethod + def _channel(value: Any) -> dict[str, Any] | None: + if not isinstance(value, dict) or not value.get("id"): + return None + try: + last_joined_time = int(value.get("last_joined_time") or 0) + except (TypeError, ValueError, OverflowError): + last_joined_time = 0 + return { + "id": str(value.get("id")), + "name": str(value.get("name") or "Voice channel"), + "guild_id": str(value.get("guild_id") or ""), + "guild_name": str(value.get("guild_name") or ""), + "last_joined_time": last_joined_time, + } + + @classmethod + def _channels(cls, value: Any, limit: int) -> list[dict[str, Any]]: + # Version 0.1 stored a single object at ``recent``. Accept it so users + # keep their saved channel when upgrading to the five-item history. + values = value if isinstance(value, list) else [value] + channels: list[dict[str, Any]] = [] + seen: set[str] = set() + for item in values: + channel = cls._channel(item) + if channel and channel["id"] not in seen: + channels.append(channel) + seen.add(channel["id"]) + if len(channels) == limit: + break + return channels + + def load(self) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + try: + payload = json.loads(self.path.read_text(encoding="utf-8")) + except (FileNotFoundError, json.JSONDecodeError, OSError): + return [], [] + if not isinstance(payload, dict): + return [], [] + favorites = payload.get("favorites") + if not isinstance(favorites, list): + # Migrate the original single pinned channel in place. + favorites = [payload.get("pinned")] + recent_channels = self._channels(payload.get("recent"), MAX_RECENT_CHANNELS) + favorite_channels = self._channels(favorites, MAX_FAVORITE_CHANNELS) + + # Older state has recency encoded only by array position. Backfill a + # stable timestamp rank so favorites shared with the recent list keep + # the same last-joined ordering after the new activity sort is applied. + known_times = [ + channel["last_joined_time"] + for channel in (*recent_channels, *favorite_channels) + ] + try: + state_mtime = int(self.path.stat().st_mtime) + except OSError: + state_mtime = 0 + migration_base = max(state_mtime, *known_times) + migrated = False + recent_times: dict[str, int] = {} + for index, channel in enumerate(recent_channels): + if channel["last_joined_time"] <= 0: + channel["last_joined_time"] = migration_base - index + migrated = True + recent_times[channel["id"]] = channel["last_joined_time"] + for index, channel in enumerate(favorite_channels): + recent_time = recent_times.get(channel["id"]) + if ( + recent_time is not None + and channel["last_joined_time"] != recent_time + ): + channel["last_joined_time"] = recent_time + migrated = True + elif channel["last_joined_time"] <= 0: + channel["last_joined_time"] = ( + migration_base - len(recent_channels) - index + ) + migrated = True + + if migrated: + self.save(recent_channels, favorite_channels) + return recent_channels, favorite_channels + + def save( + self, recent: list[dict[str, Any]], favorites: list[dict[str, Any]] + ) -> None: + payload = { + "recent": self._channels(recent, MAX_RECENT_CHANNELS), + "favorites": self._channels(favorites, MAX_FAVORITE_CHANNELS), + } + write_private_json(self.path, payload) + + +class DiscordIPC: + """Binary-framed connection to Discord's documented local RPC server.""" + + def __init__(self) -> None: + self.reader: asyncio.StreamReader | None = None + self.writer: asyncio.StreamWriter | None = None + self._write_lock = asyncio.Lock() + + @staticmethod + def candidate_paths() -> list[Path]: + directories: list[Path] = [] + runtime = os.environ.get("XDG_RUNTIME_DIR") + if runtime: + runtime_path = Path(runtime) + directories.append(runtime_path) + directories.append(runtime_path / "app" / "com.discordapp.Discord") + + snap_data = os.environ.get("SNAP_USER_DATA") + if snap_data: + directories.append(Path(snap_data) / ".config") + + for variable in ("TMPDIR", "TMP", "TEMP"): + value = os.environ.get(variable) + if value: + directories.append(Path(value)) + directories.append(Path("/tmp")) + + paths: list[Path] = [] + seen: set[Path] = set() + for directory in directories: + for index in range(10): + path = directory / f"discord-ipc-{index}" + if path not in seen: + seen.add(path) + paths.append(path) + return paths + + @property + def connected(self) -> bool: + return self.writer is not None and not self.writer.is_closing() + + async def connect(self) -> Path | None: + for path in self.candidate_paths(): + if not path.exists(): + continue + try: + self.reader, self.writer = await asyncio.open_unix_connection(path) + return path + except OSError: + continue + return None + + async def send(self, opcode: int, payload: dict[str, Any]) -> None: + if not self.writer: + raise ConnectionError("Discord IPC is not connected") + encoded = json.dumps(payload, separators=(",", ":")).encode("utf-8") + frame = struct.pack(" tuple[int, dict[str, Any]]: + if not self.reader: + raise ConnectionError("Discord IPC is not connected") + header = await self.reader.readexactly(8) + opcode, length = struct.unpack(" MAX_RPC_PAYLOAD_BYTES: + raise ValueError(f"Discord RPC payload is too large: {length} bytes") + encoded = await self.reader.readexactly(length) + payload = json.loads(encoded.decode("utf-8")) + if not isinstance(payload, dict): + raise ValueError("Discord returned a non-object payload") + return opcode, payload + + async def handshake(self) -> dict[str, Any]: + await self.send(OP_HANDSHAKE, {"v": 1, "client_id": STREAMKIT_CLIENT_ID}) + opcode, payload = await self.receive() + if opcode == OP_CLOSE: + raise ConnectionError("Discord rejected the StreamKit handshake") + if payload.get("evt") != "READY": + raise ConnectionError("Discord did not return READY") + return payload + + async def close(self) -> None: + writer = self.writer + self.reader = None + self.writer = None + if writer: + writer.close() + try: + await writer.wait_closed() + except OSError: + pass + + +def exchange_authorization_code(code: str) -> str: + body = json.dumps({"code": code}).encode("utf-8") + request = urllib.request.Request( + TOKEN_EXCHANGE_URL, + data=body, + headers={ + "Content-Type": "application/json", + "User-Agent": HTTP_USER_AGENT, + }, + method="POST", + ) + with urllib.request.urlopen(request, timeout=15) as response: + payload = json.loads(response.read().decode("utf-8")) + token = payload.get("access_token") + if not isinstance(token, str) or not token: + raise ValueError("StreamKit returned no access token") + return token + + +def download_avatar(user_id: str, avatar_hash: str, destination: Path) -> Path: + destination.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + try: + os.chmod(destination.parent, 0o700) + except OSError: + pass + url = f"https://cdn.discordapp.com/avatars/{user_id}/{avatar_hash}.png?size=64" + request = urllib.request.Request(url, headers={"User-Agent": HTTP_USER_AGENT}) + with urllib.request.urlopen(request, timeout=10) as response: + content = response.read(MAX_AVATAR_BYTES + 1) + if len(content) > MAX_AVATAR_BYTES: + raise ValueError("Discord avatar exceeded the size limit") + descriptor, temporary_name = tempfile.mkstemp( + prefix=f".{destination.name}.", suffix=".tmp", dir=destination.parent + ) + temporary = Path(temporary_name) + try: + with os.fdopen(descriptor, "wb") as handle: + handle.write(content) + os.replace(temporary, destination) + finally: + temporary.unlink(missing_ok=True) + return destination + + +class DiscordVoiceBridge: + def __init__(self) -> None: + self.ipc = DiscordIPC() + self.tokens = TokenStore() + self.channels = ChannelStore() + self.control_path = control_socket_path() + self.control_server: asyncio.AbstractServer | None = None + self.stop_event = asyncio.Event() + + self.status = "starting" + self.status_message = "" + self.authenticated = False + self.current_user: dict[str, Any] = {} + self.channel: dict[str, Any] | None = None + self.recent_channels, self.favorite_channels = self.channels.load() + self.participants: dict[str, dict[str, Any]] = {} + self.voice_settings: VoiceSettings = { + "mute": False, + "deaf": False, + "input_volume": 100, + } + self.connection: dict[str, Any] = { + "state": "DISCONNECTED", + "hostname": "", + "average_ping": 0, + "last_ping": 0, + } + self.connected_since: int | None = None + self.revision = 0 + self.pending: dict[str, dict[str, Any]] = {} + self.next_nonce = 0 + self.active_token: str | None = None + self.avatar_tasks: dict[str, asyncio.Task[None]] = {} + self.saved_channel_counts: dict[str, int] = {} + self.channel_event_subscriptions: dict[str, set[str]] = {} + + async def run(self) -> None: + loop = asyncio.get_running_loop() + for sig in (signal.SIGINT, signal.SIGTERM): + try: + loop.add_signal_handler(sig, self.stop_event.set) + except NotImplementedError: + pass + + await self._start_control_server() + supervisor = asyncio.create_task(self._supervise_discord()) + heartbeat = asyncio.create_task(self._heartbeat()) + self._emit_snapshot() + + await self.stop_event.wait() + supervisor.cancel() + heartbeat.cancel() + await asyncio.gather(supervisor, heartbeat, return_exceptions=True) + for task in self.avatar_tasks.values(): + task.cancel() + await asyncio.gather(*self.avatar_tasks.values(), return_exceptions=True) + await self.ipc.close() + if self.control_server: + self.control_server.close() + await self.control_server.wait_closed() + try: + self.control_path.unlink() + except FileNotFoundError: + pass + + def _write_event(self, payload: dict[str, Any]) -> None: + try: + sys.stdout.write(json.dumps(payload, separators=(",", ":")) + "\n") + sys.stdout.flush() + except (BrokenPipeError, OSError): + self.stop_event.set() + + def _emit_snapshot(self) -> None: + self.revision += 1 + participants: list[dict[str, Any]] = [] + for participant in self.participants.values(): + participants.append( + { + key: value + for key, value in participant.items() + if key != "avatar_hash" + } + ) + participants.sort( + key=lambda item: ( + item["nick"].casefold(), + item["id"], + ) + ) + + self._write_event( + { + "type": "snapshot", + "revision": self.revision, + "status": self.status, + "status_message": self.status_message, + "authenticated": self.authenticated, + "user": self.current_user, + "channel": self.channel, + "recent_channels": self._sorted_saved_channels(self.recent_channels), + "favorite_channels": self._sorted_saved_channels( + self.favorite_channels + ), + "participants": participants, + "voice": self.voice_settings, + "connection": self.connection, + "connected_since": self.connected_since, + } + ) + + def _sorted_saved_channels( + self, channels: list[dict[str, Any]] + ) -> list[dict[str, Any]]: + decorated: list[dict[str, Any]] = [] + for channel in channels: + entry = dict(channel) + entry["participant_count"] = max( + 0, self.saved_channel_counts.get(channel["id"], 0) + ) + decorated.append(entry) + return sorted( + decorated, + key=lambda entry: ( + entry["participant_count"] > 0, + entry["last_joined_time"], + ), + reverse=True, + ) + + def _set_status(self, status: str, message: str = "") -> None: + changed = status != self.status or message != self.status_message + self.status = status + self.status_message = message + if changed: + self._emit_snapshot() + + async def _heartbeat(self) -> None: + while not self.stop_event.is_set(): + await asyncio.sleep(HEARTBEAT_SECONDS) + self._write_event({"type": "heartbeat", "time": int(time.time())}) + + async def _start_control_server(self) -> None: + self.control_path.parent.mkdir(parents=True, exist_ok=True) + for attempt in range(51): + if not self.control_path.exists(): + break + probe = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + probe.settimeout(0.1) + occupied = False + try: + probe.connect(str(self.control_path)) + occupied = True + except OSError: + self.control_path.unlink(missing_ok=True) + break + finally: + probe.close() + if occupied and attempt < 50: + # Noctalia can start a replacement service just before the old + # runStream process has finished cleaning up during hot reload. + await asyncio.sleep(0.1) + continue + raise RuntimeError(f"another bridge owns {self.control_path}") + + self.control_server = await asyncio.start_unix_server( + self._handle_control_client, path=str(self.control_path) + ) + os.chmod(self.control_path, 0o600) + log.info("Control socket ready at %s", self.control_path) + + async def _handle_control_client( + self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter + ) -> None: + response: dict[str, Any] + try: + encoded = await asyncio.wait_for(reader.readline(), timeout=3) + request = json.loads(encoded.decode("utf-8")) + response = await self._handle_control_command(request) + except Exception as error: + response = {"ok": False, "error": str(error)} + + writer.write( + json.dumps(response, separators=(",", ":")).encode("utf-8") + b"\n" + ) + try: + await writer.drain() + finally: + writer.close() + try: + await writer.wait_closed() + except OSError: + pass + + def _saved_channel(self, channel_id: str) -> dict[str, Any] | None: + return next( + ( + channel + for channel in (*self.favorite_channels, *self.recent_channels) + if channel["id"] == channel_id + ), + None, + ) + + async def _save_channel_lists(self) -> None: + self.channels.save(self.recent_channels, self.favorite_channels) + await self._sync_channel_subscriptions() + self._emit_snapshot() + + async def _favorite_channel(self, channel_id: str) -> dict[str, Any]: + channel = ( + self.channel + if self.channel and self.channel["id"] == channel_id + else self._saved_channel(channel_id) + ) + if channel is None: + return {"ok": False, "error": "Channel is no longer available"} + + self.favorite_channels = [ + dict(channel), + *[ + favorite + for favorite in self.favorite_channels + if favorite["id"] != channel_id + ], + ][:MAX_FAVORITE_CHANNELS] + await self._save_channel_lists() + return {"ok": True} + + async def _unfavorite_channel(self, channel_id: str) -> dict[str, Any]: + remaining = [ + channel for channel in self.favorite_channels if channel["id"] != channel_id + ] + if len(remaining) == len(self.favorite_channels): + return {"ok": False, "error": "Favorite channel is no longer available"} + self.favorite_channels = remaining + await self._save_channel_lists() + return {"ok": True} + + async def _join_channel(self, channel_id: str) -> dict[str, Any]: + if self._saved_channel(channel_id) is None: + return {"ok": False, "error": "Saved channel is no longer available"} + await self._request( + "SELECT_VOICE_CHANNEL", + {"channel_id": channel_id}, + kind="join_channel", + ) + return {"ok": True} + + async def _handle_control_command(self, request: dict[str, Any]) -> dict[str, Any]: + command = request["command"] + if command == "status": + sorted_favorites = self._sorted_saved_channels(self.favorite_channels) + sorted_recents = self._sorted_saved_channels(self.recent_channels) + return { + "ok": True, + "status": self.status, + "authenticated": self.authenticated, + "in_voice": self.channel is not None, + "channel": self.channel["name"] if self.channel else "", + "participant_count": len(self.participants), + "speaking_count": sum( + 1 + for participant in self.participants.values() + if participant["speaking"] + ), + "connection_state": self.connection["state"], + "average_ping": self.connection["average_ping"], + "saved_channel": (sorted_favorites[0] if sorted_favorites else None) + or (sorted_recents[0] if sorted_recents else None), + } + if command == "authorize": + await self._begin_authorization() + return {"ok": True} + if command == "refresh": + self.status_message = "" + if self.authenticated: + await self._request( + "GET_SELECTED_VOICE_CHANNEL", kind="selected_channel" + ) + await self._request("GET_VOICE_SETTINGS", kind="voice_settings") + await self._refresh_saved_channel_counts() + return {"ok": True} + if not self.authenticated: + return {"ok": False, "error": "Authorize Discord first"} + if command in ("favorite_channel", "unfavorite_channel", "join_channel"): + channel_id = request["value"] + if command == "favorite_channel": + return await self._favorite_channel(channel_id) + if command == "unfavorite_channel": + return await self._unfavorite_channel(channel_id) + return await self._join_channel(channel_id) + if command == "set_mute": + await self._request( + "SET_VOICE_SETTINGS", + {"mute": request["value"]}, + kind="voice_settings", + ) + return {"ok": True} + if command == "set_deaf": + await self._request( + "SET_VOICE_SETTINGS", + {"deaf": request["value"]}, + kind="voice_settings", + ) + return {"ok": True} + if command == "set_mic_volume": + volume = request["value"] + previous_volume = self.voice_settings["input_volume"] + self.voice_settings["input_volume"] = volume + self.status_message = "" + self._emit_snapshot() + await self._request( + "SET_VOICE_SETTINGS", + {"input": {"volume": volume}}, + kind="mic_volume", + meta={ + "previous_volume": previous_volume, + "target_volume": volume, + }, + ) + return {"ok": True} + if command == "set_user_volume": + user_id = request["user_id"] + if not self.channel or user_id not in self.participants: + return { + "ok": False, + "error": "Participant is no longer in this channel", + } + if user_id == self.current_user["id"]: + return { + "ok": False, + "error": "Use microphone volume to change your own input level", + } + volume = request["value"] + participant = self.participants[user_id] + previous_volume = participant["volume"] + participant["volume"] = volume + self.status_message = "" + self._emit_snapshot() + await self._request( + "SET_USER_VOICE_SETTINGS", + {"user_id": user_id, "volume": volume}, + kind="user_voice_settings", + meta={ + "user_id": user_id, + "previous_volume": previous_volume, + "target_volume": volume, + }, + ) + return {"ok": True} + if command == "hang_up": + await self._request( + "SELECT_VOICE_CHANNEL", + {"channel_id": None}, + kind="hang_up", + ) + return {"ok": True} + return {"ok": False, "error": f"unknown command: {command}"} + + async def _supervise_discord(self) -> None: + while not self.stop_event.is_set(): + try: + await self._discord_session() + except asyncio.CancelledError: + raise + except Exception as error: + log.warning("Discord session ended: %s", error) + self._set_status("discord_unavailable", str(error)) + finally: + await self.ipc.close() + self._reset_session_state() + + if not self.stop_event.is_set(): + await asyncio.sleep(RECONNECT_SECONDS) + + async def _discord_session(self) -> None: + self._set_status("connecting") + path = await self.ipc.connect() + if not path: + raise ConnectionError("Discord desktop is not running") + await self.ipc.handshake() + log.info("Connected to Discord IPC at %s", path) + + token = self.tokens.load() + if token: + self.active_token = token + self._set_status("authenticating") + await self._request( + "AUTHENTICATE", {"access_token": token}, kind="authenticate" + ) + else: + self._set_status("auth_required") + + while not self.stop_event.is_set(): + opcode, payload = await self.ipc.receive() + if opcode == OP_CLOSE: + raise ConnectionError("Discord closed the RPC connection") + if opcode == OP_PING: + await self.ipc.send(OP_PONG, payload) + continue + if opcode == OP_PONG: + continue + await self._handle_discord_payload(payload) + + def _reset_session_state(self) -> None: + self.pending.clear() + self.channel_event_subscriptions.clear() + self.saved_channel_counts.clear() + self.authenticated = False + self.current_user = {} + self.channel = None + self.participants.clear() + self.voice_settings = {"mute": False, "deaf": False, "input_volume": 100} + self.connection = { + "state": "DISCONNECTED", + "hostname": "", + "average_ping": 0, + "last_ping": 0, + } + self.connected_since = None + if self.status not in ("discord_unavailable", "connecting"): + self.status = "discord_unavailable" + self._emit_snapshot() + + async def _request( + self, + command: str, + args: dict[str, Any] | None = None, + *, + kind: str, + meta: dict[str, Any] | None = None, + event: str | None = None, + ) -> str: + if not self.ipc.connected: + raise ConnectionError("Discord IPC is not connected") + self.next_nonce += 1 + nonce = str(self.next_nonce) + payload: dict[str, Any] = {"cmd": command, "nonce": nonce} + if args is not None: + payload["args"] = args + if event is not None: + payload["evt"] = event + self.pending[nonce] = {"kind": kind, "meta": meta or {}} + await self.ipc.send(OP_FRAME, payload) + return nonce + + async def _subscribe( + self, + event: str, + args: dict[str, Any] | None = None, + *, + meta: dict[str, Any] | None = None, + ) -> None: + await self._request("SUBSCRIBE", args, kind="subscribe", meta=meta, event=event) + + async def _unsubscribe(self, event: str, args: dict[str, Any]) -> None: + try: + await self._request("UNSUBSCRIBE", args, kind="unsubscribe", event=event) + except ConnectionError: + pass + + async def _begin_authorization(self) -> None: + if not self.ipc.connected: + raise ConnectionError("Start Discord desktop before authorizing") + if any(item["kind"] == "authorize" for item in self.pending.values()): + return + self._set_status("authorizing") + await self._request( + "AUTHORIZE", + { + "client_id": STREAMKIT_CLIENT_ID, + "scopes": list(OAUTH_SCOPES), + "prompt": "none", + }, + kind="authorize", + ) + + async def _handle_discord_payload(self, payload: dict[str, Any]) -> None: + nonce = payload.get("nonce") + event = payload.get("evt") + + if event == "ERROR": + pending = self.pending.pop(str(nonce), None) if nonce is not None else None + message = str(payload.get("data", {}).get("message") or "Discord RPC error") + kind = pending["kind"] if pending else "" + if kind == "authenticate": + self.tokens.clear() + self.active_token = None + self.authenticated = False + self._set_status( + "auth_required", "Authorization expired; authorize Discord again" + ) + elif kind == "authorize": + self._set_status("auth_required", message) + elif kind == "saved_channel": + channel_id = pending["meta"]["channel_id"] + self.saved_channel_counts[channel_id] = 0 + self._emit_snapshot() + elif kind == "mic_volume": + meta = pending["meta"] + target = meta["target_volume"] + if self.voice_settings["input_volume"] == target: + self.voice_settings["input_volume"] = meta["previous_volume"] + self.status_message = message + self._emit_snapshot() + elif kind == "user_voice_settings": + meta = pending["meta"] + user_id = meta["user_id"] + participant = self.participants.get(user_id) + target = meta["target_volume"] + if participant and participant["volume"] == target: + participant["volume"] = meta["previous_volume"] + self.status_message = message + self._emit_snapshot() + else: + self.status_message = message + self._emit_snapshot() + return + + if nonce is not None and str(nonce) in self.pending: + pending = self.pending.pop(str(nonce)) + await self._handle_response(pending, payload.get("data")) + return + + if payload.get("cmd") == "DISPATCH" and isinstance(event, str): + data = payload.get("data") + await self._handle_dispatch(event, data if isinstance(data, dict) else {}) + + async def _handle_response(self, pending: dict[str, Any], data: Any) -> None: + kind = pending["kind"] + response = data if isinstance(data, dict) else {} + + if kind == "authorize": + code = response.get("code") + if not isinstance(code, str) or not code: + self._set_status( + "auth_required", "Discord authorization was not completed" + ) + return + try: + token = await asyncio.to_thread(exchange_authorization_code, code) + except Exception as error: + self._set_status( + "auth_required", f"StreamKit token exchange failed: {error}" + ) + return + self.active_token = token + self._set_status("authenticating") + await self._request( + "AUTHENTICATE", {"access_token": token}, kind="authenticate" + ) + return + + if kind == "authenticate": + user = ( + response.get("user") if isinstance(response.get("user"), dict) else {} + ) + self.current_user = { + "id": str(user.get("id") or ""), + "username": str(user.get("global_name") or user.get("username") or ""), + } + self.authenticated = True + if self.active_token: + self.tokens.save(self.active_token) + self._set_status("ready") + await self._subscribe_global_events() + await self._request("GET_SELECTED_VOICE_CHANNEL", kind="selected_channel") + await self._request("GET_VOICE_SETTINGS", kind="voice_settings") + return + + if kind == "selected_channel": + if response.get("id"): + await self._enter_channel(response) + else: + await self._leave_channel() + return + + if kind == "saved_channel": + channel_id = pending["meta"]["channel_id"] + voice_states = response.get("voice_states") + users: set[str] = set() + if isinstance(voice_states, list): + for state in voice_states: + if not isinstance(state, dict): + continue + user = state.get("user") + user_id = str( + (user.get("id") if isinstance(user, dict) else "") + or state.get("user_id") + or "" + ) + if user_id: + users.add(user_id) + self.saved_channel_counts[channel_id] = len(users) + self._emit_snapshot() + return + + if kind == "guild": + guild_id = pending["meta"]["guild_id"] + if self.channel and self.channel["guild_id"] == guild_id: + self.channel["guild_name"] = str(response.get("name") or "") + self._remember_current_channel() + self._emit_snapshot() + return + + if kind in ("voice_settings", "mic_volume"): + input_settings = ( + response.get("input") if isinstance(response.get("input"), dict) else {} + ) + self.voice_settings = { + "mute": bool( + response.get("mute", self.voice_settings["mute"]) + ), + "deaf": bool( + response.get("deaf", self.voice_settings["deaf"]) + ), + "input_volume": normalized_volume( + input_settings.get("volume"), + self.voice_settings["input_volume"], + 100, + ), + } + self.status_message = "" + self._emit_snapshot() + return + + if kind == "user_voice_settings": + user_id = response.get("user_id") or pending["meta"]["user_id"] + participant = self.participants.get(user_id) + if participant: + participant["volume"] = normalized_volume( + response.get("volume"), participant["volume"], 200 + ) + if "mute" in response: + participant["local_mute"] = bool(response["mute"]) + self.status_message = "" + self._emit_snapshot() + return + + if kind == "hang_up": + if not response: + await self._leave_channel() + return + + if kind == "join_channel": + if response.get("id"): + await self._enter_channel(response) + else: + await self._request( + "GET_SELECTED_VOICE_CHANNEL", kind="selected_channel" + ) + + async def _subscribe_global_events(self) -> None: + for event in ( + "VOICE_CHANNEL_SELECT", + "VOICE_SETTINGS_UPDATE", + "VOICE_CONNECTION_STATUS", + ): + await self._subscribe(event) + + def _saved_channel_ids(self) -> list[str]: + channel_ids: list[str] = [] + seen: set[str] = set() + for channel in (*self.favorite_channels, *self.recent_channels): + channel_id = channel["id"] + if channel_id not in seen: + seen.add(channel_id) + channel_ids.append(channel_id) + return channel_ids + + async def _subscribe_channel_event(self, channel_id: str, event: str) -> None: + await self._subscribe( + event, + {"channel_id": channel_id}, + meta={"channel_id": channel_id}, + ) + self.channel_event_subscriptions.setdefault(channel_id, set()).add(event) + + async def _unsubscribe_channel_event(self, channel_id: str, event: str) -> None: + await self._unsubscribe(event, {"channel_id": channel_id}) + subscribed = self.channel_event_subscriptions.get(channel_id) + if subscribed is None: + return + subscribed.discard(event) + if not subscribed: + self.channel_event_subscriptions.pop(channel_id, None) + + async def _sync_channel_subscriptions(self) -> None: + if not self.authenticated: + return + desired: dict[str, set[str]] = {} + if self.channel: + desired[self.channel["id"]] = set((*VOICE_STATE_EVENTS, *SPEAKING_EVENTS)) + else: + for channel_id in self._saved_channel_ids(): + desired[channel_id] = {"VOICE_STATE_CREATE", "VOICE_STATE_DELETE"} + + for channel_id, events in list(self.channel_event_subscriptions.items()): + for event in events - desired.get(channel_id, set()): + await self._unsubscribe_channel_event(channel_id, event) + for channel_id, events in desired.items(): + subscribed = self.channel_event_subscriptions.get(channel_id, set()) + for event in events - subscribed: + await self._subscribe_channel_event(channel_id, event) + + await self._refresh_saved_channel_counts() + + async def _refresh_saved_channel_counts(self) -> None: + if not self.authenticated or self.channel is not None: + return + pending_ids = { + item["meta"]["channel_id"] + for item in self.pending.values() + if item["kind"] == "saved_channel" + } + for channel_id in self._saved_channel_ids(): + if channel_id not in pending_ids: + await self._request( + "GET_CHANNEL", + {"channel_id": channel_id}, + kind="saved_channel", + meta={"channel_id": channel_id}, + ) + + async def _enter_channel(self, channel_data: dict[str, Any]) -> None: + channel_id = str(channel_data.get("id") or "") + if not channel_id: + return + previous_id = self.channel["id"] if self.channel else "" + previous_last_joined = ( + self.channel["last_joined_time"] if self.channel else 0 + ) + + guild_id = str(channel_data.get("guild_id") or "") + known_guild_name = "" + for known in (self.channel, *self.favorite_channels, *self.recent_channels): + if known and known["guild_id"] == guild_id: + known_guild_name = known["guild_name"] + if known_guild_name: + break + self.channel = { + "id": channel_id, + "name": str(channel_data.get("name") or "Voice channel"), + "guild_id": guild_id, + "guild_name": str(channel_data.get("guild_name") or known_guild_name), + "last_joined_time": ( + int(time.time()) if previous_id != channel_id else previous_last_joined + ), + } + if previous_id != channel_id: + self.connected_since = int(time.time()) + self.participants.clear() + voice_states = channel_data.get("voice_states") + if isinstance(voice_states, list): + for state in voice_states: + if isinstance(state, dict): + self._upsert_participant(state) + + self._remember_current_channel() + + await self._sync_channel_subscriptions() + if guild_id: + await self._request( + "GET_GUILD", + {"guild_id": guild_id}, + kind="guild", + meta={"guild_id": guild_id}, + ) + await self._request("GET_VOICE_SETTINGS", kind="voice_settings") + self._emit_snapshot() + + def _remember_current_channel(self) -> None: + if not self.channel: + return + channel_id = self.channel["id"] + self.recent_channels = [ + dict(self.channel), + *[ + channel + for channel in self.recent_channels + if channel["id"] != channel_id + ], + ][:MAX_RECENT_CHANNELS] + self.favorite_channels = [ + dict(self.channel) if channel["id"] == channel_id else channel + for channel in self.favorite_channels + ] + self.channels.save(self.recent_channels, self.favorite_channels) + + async def _leave_channel(self) -> None: + self.channel = None + self.participants.clear() + self.connected_since = None + self.connection = { + "state": "DISCONNECTED", + "hostname": "", + "average_ping": 0, + "last_ping": 0, + } + await self._sync_channel_subscriptions() + self._emit_snapshot() + + def _upsert_participant(self, data: dict[str, Any]) -> None: + user = data.get("user") if isinstance(data.get("user"), dict) else {} + voice = ( + data.get("voice_state") if isinstance(data.get("voice_state"), dict) else {} + ) + user_id = str(user.get("id") or data.get("user_id") or "") + if not user_id: + return + + existing = self.participants.get(user_id, {}) + avatar_hash = str(user.get("avatar") or existing.get("avatar_hash") or "") + username = str( + user.get("global_name") + or user.get("username") + or existing.get("username") + or "Unknown" + ) + participant = { + "id": user_id, + "username": username, + "nick": str(data.get("nick") or existing.get("nick") or username), + "avatar_hash": avatar_hash, + "avatar_path": str(existing.get("avatar_path") or ""), + "mute": bool(voice.get("mute", existing.get("mute", False))), + "self_mute": bool(voice.get("self_mute", existing.get("self_mute", False))), + "deaf": bool(voice.get("deaf", existing.get("deaf", False))), + "self_deaf": bool(voice.get("self_deaf", existing.get("self_deaf", False))), + "volume": normalized_volume( + data.get("volume"), int(existing.get("volume", 100)), 200 + ), + "local_mute": bool(data.get("mute", existing.get("local_mute", False))), + "speaking": bool(existing.get("speaking", False)), + } + self.participants[user_id] = participant + self._ensure_avatar(user_id, avatar_hash) + + def _ensure_avatar(self, user_id: str, avatar_hash: str) -> None: + if not avatar_hash: + return + destination = avatar_directory() / f"{user_id}-{avatar_hash}.png" + participant = self.participants[user_id] + if destination.exists(): + participant["avatar_path"] = str(destination) + return + key = f"{user_id}:{avatar_hash}" + if key in self.avatar_tasks: + return + + async def hydrate() -> None: + try: + path = await asyncio.to_thread( + download_avatar, user_id, avatar_hash, destination + ) + current = self.participants.get(user_id) + if current and current.get("avatar_hash") == avatar_hash: + current["avatar_path"] = str(path) + self._emit_snapshot() + except Exception as error: + log.info("Could not cache avatar for user %s: %s", user_id, error) + finally: + self.avatar_tasks.pop(key, None) + + self.avatar_tasks[key] = asyncio.create_task(hydrate()) + + async def _handle_dispatch(self, event: str, data: dict[str, Any]) -> None: + if event == "VOICE_CHANNEL_SELECT": + if data.get("channel_id"): + await self._request( + "GET_SELECTED_VOICE_CHANNEL", kind="selected_channel" + ) + else: + await self._leave_channel() + return + + if event == "VOICE_SETTINGS_UPDATE": + input_settings = ( + data.get("input") if isinstance(data.get("input"), dict) else {} + ) + self.voice_settings = { + "mute": bool(data.get("mute", self.voice_settings["mute"])), + "deaf": bool(data.get("deaf", self.voice_settings["deaf"])), + "input_volume": normalized_volume( + input_settings.get("volume"), + self.voice_settings["input_volume"], + 100, + ), + } + self._emit_snapshot() + return + + if event == "VOICE_CONNECTION_STATUS": + self.connection = { + "state": str(data.get("state") or "DISCONNECTED"), + "hostname": str(data.get("hostname") or ""), + "average_ping": int(float(data.get("average_ping") or 0)), + "last_ping": int(float(data.get("last_ping") or 0)), + } + self._emit_snapshot() + return + + if event in ("VOICE_STATE_CREATE", "VOICE_STATE_UPDATE"): + if self.channel: + self._upsert_participant(data) + self._emit_snapshot() + else: + await self._refresh_saved_channel_counts() + return + + if event == "VOICE_STATE_DELETE": + if self.channel: + user = data.get("user") if isinstance(data.get("user"), dict) else {} + user_id = str(user.get("id") or data.get("user_id") or "") + if user_id: + self.participants.pop(user_id, None) + self._emit_snapshot() + else: + await self._refresh_saved_channel_counts() + return + + if event in ("SPEAKING_START", "SPEAKING_STOP"): + user_id = str(data.get("user_id") or "") + participant = self.participants.get(user_id) + if participant: + participant["speaking"] = event == "SPEAKING_START" + self._emit_snapshot() + + +async def send_control_command( + command: str, value: Any = None, user_id: str | None = None +) -> dict[str, Any]: + request: dict[str, Any] = {"command": command} + if value is not None: + request["value"] = value + if user_id is not None: + request["user_id"] = user_id + reader, writer = await asyncio.wait_for( + asyncio.open_unix_connection(str(control_socket_path())), timeout=3 + ) + writer.write(json.dumps(request, separators=(",", ":")).encode("utf-8") + b"\n") + await writer.drain() + encoded = await asyncio.wait_for(reader.readline(), timeout=5) + writer.close() + try: + await writer.wait_closed() + except OSError: + pass + return json.loads(encoded.decode("utf-8")) + + +def main() -> int: + if len(sys.argv) < 2 or sys.argv[1] not in ("daemon", "command"): + print( + "usage: discord_bridge.py daemon | command [value] [volume]", + file=sys.stderr, + ) + return 2 + + if sys.argv[1] == "daemon": + try: + asyncio.run(DiscordVoiceBridge().run()) + return 0 + except Exception as error: + log.error("Bridge failed: %s", error) + return 1 + + if len(sys.argv) < 3: + print(json.dumps({"ok": False, "error": "missing command"})) + return 2 + + command = sys.argv[2].replace("-", "_") + + try: + user_id: str | None = None + if command in ("set_mute", "set_deaf"): + value: Any = sys.argv[3] == "true" + elif command == "set_mic_volume": + value = int(sys.argv[3]) + elif command == "set_user_volume": + user_id = sys.argv[3] + value = int(sys.argv[4]) + elif command in ("favorite_channel", "unfavorite_channel", "join_channel"): + value = sys.argv[3] + else: + value = None + except (IndexError, ValueError) as error: + detail = ( + "missing required value" if isinstance(error, IndexError) else str(error) + ) + print( + json.dumps( + { + "ok": False, + "error": f"invalid arguments for {sys.argv[2]}: {detail}", + }, + separators=(",", ":"), + ) + ) + return 2 + + try: + response = asyncio.run(send_control_command(command, value, user_id)) + except Exception as error: + response = { + "ok": False, + "error": f"Discord voice bridge is unavailable: {error}", + } + print(json.dumps(response, separators=(",", ":"))) + return 0 if response.get("ok") else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/discord-voice/panel.luau b/discord-voice/panel.luau new file mode 100644 index 00000000..39e2c373 --- /dev/null +++ b/discord-voice/panel.luau @@ -0,0 +1,614 @@ +--!nonstrict + +local snapshot = noctalia.state.get("voice_snapshot") or { + status = "starting", + status_message = "", + authenticated = false, + user = {}, + channel = nil, + recent_channels = {}, + favorite_channels = {}, + participants = {}, + voice = { mute = false, deaf = false, input_volume = 100 }, + connection = { state = "DISCONNECTED", average_ping = 0, last_ping = 0, hostname = "" }, + connected_since = nil, +} +local isOpen = false +local actionPending = false +local actionError = "" + +local render +local ACTION_BUTTON_WIDTH = 112 +local CENTERED_LAYOUT = { flexGrow = 1, align = "center", justify = "center", gap = 10, padding = 20 } +local micVolumeDraft = snapshot.voice.input_volume +local micVolumeEditing = false +local participantVolumeDraft = {} +local participantVolumeEditing = {} + +local tr = noctalia.tr + +local function quoteShellArgument(value) + return "'" .. value:gsub("'", "'\\''") .. "'" +end + +local function discordBinary() + local configured = noctalia.getConfig("discord_binary") + if type(configured) == "string" and configured ~= "" then + return configured + end + return "/usr/bin/discord" +end + +local function openDiscord() + local binary = quoteShellArgument(discordBinary()) + local channel = snapshot.channel + if channel == nil then + noctalia.runAsync(binary) + return + end + local guildId = channel.guild_id + local channelId = channel.id + if guildId ~= "" and channelId ~= "" then + local uri = `discord://-/channels/{guildId}/{channelId}` + noctalia.runAsync(binary .. " --url -- " .. quoteShellArgument(uri)) + else + noctalia.runAsync(binary) + end +end + +local function durationText() + local since = snapshot.connected_since + if since == nil then + return "—" + end + local total = math.max(0, (tonumber(noctalia.formatTime("%s")) or 0) - since) + local hours = math.floor(total / 3600) + local minutes = math.floor((total % 3600) / 60) + local seconds = math.floor(total % 60) + if hours > 0 then + return string.format("%d:%02d:%02d", hours, minutes, seconds) + end + return string.format("%d:%02d", minutes, seconds) +end + +local function appendCommandArgument(command, value) + if value == nil then + return command + end + if type(value) == "boolean" then + return command .. (value and " true" or " false") + end + return command .. " " .. quoteShellArgument(tostring(value)) +end + +local function runBridgeCommand(name, value, secondValue) + if actionPending then + return + end + actionPending = true + actionError = "" + render() + + local script = noctalia.pluginDir() .. "/discord_bridge.py" + local command = "python3 " .. quoteShellArgument(script) .. " command " .. name + command = appendCommandArgument(command, value) + command = appendCommandArgument(command, secondValue) + local started = noctalia.runAsync(command, function(result) + actionPending = false + local decoded = noctalia.json.decode(result.stdout or "") + if result.exitCode ~= 0 then + if type(decoded) == "table" and type(decoded.error) == "string" then + actionError = decoded.error + else + local message = noctalia.string.trim(result.stderr or ""):match("^[^\r\n]+") or "" + actionError = message ~= "" and message or tr("error.command") + end + end + render() + end) + if not started then + actionPending = false + actionError = tr("error.command") + render() + end +end + +local function roundedVolume(value, maximum) + return math.max(0, math.min(maximum, math.floor(value + 0.5))) +end + +local function syncVolumeDrafts() + if not micVolumeEditing then + micVolumeDraft = roundedVolume(snapshot.voice.input_volume, 100) + end + local present = {} + for _, participant in ipairs(snapshot.participants) do + local userId = participant.id + present[userId] = true + if not participantVolumeEditing[userId] then + participantVolumeDraft[userId] = roundedVolume(participant.volume, 200) + end + end + for userId, _ in pairs(participantVolumeDraft) do + if not present[userId] then + participantVolumeDraft[userId] = nil + participantVolumeEditing[userId] = nil + end + end +end + +local function changeMicVolume(value) + micVolumeEditing = true + micVolumeDraft = roundedVolume(value, 100) + render() +end + +local function commitMicVolume() + micVolumeEditing = false + runBridgeCommand("set-mic-volume", micVolumeDraft) +end + +local function changeParticipantVolume(userId, value) + participantVolumeEditing[userId] = true + participantVolumeDraft[userId] = roundedVolume(value, 200) + render() +end + +local function commitParticipantVolume(userId) + local volume = participantVolumeDraft[userId] + participantVolumeEditing[userId] = false + runBridgeCommand("set-user-volume", userId, volume) +end + +local function connectionAppearance() + local connection = snapshot.connection + local state = connection.state + local ping = connection.average_ping + if state == "VOICE_CONNECTED" then + if ping <= 0 then + return "wifi", "primary", tr("connection.connected") + elseif ping <= 60 then + return "wifi", "primary", tr("connection.ping", { ping = math.floor(ping) }) + elseif ping <= 120 then + return "wifi", "tertiary", tr("connection.ping", { ping = math.floor(ping) }) + end + return "wifi", "error", tr("connection.ping", { ping = math.floor(ping) }) + elseif state == "NO_ROUTE" then + return "wifi-off", "error", tr("connection.no_route") + elseif state == "DISCONNECTED" or state == "VOICE_DISCONNECTED" then + return "wifi-off", "on_surface_variant", tr("connection.disconnected") + end + return "loader-2", "tertiary", tr("connection.connecting") +end + +local function participantVisual(participant) + local speaking = participant.speaking + if participant.avatar_path ~= "" then + return ui.image({ + key = "avatar:" .. participant.id, + path = participant.avatar_path, + width = 36, + height = 36, + radius = 18, + fit = "cover", + border = speaking and "primary" or "outline", + borderWidth = speaking and 2 or 1, + }) + end + return ui.glyph({ + key = "avatar-fallback:" .. participant.id, + name = "user-circle", + size = 36, + color = speaking and "primary" or "on_surface_variant", + }) +end + +local function participantRow(participant) + local speaking = participant.speaking + local displayName = participant.nick + if participant.id == snapshot.user.id then + displayName ..= " " .. tr("participant.you") + end + local isSelf = participant.id == snapshot.user.id + local muted = participant.mute or participant.self_mute or participant.local_mute + local deafened = participant.deaf or participant.self_deaf + + local indicators = {} + if speaking then + table.insert(indicators, ui.glyph({ name = "volume", size = 15, color = "primary" })) + end + if muted then + table.insert(indicators, ui.glyph({ name = "microphone-off", size = 15, color = "error" })) + end + if deafened then + table.insert(indicators, ui.glyph({ name = "headphones-off", size = 15, color = "error" })) + end + + local participantDetails = { + ui.row({ align = "center", gap = 6 }, { + ui.label({ + text = displayName, + color = speaking and "primary" or "on_surface", + fontWeight = speaking and "bold" or "medium", + flexGrow = 1, + maxLines = 1, + }), + ui.label({ + text = speaking and tr("participant.speaking") or tr("participant.listening"), + color = "on_surface_variant", + fontSize = 10, + }), + ui.row({ gap = 4, align = "center" }, indicators), + }), + } + + local userId = participant.id + local volume = isSelf + and roundedVolume(micVolumeDraft, 100) + or (participantVolumeDraft[userId] or roundedVolume(participant.volume, 200)) + local maximum = isSelf and 100 or 200 + local volumeGlyph = isSelf + and (volume == 0 and "microphone-off" or "microphone") + or (volume == 0 and "volume-off" or "volume") + table.insert(participantDetails, ui.row({ gap = 6, align = "center" }, { + ui.glyph({ name = volumeGlyph, size = 13, color = "on_surface_variant" }), + ui.slider({ + key = "participant-volume:" .. userId, + min = 0, + max = maximum, + step = 1, + value = volume, + enabled = not actionPending, + flexGrow = 1, + onChange = function(value) + if isSelf then + changeMicVolume(value) + else + changeParticipantVolume(userId, value) + end + end, + onDragEnd = function() + if isSelf then + commitMicVolume() + else + commitParticipantVolume(userId) + end + end, + }), + ui.label({ text = tostring(volume) .. "%", width = 38, fontSize = 10, color = "on_surface_variant", textAlign = "end" }), + })) + + return ui.row({ + key = "participant:" .. participant.id, + gap = 10, + align = "center", + paddingH = 10, + paddingV = 6, + fill = speaking and "primary/0.10" or "surface_variant/0.24", + border = speaking and "primary/0.55" or "outline/0.32", + borderWidth = 1, + radius = 11, + }, { + participantVisual(participant), + ui.column({ gap = 1, flexGrow = 1, align = "stretch" }, participantDetails), + }) +end + +local function participantList() + local rows = {} + for _, participant in ipairs(snapshot.participants) do + table.insert(rows, participantRow(participant)) + end + if #rows == 0 then + table.insert(rows, ui.column({ paddingV = 20, align = "center" }, { + ui.label({ + text = tr("participants.empty"), + color = "on_surface_variant", + textAlign = "center", + }), + })) + end + return rows +end + +local function recentChannelRow(channel, favorite) + local serverName = channel.guild_name ~= "" and channel.guild_name or tr("unknown_server") + local participantCount = channel.participant_count + local children = { + ui.button({ + glyph = favorite and "star-filled" or "star", + variant = "ghost", + selected = favorite, + enabled = not actionPending, + onClick = function() + runBridgeCommand(favorite and "unfavorite-channel" or "favorite-channel", channel.id) + end, + }), + ui.label({ text = serverName, width = 120, fontWeight = "bold", color = "primary", maxLines = 1 }), + ui.label({ text = "/", color = "on_surface_variant" }), + ui.label({ text = channel.name, color = "on_surface_variant", flexGrow = 1, maxLines = 1 }), + } + if participantCount > 0 then + table.insert(children, ui.row({ gap = 4, align = "center" }, { + ui.glyph({ name = "users", size = 14, color = "primary" }), + ui.label({ text = tostring(participantCount), fontSize = 12, fontWeight = "bold", color = "primary" }), + })) + end + table.insert(children, ui.button({ + text = tr("saved.join"), + glyph = "phone", + variant = "primary", + width = ACTION_BUTTON_WIDTH, + enabled = not actionPending, + onClick = function() + runBridgeCommand("join-channel", channel.id) + end, + })) + return ui.row({ + key = "recent:" .. channel.id, + fill = "surface_variant/0.26", + border = "outline/0.34", + borderWidth = 1, + radius = 10, + paddingH = 10, + paddingV = 8, + gap = 7, + align = "center", + }, children) +end + +local function recentChannelRows() + local rows = {} + local favoriteIds = {} + + for _, channel in ipairs(snapshot.favorite_channels) do + favoriteIds[channel.id] = true + table.insert(rows, recentChannelRow(channel, true)) + end + + for _, channel in ipairs(snapshot.recent_channels) do + if not favoriteIds[channel.id] then + table.insert(rows, recentChannelRow(channel, false)) + end + end + + if #rows == 0 then + table.insert(rows, ui.column({ paddingV = 12, align = "center" }, { + ui.label({ text = tr("saved.empty"), color = "on_surface_variant", textAlign = "center" }), + })) + end + return rows +end + +local function disconnectedBody() + local status = snapshot.status + local message = snapshot.status_message + if status == "auth_required" then + local children = { + ui.glyph({ name = "brand-discord", size = 46, color = "primary" }), + ui.label({ text = tr("auth.title"), fontSize = 17, fontWeight = "bold", color = "on_surface" }), + ui.label({ text = message ~= "" and message or tr("auth.description"), color = "on_surface_variant", textAlign = "center", maxLines = 4 }), + ui.button({ + text = tr("auth.button"), + glyph = "external-link", + variant = "primary", + enabled = not actionPending, + onClick = function() + runBridgeCommand("authorize") + end, + }), + } + return ui.column(CENTERED_LAYOUT, children) + elseif status == "authorizing" or status == "authenticating" then + return ui.column(CENTERED_LAYOUT, { + ui.glyph({ name = "loader-2", size = 42, color = "primary" }), + ui.label({ text = tr("auth.waiting"), fontWeight = "bold", color = "on_surface" }), + ui.label({ text = tr("auth.waiting_description"), color = "on_surface_variant", textAlign = "center" }), + }) + elseif status == "discord_unavailable" then + return ui.column(CENTERED_LAYOUT, { + ui.glyph({ name = "brand-discord", size = 46, color = "on_surface_variant" }), + ui.label({ text = tr("offline.title"), fontSize = 17, fontWeight = "bold", color = "on_surface" }), + ui.label({ text = message ~= "" and message or tr("offline.description"), color = "on_surface_variant", textAlign = "center", maxLines = 4 }), + ui.button({ text = tr("open_discord"), glyph = "brand-discord", variant = "primary", onClick = openDiscord }), + }) + elseif status == "error" then + return ui.column(CENTERED_LAYOUT, { + ui.glyph({ name = "alert-circle", size = 42, color = "error" }), + ui.label({ text = tr("error.title"), fontWeight = "bold", color = "error" }), + ui.label({ text = message, color = "on_surface_variant", textAlign = "center", maxLines = 4 }), + }) + end + return ui.column(CENTERED_LAYOUT, { + ui.glyph({ name = "loader-2", size = 42, color = "primary" }), + ui.label({ text = tr("starting"), color = "on_surface_variant" }), + }) +end + +local function voiceBody() + if snapshot.channel == nil then + return ui.column({ flexGrow = 1, gap = 10, align = "stretch", padding = 4 }, { + ui.row({ + align = "center", + justify = "center", + gap = 8, + paddingH = 12, + paddingV = 12, + fill = "surface_variant/0.24", + border = "outline/0.34", + borderWidth = 1, + radius = 11, + }, { + ui.glyph({ name = "headphones", size = 20, color = "on_surface_variant" }), + ui.label({ text = tr("not_in_voice.title"), fontSize = 15, fontWeight = "bold", color = "on_surface" }), + }), + ui.row({ align = "center", gap = 8, paddingH = 2 }, { + ui.glyph({ name = "history", size = 18, color = "primary" }), + ui.label({ text = tr("saved.channels"), fontSize = 16, fontWeight = "bold", color = "on_surface", flexGrow = 1 }), + ui.button({ glyph = "external-link", variant = "ghost", onClick = openDiscord }), + }), + ui.scroll({ flexGrow = 1, gap = 8, align = "stretch" }, recentChannelRows()), + }) + end + + local channel = snapshot.channel + local voice = snapshot.voice + local connectionGlyph, connectionColor, connectionLabel = connectionAppearance() + local serverName = channel.guild_name ~= "" and channel.guild_name or tr("unknown_server") + local participants = snapshot.participants + local favorite = false + for _, savedChannel in ipairs(snapshot.favorite_channels) do + if savedChannel.id == channel.id then + favorite = true + break + end + end + + return ui.column({ flexGrow = 1, gap = 12, align = "stretch" }, { + ui.column({ + fill = "surface_variant/0.24", + border = "outline/0.38", + borderWidth = 1, + radius = 13, + padding = 13, + gap = 8, + }, { + ui.row({ gap = 8, align = "center" }, { + ui.glyph({ name = "server", size = 17, color = "on_surface_variant" }), + ui.label({ text = serverName, fontWeight = "bold", color = "on_surface", flexGrow = 1, maxLines = 1 }), + ui.button({ + glyph = favorite and "star-filled" or "star", + variant = favorite and "secondary" or "ghost", + enabled = not actionPending, + onClick = function() + runBridgeCommand(favorite and "unfavorite-channel" or "favorite-channel", channel.id) + end, + }), + ui.button({ glyph = "external-link", variant = "ghost", onClick = openDiscord }), + }), + ui.row({ gap = 8, align = "center" }, { + ui.glyph({ name = "volume", size = 18, color = "primary" }), + ui.label({ text = channel.name, fontSize = 16, fontWeight = "bold", color = "primary", flexGrow = 1, maxLines = 1 }), + }), + ui.row({ gap = 14, align = "center" }, { + ui.row({ gap = 5, align = "center" }, { + ui.glyph({ name = "clock", size = 14, color = "on_surface_variant" }), + ui.label({ text = durationText(), fontSize = 12, color = "on_surface_variant" }), + }), + ui.row({ + gap = 5, + align = "center", + fill = connectionColor .. "/0.13", + border = connectionColor .. "/0.30", + borderWidth = 1, + radius = 8, + paddingH = 8, + paddingV = 3, + }, { + ui.glyph({ name = connectionGlyph, size = 14, color = connectionColor }), + ui.label({ text = connectionLabel, fontSize = 12, color = connectionColor }), + }), + ui.row({ gap = 5, align = "center" }, { + ui.glyph({ name = "users", size = 14, color = "on_surface_variant" }), + ui.label({ text = tostring(#participants), fontSize = 12, color = "on_surface_variant" }), + }), + }), + }), + ui.row({ + gap = 9, + align = "center", + padding = 8, + fill = "surface_variant/0.18", + border = "outline/0.30", + borderWidth = 1, + radius = 12, + }, { + ui.button({ + text = voice.mute and tr("controls.unmute") or tr("controls.mute"), + glyph = voice.mute and "microphone-off" or "microphone", + variant = voice.mute and "secondary" or "outline", + selected = voice.mute, + enabled = not actionPending, + flexGrow = 1, + onClick = function() + runBridgeCommand("set-mute", not voice.mute) + end, + }), + ui.button({ + text = voice.deaf and tr("controls.undeafen") or tr("controls.deafen"), + glyph = voice.deaf and "headphones-off" or "headphones", + variant = voice.deaf and "secondary" or "outline", + selected = voice.deaf, + enabled = not actionPending, + flexGrow = 1, + onClick = function() + runBridgeCommand("set-deaf", not voice.deaf) + end, + }), + ui.button({ + text = tr("controls.hang_up"), + glyph = "phone-off", + variant = "destructive", + enabled = not actionPending, + flexGrow = 1, + onClick = function() + runBridgeCommand("hang-up") + end, + }), + }), + ui.label({ text = tr("participants.title", { count = #participants }), fontSize = 13, fontWeight = "bold", color = "on_surface_variant" }), + ui.scroll({ flexGrow = 1, gap = 8, align = "stretch" }, participantList()), + }) +end + +render = function() + if not isOpen then + return + end + local children = { + ui.row({ align = "center", justify = "space_between", gap = 8 }, { + ui.row({ gap = 8, align = "center", flexGrow = 1 }, { + ui.glyph({ name = "brand-discord", size = 20, color = "primary" }), + ui.label({ text = tr("title"), fontSize = 17, fontWeight = "bold", color = "on_surface" }), + }), + ui.button({ glyph = "close", variant = "ghost", onClick = panel.close }), + }), + } + if actionError ~= "" then + table.insert(children, ui.label({ text = actionError, fontSize = 11, color = "error", maxLines = 3 })) + end + local body = snapshot.authenticated and voiceBody() or disconnectedBody() + table.insert(children, body) + panel.render(ui.column({ flexGrow = 1, gap = 12, align = "stretch" }, children)) +end + +noctalia.state.watch("voice_snapshot", function(value) + if type(value) == "table" then + snapshot = value + syncVolumeDrafts() + actionError = snapshot.authenticated and snapshot.status_message or "" + render() + end +end) + +function onOpen(_context) + snapshot = noctalia.state.get("voice_snapshot") or snapshot + micVolumeEditing = false + participantVolumeEditing = {} + syncVolumeDrafts() + isOpen = true + panel.setWantsSecondTicks(true) + render() + runBridgeCommand("refresh") +end + +function onClose() + isOpen = false + micVolumeEditing = false + participantVolumeEditing = {} + panel.setWantsSecondTicks(false) +end + +function update() + render() +end diff --git a/discord-voice/plugin.toml b/discord-voice/plugin.toml new file mode 100644 index 00000000..b0217655 --- /dev/null +++ b/discord-voice/plugin.toml @@ -0,0 +1,54 @@ +id = "raycursive/discord-voice" +name = "Discord Voice" +version = "0.3.1" +plugin_api = 9 +author = "raycursive" +license = "MIT" +dependencies = ["discord", "python3"] +tags = ["audio", "bar", "network", "panel", "privacy", "service"] +icon = "brand-discord" +description = "Monitor and control local Discord voice sessions, including participants, volume, speaking, and latency." + +[[setting]] +key = "discord_binary" +type = "file" +label_key = "settings.discord_binary.label" +description_key = "settings.discord_binary.description" +default = "/usr/bin/discord" + +[[service]] +id = "bridge" +entry = "service.luau" + +[[widget]] +id = "bar" +entry = "bar.luau" + + [[widget.setting]] + key = "glyph" + type = "glyph" + label_key = "settings.glyph.label" + default = "brand-discord" + + [[widget.setting]] + key = "show_channel_name" + type = "bool" + label_key = "settings.show_channel_name.label" + description_key = "settings.show_channel_name.description" + default = true + + [[widget.setting]] + key = "hide_when_disconnected" + type = "bool" + label_key = "settings.hide_when_disconnected.label" + description_key = "settings.hide_when_disconnected.description" + default = false + +[[panel]] +id = "panel" +entry = "panel.luau" +width = 600 +height = 640 +placement = "attached" +position = "auto" +open_near_click = true diff --git a/discord-voice/service.luau b/discord-voice/service.luau new file mode 100644 index 00000000..6351fe62 --- /dev/null +++ b/discord-voice/service.luau @@ -0,0 +1,118 @@ +--!nonstrict +-- Headless service: owns the long-lived Python bridge and publishes each +-- complete Discord voice snapshot for the widget and panel. + +local BRIDGE_STATE_KEY = "voice_snapshot" + +local bridgePath = noctalia.pluginDir() .. "/discord_bridge.py" +local bridgeScript = "'" .. bridgePath:gsub("'", "'\\''") .. "'" + +local function initialSnapshot(status, message) + return { + revision = 0, + status = status, + status_message = message, + authenticated = false, + user = {}, + channel = nil, + recent_channels = {}, + favorite_channels = {}, + participants = {}, + voice = { mute = false, deaf = false, input_volume = 100 }, + connection = { + state = "DISCONNECTED", + hostname = "", + average_ping = 0, + last_ping = 0, + }, + connected_since = nil, + } +end + +local function shallowCopy(value) + local copy = {} + for key, item in pairs(value) do + copy[key] = item + end + return copy +end + +local lastLineAt = tonumber(noctalia.formatTime("%s")) or 0 +local streamStarted = false + +local function onBridgeLine(line) + lastLineAt = tonumber(noctalia.formatTime("%s")) or 0 + local event, err = noctalia.json.decode(line) + if type(event) ~= "table" then + noctalia.log("discord-voice: ignored malformed bridge output: " .. tostring(err or "unknown error")) + return + end + if event.type == "heartbeat" then + return + end + if event.type ~= "snapshot" then + return + end + event.type = nil + noctalia.state.set(BRIDGE_STATE_KEY, event) +end + +local function startBridge() + if streamStarted then + return + end + if not noctalia.commandExists("python3") then + noctalia.state.set(BRIDGE_STATE_KEY, initialSnapshot("error", noctalia.tr("error.python_missing"))) + return + end + noctalia.state.set(BRIDGE_STATE_KEY, initialSnapshot("starting", "")) + streamStarted = noctalia.runStream("python3 " .. bridgeScript .. " daemon", onBridgeLine) + if streamStarted then + lastLineAt = tonumber(noctalia.formatTime("%s")) or 0 + else + noctalia.state.set(BRIDGE_STATE_KEY, initialSnapshot("error", noctalia.tr("error.bridge_start"))) + end +end + +noctalia.setUpdateInterval(5000) +startBridge() + +function update() + -- The bridge emits a heartbeat every five seconds. Surface a stalled helper + -- and restart it. This also recovers from a helper that exits during a + -- Noctalia hot-reload handoff. + if not streamStarted then + startBridge() + return + end + if (tonumber(noctalia.formatTime("%s")) or 0) - lastLineAt > 20 then + local previous = shallowCopy(noctalia.state.get(BRIDGE_STATE_KEY)) + previous.status_message = noctalia.tr("error.bridge_stalled") + noctalia.state.set(BRIDGE_STATE_KEY, previous) + streamStarted = false + startBridge() + end +end + +function onIpc(event, _payload) + if event == "authorize" then + noctalia.runAsync("python3 " .. bridgeScript .. " command authorize") + elseif event == "refresh" then + noctalia.runAsync("python3 " .. bridgeScript .. " command refresh") + elseif event == "hangup" then + noctalia.runAsync("python3 " .. bridgeScript .. " command hang-up") + elseif event == "dump" then + local snapshot = noctalia.state.get(BRIDGE_STATE_KEY) + local safe = { + status = snapshot.status, + authenticated = snapshot.authenticated, + channel = snapshot.channel, + recent_channels = snapshot.recent_channels, + favorite_channels = snapshot.favorite_channels, + participants = #snapshot.participants, + voice = snapshot.voice, + connection = snapshot.connection, + } + noctalia.log("discord-voice: " .. (noctalia.json.encode(safe) or "?")) + end +end diff --git a/discord-voice/thumbnail.webp b/discord-voice/thumbnail.webp new file mode 100644 index 0000000000000000000000000000000000000000..4ddd00099b7a533c7458ed0789dc1584fa3785c8 GIT binary patch literal 40540 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

#5!V-f!_^lAu(r(?R{=jo&pH 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