From f8f0ca4549825d6cfb0d9dcbc8c6ff24b1380889 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Wed, 4 Nov 2026 19:36:38 -0500 Subject: [PATCH] discord-bridge: Discord presence into Matrix ghosts mautrix-discord doesn't bridge presence. lotus_presence.py watches the Discord server (a second gateway session on the bridge's own bot, Guilds + Presence intents only) and sets presence and a status message on each @discord_* ghost: online / idle / DND / offline -> online / unavailable / msc3026 busy / offline; custom status and the first activity become the status message. Its own appservice registration is non-exclusive on the ghost namespace, so it can update ghosts but not create them. Synapse drops an unrefreshed user to offline after about a minute, so live states are re-sent every 20 s; if the service dies, ghosts go offline rather than stale. The installer generates the credentials on the server and doesn't restart Synapse. Statuses and activities are never logged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA --- README.md | 2 + deploy/lxc151-hookshot.sh | 13 + discord-bridge/install-presence.sh | 56 +++++ discord-bridge/lotus_presence.py | 335 +++++++++++++++++++++++++ discord-bridge/test_lotus_presence.py | 123 +++++++++ systemd/lotus-discord-presence.service | 31 +++ 6 files changed, 560 insertions(+) create mode 100644 discord-bridge/install-presence.sh create mode 100644 discord-bridge/lotus_presence.py create mode 100644 discord-bridge/test_lotus_presence.py create mode 100644 systemd/lotus-discord-presence.service diff --git a/README.md b/README.md index 978ca77..da3c5b1 100644 --- a/README.md +++ b/README.md @@ -436,6 +436,8 @@ Skip `guilds bridge`: it sets the guild to create a Matrix room for every channe old room). The relay webhook stays with the channel. Keep `@discordbot` at the default power level so it can't overwrite the room's name and icon with the Discord channel's. +**Presence (`discord-bridge/lotus_presence.py`, `lotus-discord-presence.service`):** the bridge doesn't carry Discord status, so this small service does. It reuses the bridge's bot (token read from the bridge database at startup; a second gateway session with only the Guilds and Presence intents; **Presence Intent** must stay on in the developer portal) and has its own appservice registration, `/etc/matrix-synapse/discord-presence-registration.yaml`, non-exclusive on `@discord_*`, so it can update ghosts but not create them. Online / idle / DND / offline map to online / unavailable / `org.matrix.msc3026.busy` / offline; custom status and the first activity become the status message. Only people who already have a ghost show up. Synapse drops an unrefreshed user to offline after about a minute, so live states are re-sent every 20 s; if the service dies, ghosts go offline rather than stale. Install once with `discord-bridge/install-presence.sh`, then restart Synapse. Statuses and activities are never logged. + **Bot profile:** `@discordbot` shows as "Discord Bridge" with the Discord logo (`discord-bridge/bot-avatar.png`). To reapply it: `pct exec 151 -- bash /opt/matrix-config/discord-bridge/set-bot-profile.sh`. diff --git a/deploy/lxc151-hookshot.sh b/deploy/lxc151-hookshot.sh index 90d065a..e7a8b9b 100644 --- a/deploy/lxc151-hookshot.sh +++ b/deploy/lxc151-hookshot.sh @@ -54,6 +54,19 @@ else && install -m755 "$REPO_DIR/deploy/livekit-graceful-restart.sh" /usr/local/bin/livekit-graceful-restart.sh fi + # Discord presence service: only once installed (discord-bridge/install-presence.sh). + if [ -f /etc/lotus-presence/env ] && echo "$CHANGED" | grep -qE '^discord-bridge/lotus_presence\.py|^systemd/lotus-discord-presence\.service'; then + echo "Deploying lotus-discord-presence..." + if python3 -m py_compile "$REPO_DIR/discord-bridge/lotus_presence.py"; then + install -D -m644 "$REPO_DIR/discord-bridge/lotus_presence.py" /opt/lotus-presence/lotus_presence.py + install -m644 "$REPO_DIR/systemd/lotus-discord-presence.service" /etc/systemd/system/lotus-discord-presence.service + systemctl daemon-reload + systemctl restart lotus-discord-presence || true + else + echo "ERROR: lotus_presence.py has a syntax error, keeping the running version." + fi + fi + # Voice-limit / quality guard (fronts lk-jwt-service on :8070) if echo "$CHANGED" | grep -qE '^livekit/voice-limit-guard\.py|^systemd/voice-limit-guard\.service'; then echo "Deploying voice-limit-guard..." diff --git a/discord-bridge/install-presence.sh b/discord-bridge/install-presence.sh new file mode 100644 index 0000000..2e6f699 --- /dev/null +++ b/discord-bridge/install-presence.sh @@ -0,0 +1,56 @@ +#!/bin/bash +# One-time install of the Discord presence service (lotus_presence.py). +# Run on LXC 151 with this repo's main branch pulled to /opt/matrix-config: +# bash /opt/matrix-config/discord-bridge/install-presence.sh +# Generates its own appservice credentials on this machine and never prints them. +# Does NOT restart Synapse: until you do, Discord works but Matrix answers 401. +# Safe to re-run: existing credentials are kept. +set -euo pipefail + +REPO=/opt/matrix-config +REG=/etc/matrix-synapse/discord-presence-registration.yaml +ENV_FILE=/etc/lotus-presence/env +HS=/etc/matrix-synapse/homeserver.yaml + +install -d -m750 -o root -g mautrix-discord /etc/lotus-presence +install -D -m644 "$REPO/discord-bridge/lotus_presence.py" /opt/lotus-presence/lotus_presence.py + +if [ ! -f "$ENV_FILE" ]; then + AS_TOKEN="$(openssl rand -hex 32)" + HS_TOKEN="$(openssl rand -hex 32)" + (umask 027; printf 'AS_TOKEN=%s\n' "$AS_TOKEN" > "$ENV_FILE") + chown root:mautrix-discord "$ENV_FILE" + (umask 027; cat > "$REG" < Matrix ghosts. + +mautrix-discord doesn't bridge presence, so this small service does: it watches +the Discord server over the gateway (GUILD_PRESENCES) and sets presence and a +status message on each member's @discord_ ghost, which Lotus already shows. + +- Discord bot: the text bridge's own bot, read from its database at startup, so + there is no extra bot. This is a second gateway session asking only for the + intents it needs; the bridge's session is untouched. +- Matrix: its own appservice registration (non-exclusive on the ghost namespace; + the bridge still owns the ghosts). Only ghosts that already exist are updated. +- Synapse drops a user nobody refreshes to offline, so live states are re-sent + on a timer. That is also the failure mode we want: if this service dies, the + ghosts go offline rather than showing a stale "online" forever. + +Member statuses and activities are never logged. +""" +import asyncio +import logging +import os +import re +import subprocess +import sys +import time +import urllib.parse + +import aiohttp + +log = logging.getLogger("lotus-presence") + +GATEWAY = "wss://gateway.discord.gg/?v=10&encoding=json" +INTENT_GUILDS = 1 << 0 +INTENT_GUILD_PRESENCES = 1 << 8 +# Close codes where reconnecting cannot help: bad token, disallowed intents. +FATAL_CLOSE = {4004, 4013, 4014} +BUSY = "org.matrix.msc3026.busy" +STATUS_MAP = {"online": "online", "idle": "unavailable", "dnd": BUSY} +MAX_STATUS = 120 +# A ghost that doesn't exist yet (never spoke in a bridged room) is retried this often. +UNKNOWN_RETRY_S = 600 + + +def describe_activity(activity): + """One Discord activity as text, or None. Types per Discord's Activity object.""" + kind = activity.get("type") + name = (activity.get("name") or "").strip() + if kind == 4: # custom status + emoji = (activity.get("emoji") or {}) + # Custom server emoji have an id and can't be shown as text. + glyph = emoji.get("name") if not emoji.get("id") else None + text = (activity.get("state") or "").strip() + return " ".join(p for p in (glyph, text) if p) or None + if not name: + return None + if kind == 0: + return f"Playing {name}" + if kind == 1: + return f"Streaming {activity.get('details') or name}" + if kind == 2: + song, artist = activity.get("details"), activity.get("state") + if song and artist: + return f"Listening to {song} by {artist}" + return f"Listening to {song or name}" + if kind == 3: + return f"Watching {name}" + if kind == 5: + return f"Competing in {name}" + return None + + +def map_presence(status, activities): + """Discord status + activities -> (Matrix presence, status message).""" + presence = STATUS_MAP.get(status) + if presence is None: # offline / invisible: show nothing about them + return "offline", "" + custom = [a for a in activities or [] if a.get("type") == 4] + others = [a for a in activities or [] if a.get("type") != 4] + parts = [] + for activity in custom + others[:1]: + text = describe_activity(activity) + if text: + parts.append(text) + message = " · ".join(parts) + if len(message) > MAX_STATUS: + message = message[: MAX_STATUS - 1].rstrip() + "…" + return presence, message + + +def ghost_id(discord_id, server_name): + return f"@discord_{discord_id}:{server_name}" + + +def bridge_db_env(config_text): + """libpq settings from the bridge config's database URI (password stays out of argv).""" + match = re.search(r"^\s*uri:\s*[\"']?(postgres(?:ql)?://[^\"'\s]+)", config_text, re.M) + if not match: + raise RuntimeError("no postgres uri in the bridge config") + uri = urllib.parse.urlparse(match.group(1)) + env = { + "PGHOST": uri.hostname or "", + "PGPORT": str(uri.port or 5432), + "PGUSER": urllib.parse.unquote(uri.username or ""), + "PGPASSWORD": urllib.parse.unquote(uri.password or ""), + "PGDATABASE": uri.path.lstrip("/"), + } + sslmode = urllib.parse.parse_qs(uri.query).get("sslmode") + if sslmode: + env["PGSSLMODE"] = sslmode[0] + return env + + +def read_bot_token(config_path): + """The text bridge's bot token, from its database. Never logged.""" + with open(config_path, encoding="utf-8") as handle: + env = {**os.environ, **bridge_db_env(handle.read())} + out = subprocess.run( + ["psql", "-Atc", "select discord_token from \"user\" where discord_token like 'Bot %'"], + env=env, + capture_output=True, + text=True, + timeout=30, + check=False, + ) + if out.returncode != 0: + raise RuntimeError("could not read the bot token from the bridge database") + tokens = [line for line in out.stdout.splitlines() if line] + if len(tokens) != 1: + raise RuntimeError(f"expected exactly one bridge bot login, found {len(tokens)}") + return tokens[0].removeprefix("Bot ") + + +class GhostPresence: + """What each ghost should show, pushed to Synapse on change and on a timer.""" + + def __init__(self, session, homeserver, as_token, server_name, clock=time.monotonic): + self.session = session + self.homeserver = homeserver.rstrip("/") + self.as_token = as_token + self.server_name = server_name + self.clock = clock + self.live = {} # discord id -> (presence, message), everything not offline + self.unknown_until = {} # discord id -> when to try a missing ghost again + + async def _put(self, discord_id, presence, message): + user = urllib.parse.quote(ghost_id(discord_id, self.server_name), safe="") + url = f"{self.homeserver}/_matrix/client/v3/presence/{user}/status?user_id={user}" + body = {"presence": presence, "status_msg": message} + headers = {"Authorization": f"Bearer {self.as_token}"} + try: + async with self.session.put( + url, json=body, headers=headers, timeout=aiohttp.ClientTimeout(total=10) + ) as resp: + # Synapse answers 403 for a user the appservice hasn't registered. + if resp.status == 403: + self.unknown_until[discord_id] = self.clock() + UNKNOWN_RETRY_S + elif resp.status >= 400: + log.warning("presence update failed: HTTP %s", resp.status) + else: + self.unknown_until.pop(discord_id, None) + except (aiohttp.ClientError, asyncio.TimeoutError) as err: + log.warning("presence update failed: %s", type(err).__name__) + + def _skip(self, discord_id): + return self.unknown_until.get(discord_id, 0) > self.clock() + + async def update(self, discord_id, presence, message): + known = self.live.get(discord_id) + if presence == "offline": + if known is None: + return + del self.live[discord_id] + else: + if known == (presence, message): + return + self.live[discord_id] = (presence, message) + if not self._skip(discord_id): + await self._put(discord_id, presence, message) + + async def resync(self, current): + """A full member snapshot: `current` replaces what we had.""" + for discord_id in set(self.live) - set(current): + await self.update(discord_id, "offline", "") + for discord_id, (presence, message) in current.items(): + await self.update(discord_id, presence, message) + + async def refresh(self): + for discord_id, (presence, message) in list(self.live.items()): + if not self._skip(discord_id): + await self._put(discord_id, presence, message) + + +class FatalGatewayError(Exception): + pass + + +class Gateway: + def __init__(self, session, token, guild_id, ghosts): + self.session = session + self.token = token + self.guild_id = guild_id + self.ghosts = ghosts + self.seq = None + self.session_id = None + self.resume_url = None + self.acked = True + + async def _heartbeat(self, ws, interval_s): + while True: + await asyncio.sleep(interval_s) + if not self.acked: + log.warning("gateway missed a heartbeat ack, reconnecting") + await ws.close(code=4000) + return + self.acked = False + await ws.send_json({"op": 1, "d": self.seq}) + + async def _dispatch(self, kind, data): + if kind == "READY": + self.session_id = data["session_id"] + self.resume_url = data.get("resume_gateway_url") + log.info("gateway ready") + elif kind == "GUILD_CREATE" and data.get("id") == self.guild_id: + current = {} + for item in data.get("presences") or []: + presence, message = map_presence(item.get("status"), item.get("activities")) + if presence != "offline": + current[item["user"]["id"]] = (presence, message) + await self.ghosts.resync(current) + log.info("synced %d online members", len(current)) + elif kind == "PRESENCE_UPDATE" and data.get("guild_id") == self.guild_id: + presence, message = map_presence(data.get("status"), data.get("activities")) + await self.ghosts.update(data["user"]["id"], presence, message) + + async def _run_once(self): + resuming = bool(self.session_id and self.resume_url) + url = f"{self.resume_url}/?v=10&encoding=json" if resuming else GATEWAY + beat = None + async with self.session.ws_connect(url, heartbeat=None, max_msg_size=0) as ws: + try: + async for frame in ws: + if frame.type != aiohttp.WSMsgType.TEXT: + break + msg = frame.json() + if msg.get("s") is not None: + self.seq = msg["s"] + op = msg.get("op") + if op == 10: + self.acked = True + interval = msg["d"]["heartbeat_interval"] / 1000 + beat = asyncio.create_task(self._heartbeat(ws, interval)) + if resuming: + await ws.send_json({"op": 6, "d": { + "token": self.token, + "session_id": self.session_id, + "seq": self.seq, + }}) + else: + await ws.send_json({"op": 2, "d": { + "token": self.token, + "intents": INTENT_GUILDS | INTENT_GUILD_PRESENCES, + "properties": { + "os": "linux", + "browser": "lotus-presence", + "device": "lotus-presence", + }, + }}) + elif op == 11: + self.acked = True + elif op == 1: + await ws.send_json({"op": 1, "d": self.seq}) + elif op == 7: + return + elif op == 9: + if not msg.get("d"): + self.session_id = None + return + elif op == 0: + await self._dispatch(msg.get("t"), msg.get("d") or {}) + finally: + if beat: + beat.cancel() + if ws.close_code in FATAL_CLOSE: + raise FatalGatewayError(f"gateway refused us (close code {ws.close_code})") + if ws.close_code: + log.info("gateway closed (code %s)", ws.close_code) + + async def run(self): + backoff = 1 + while True: + started = time.monotonic() + try: + await self._run_once() + except (aiohttp.ClientError, asyncio.TimeoutError, OSError) as err: + log.warning("gateway connection failed: %s", type(err).__name__) + if time.monotonic() - started > 60: + backoff = 1 + await asyncio.sleep(backoff) + backoff = min(backoff * 2, 60) + + +async def refresh_loop(ghosts, interval_s): + while True: + await asyncio.sleep(interval_s) + await ghosts.refresh() + + +async def amain(): + token = read_bot_token(os.environ["BRIDGE_CONFIG"]) + async with aiohttp.ClientSession() as session: + ghosts = GhostPresence( + session, + os.environ.get("HOMESERVER_URL", "http://127.0.0.1:8008"), + os.environ["AS_TOKEN"], + os.environ["SERVER_NAME"], + ) + gateway = Gateway(session, token, os.environ["DISCORD_GUILD_ID"], ghosts) + refresh_s = float(os.environ.get("REFRESH_SECONDS", "20")) + await asyncio.gather(gateway.run(), refresh_loop(ghosts, refresh_s)) + + +def main(): + logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s") + # Discord's own clients log request URLs at DEBUG; keep ours quiet regardless. + logging.getLogger("aiohttp").setLevel(logging.WARNING) + try: + asyncio.run(amain()) + except FatalGatewayError as err: + log.error("%s", err) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/discord-bridge/test_lotus_presence.py b/discord-bridge/test_lotus_presence.py new file mode 100644 index 0000000..a2365ed --- /dev/null +++ b/discord-bridge/test_lotus_presence.py @@ -0,0 +1,123 @@ +import asyncio +import unittest + +import lotus_presence as lp + + +class MapPresence(unittest.TestCase): + def test_statuses(self): + self.assertEqual(lp.map_presence("online", []), ("online", "")) + self.assertEqual(lp.map_presence("idle", []), ("unavailable", "")) + self.assertEqual(lp.map_presence("dnd", []), (lp.BUSY, "")) + self.assertEqual(lp.map_presence("offline", []), ("offline", "")) + self.assertEqual(lp.map_presence("invisible", None), ("offline", "")) + + def test_offline_never_carries_activity(self): + game = [{"type": 0, "name": "Valorant"}] + self.assertEqual(lp.map_presence("offline", game), ("offline", "")) + + def test_activities(self): + for activity, expected in ( + ({"type": 0, "name": "Valorant"}, "Playing Valorant"), + ({"type": 1, "name": "Twitch", "details": "Ranked"}, "Streaming Ranked"), + ({"type": 2, "name": "Spotify", "details": "Song", "state": "Band"}, + "Listening to Song by Band"), + ({"type": 2, "name": "Spotify"}, "Listening to Spotify"), + ({"type": 3, "name": "YouTube"}, "Watching YouTube"), + ({"type": 5, "name": "Arena"}, "Competing in Arena"), + ): + self.assertEqual(lp.map_presence("online", [activity])[1], expected) + + def test_custom_status_comes_first_with_one_activity(self): + activities = [ + {"type": 0, "name": "Valorant"}, + {"type": 0, "name": "Second game"}, + {"type": 4, "name": "Custom Status", "state": "afk", "emoji": {"name": "🍕"}}, + ] + self.assertEqual(lp.map_presence("online", activities)[1], "🍕 afk · Playing Valorant") + + def test_custom_server_emoji_is_dropped(self): + custom = {"type": 4, "state": "hi", "emoji": {"name": "partyblob", "id": "123"}} + self.assertEqual(lp.map_presence("online", [custom])[1], "hi") + + def test_long_status_is_truncated(self): + _, message = lp.map_presence("online", [{"type": 0, "name": "x" * 500}]) + self.assertLessEqual(len(message), lp.MAX_STATUS) + self.assertTrue(message.endswith("…")) + + +class BridgeConfig(unittest.TestCase): + def test_reads_the_database_uri(self): + env = lp.bridge_db_env( + "appservice:\n database:\n type: postgres\n" + ' uri: "postgres://u:p%40ss@10.0.0.1:5433/db?sslmode=disable"\n' + ) + self.assertEqual(env["PGHOST"], "10.0.0.1") + self.assertEqual(env["PGPORT"], "5433") + self.assertEqual(env["PGPASSWORD"], "p@ss") + self.assertEqual(env["PGDATABASE"], "db") + self.assertEqual(env["PGSSLMODE"], "disable") + + def test_missing_uri_is_an_error(self): + with self.assertRaises(RuntimeError): + lp.bridge_db_env("uri: sqlite:///x") + + +class FakeGhosts(lp.GhostPresence): + def __init__(self, reject=()): + super().__init__(None, "http://hs", "t", "s", clock=lambda: self.now) + self.now = 0 + self.sent = [] + self.reject = set(reject) + + async def _put(self, discord_id, presence, message): + self.sent.append((discord_id, presence, message)) + if discord_id in self.reject: + self.unknown_until[discord_id] = self.now + lp.UNKNOWN_RETRY_S + + +class Ghosts(unittest.TestCase): + def test_only_changes_are_sent(self): + g = FakeGhosts() + asyncio.run(g.update("1", "online", "")) + asyncio.run(g.update("1", "online", "")) + asyncio.run(g.update("1", "online", "Playing X")) + self.assertEqual(len(g.sent), 2) + + def test_offline_is_sent_once_and_only_after_being_live(self): + g = FakeGhosts() + asyncio.run(g.update("1", "offline", "")) + self.assertEqual(g.sent, []) + asyncio.run(g.update("1", "online", "")) + asyncio.run(g.update("1", "offline", "")) + asyncio.run(g.update("1", "offline", "")) + self.assertEqual(g.sent[-1], ("1", "offline", "")) + self.assertEqual(len(g.sent), 2) + + def test_resync_turns_off_whoever_left(self): + g = FakeGhosts() + asyncio.run(g.update("1", "online", "")) + asyncio.run(g.resync({"2": ("online", "")})) + self.assertIn(("1", "offline", ""), g.sent) + self.assertEqual(set(g.live), {"2"}) + + def test_refresh_resends_live_states(self): + g = FakeGhosts() + asyncio.run(g.update("1", "unavailable", "x")) + g.sent.clear() + asyncio.run(g.refresh()) + self.assertEqual(g.sent, [("1", "unavailable", "x")]) + + def test_missing_ghost_is_retried_later_not_every_refresh(self): + g = FakeGhosts(reject={"1"}) + asyncio.run(g.update("1", "online", "")) + g.sent.clear() + asyncio.run(g.refresh()) + self.assertEqual(g.sent, []) + g.now += lp.UNKNOWN_RETRY_S + 1 + asyncio.run(g.refresh()) + self.assertEqual(g.sent, [("1", "online", "")]) + + +if __name__ == "__main__": + unittest.main() diff --git a/systemd/lotus-discord-presence.service b/systemd/lotus-discord-presence.service new file mode 100644 index 0000000..e534773 --- /dev/null +++ b/systemd/lotus-discord-presence.service @@ -0,0 +1,31 @@ +[Unit] +Description=Discord presence to Matrix ghosts (Lotus) +After=network-online.target matrix-synapse.service mautrix-discord.service +Wants=network-online.target + +[Service] +Type=simple +# The bridge's user: it reads the bot token from the bridge's own config + database. +User=mautrix-discord +Group=mautrix-discord +EnvironmentFile=/etc/lotus-presence/env +Environment=BRIDGE_CONFIG=/opt/mautrix-discord/config.yaml +Environment=SERVER_NAME=matrix.lotusguild.org +Environment=DISCORD_GUILD_ID=1565159371355197570 +ExecStart=/usr/bin/python3 /opt/lotus-presence/lotus_presence.py +Restart=always +RestartSec=30 +StandardOutput=journal +StandardError=journal +SyslogIdentifier=lotus-presence + +MemoryMax=128M +CPUQuota=20% + +NoNewPrivileges=yes +ProtectSystem=strict +ProtectHome=yes +PrivateTmp=yes + +[Install] +WantedBy=multi-user.target -- 2.47.3