discord-bridge: Discord presence into Matrix ghosts
Lint / JS (eslint) (pull_request) Successful in 10s
Lint / Shell (shellcheck) (pull_request) Successful in 11s
Lint / No Hookshot webhook IDs in README (pull_request) Successful in 6s
Lint / No secrets in webhook configs (pull_request) Successful in 6s
Lint / Landing page is rendered (matrix (pull_request) Successful in 6s
Lint / Python (ruff) (pull_request) Successful in 7s
Lint / Secret scan (gitleaks) (pull_request) Successful in 13s
Lint / Shell (shellcheck) (push) Successful in 26s
Lint / JS (eslint) (push) Successful in 18s
Lint / No Hookshot webhook IDs in README (push) Successful in 21s
Lint / No secrets in webhook configs (push) Successful in 12s
Lint / Python deps (pip-audit) (pull_request) Successful in 1m54s
Lint / Landing page is rendered (matrix (push) Successful in 17s
Lint / Python (ruff) (push) Successful in 16s
Lint / Secret scan (gitleaks) (push) Successful in 31s
Lint / Python deps (pip-audit) (push) Successful in 1m37s
Lint / JS (eslint) (pull_request) Successful in 10s
Lint / Shell (shellcheck) (pull_request) Successful in 11s
Lint / No Hookshot webhook IDs in README (pull_request) Successful in 6s
Lint / No secrets in webhook configs (pull_request) Successful in 6s
Lint / Landing page is rendered (matrix (pull_request) Successful in 6s
Lint / Python (ruff) (pull_request) Successful in 7s
Lint / Secret scan (gitleaks) (pull_request) Successful in 13s
Lint / Shell (shellcheck) (push) Successful in 26s
Lint / JS (eslint) (push) Successful in 18s
Lint / No Hookshot webhook IDs in README (push) Successful in 21s
Lint / No secrets in webhook configs (push) Successful in 12s
Lint / Python deps (pip-audit) (pull_request) Successful in 1m54s
Lint / Landing page is rendered (matrix (push) Successful in 17s
Lint / Python (ruff) (push) Successful in 16s
Lint / Secret scan (gitleaks) (push) Successful in 31s
Lint / Python deps (pip-audit) (push) Successful in 1m37s
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
@@ -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`.
|
||||
|
||||
|
||||
@@ -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..."
|
||||
|
||||
@@ -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"
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user