discord-bridge: Discord presence into Matrix ghosts #60

Merged
jared merged 1 commits from feat/discord-presence into main 2026-11-04 19:41:08 -05:00
6 changed files with 560 additions and 0 deletions
Showing only changes of commit f8f0ca4549 - Show all commits
+2
View File
@@ -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`.
+13
View File
@@ -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..."
+56
View File
@@ -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" <<YAML
# Discord presence service. Non-exclusive on the ghost namespace: it updates
# presence on ghosts the text bridge owns, and cannot create them.
id: lotus-discord-presence
url: null
as_token: $AS_TOKEN
hs_token: $HS_TOKEN
sender_localpart: discordpresence
rate_limited: false
namespaces:
users:
- regex: '^@discord_[0-9]+:matrix\.lotusguild\.org$'
exclusive: false
rooms: []
aliases: []
YAML
)
chown root:matrix-synapse "$REG"
echo "generated appservice credentials"
else
echo "keeping existing credentials"
fi
if ! grep -qF "$REG" "$HS"; then
cp -p "$HS" "$HS.bak-presence-$(date +%Y%m%d%H%M%S)"
sed -i "/^app_service_config_files:/a\\ - $REG" "$HS"
echo "added the registration to homeserver.yaml (takes effect on the next Synapse restart)"
fi
install -m644 "$REPO/systemd/lotus-discord-presence.service" /etc/systemd/system/lotus-discord-presence.service
systemctl daemon-reload
systemctl enable --now lotus-discord-presence
systemctl restart lotus-discord-presence
echo "installed. Restart Synapse once to activate the registration: systemctl restart matrix-synapse"
+335
View File
@@ -0,0 +1,335 @@
#!/usr/bin/env python3
"""Discord presence -> 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_<id> 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())
+123
View File
@@ -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()
+31
View File
@@ -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