Avatar icons in room cards: fetch m.room.avatar + matrixrooms.info enrichment, store in DB, render 32px icon; v2.2.1

This commit is contained in:
Eddy de Vink 2026-09-04 17:23:31 +02:00
commit 11c8c5ca15
3 changed files with 210 additions and 56 deletions

View file

@ -1,6 +1,6 @@
maubot: 0.1.0
id: nl.roomsoverview
version: 2.1.7
version: 2.2.1
license: MIT
modules:
- roomsoverview

Binary file not shown.

View file

@ -1,4 +1,5 @@
import asyncio
import html
import json
import time
from functools import partial
@ -9,6 +10,7 @@ import aiohttp
from maubot import Plugin, MessageEvent
from maubot.handlers import command
from mautrix.types import Format, MessageType, TextMessageEventContent
from mautrix.util.async_db import Connection, Scheme, UpgradeTable
from mautrix.util.config import BaseProxyConfig, ConfigUpdateHelper
@ -74,18 +76,25 @@ async def upgrade_v2(conn: Connection, scheme: Scheme) -> None:
await conn.execute("DROP TABLE IF EXISTS roomsoverview_cache")
def _escape_md(text: str) -> str:
"""Escape markdown-significant characters when embedding user content."""
return (
text.replace("\\", "\\\\")
.replace("`", "\\`")
.replace("*", "\\*")
.replace("_", "\\_")
.replace("[", "\\[")
.replace("]", "\\]")
.replace("<", "\\<")
.replace(">", "\\>")
)
@upgrade_table.register(description="Room avatars")
async def upgrade_v3(conn: Connection, scheme: Scheme) -> None:
# avatar = mxc:// URL van m.room.avatar (of NULL als room geen/ontoegankelijk
# icoon heeft). Alleen in de lijst getoond als de URL naar een echte
# thumbnail leidt.
if scheme == Scheme.SQLITE:
# SQLite kent geen ADD COLUMN IF NOT EXISTS; de kolom bestaat al zodra
# een eerdere (gedeeltelijk gefaalde) poging hem heeft aangemaakt.
try:
await conn.execute(
"ALTER TABLE roomsoverview_rooms ADD COLUMN avatar TEXT"
)
except Exception:
pass # duplicate column -> al aanwezig, prima
else:
await conn.execute(
"ALTER TABLE roomsoverview_rooms "
"ADD COLUMN IF NOT EXISTS avatar TEXT"
)
class Config(BaseProxyConfig):
@ -159,10 +168,11 @@ class RoomsOverviewPlugin(Plugin):
pass
return None
name, topic, alias = await asyncio.gather(
name, topic, alias, avatar = await asyncio.gather(
state("m.room.name"),
state("m.room.topic"),
state("m.room.canonical_alias"),
state("m.room.avatar"),
)
members: Optional[int] = None
@ -184,6 +194,7 @@ class RoomsOverviewPlugin(Plugin):
"name": ((name or {}).get("name", "") or "").strip(),
"topic": ((topic or {}).get("topic", "") or "").strip(),
"alias": ((alias or {}).get("alias", "") or "").strip(),
"avatar": ((avatar or {}).get("url", "") or "").strip() or None,
"members": members,
}
@ -272,12 +283,14 @@ class RoomsOverviewPlugin(Plugin):
async def _validate_all(self) -> Tuple[int, int]:
"""Valideer: verwijder alleen rooms die niet meer bestaan (404).
Werkt metadata NOOIT bij, alleen een duidelijke 404 op meerdere
endpoints verwijdert een rij. (removed, total)
Vult daarnaast de avatar (m.room.avatar) in van rooms die er nog geen
hebben; rooms waar de bot geen state mag lezen (403) of die simpelweg
geen icoon hebben blijven op NULL. Metadata wordt verder nooit
overschreven. (removed, total)
"""
async with self._refresh_lock:
rows = await self.database.fetch(
"SELECT room_id FROM roomsoverview_rooms"
"SELECT room_id, avatar FROM roomsoverview_rooms"
)
removed = 0
for row in rows:
@ -287,13 +300,119 @@ class RoomsOverviewPlugin(Plugin):
"DELETE FROM roomsoverview_rooms WHERE room_id = $1", rid
)
removed += 1
continue
if not row.get("avatar"):
mxc = await self._get_avatar_mxc(rid)
if mxc:
await self.database.execute(
"UPDATE roomsoverview_rooms SET avatar = $2 "
"WHERE room_id = $1",
rid,
mxc,
)
if removed:
self.log.info(f"validatie: {removed} room(s) verwijderd (bestaan niet meer)")
return removed, len(rows)
async def _get_avatar_mxc(self, room_id: str) -> Optional[str]:
"""haal de mxc-URL van m.room.avatar op (None als afwezig/ontoegankelijk)."""
base = self._base_url()
h = self._headers()
rid = quote(room_id, safe="")
try:
async with self.http.get(
f"{base}/_matrix/client/v3/rooms/{rid}/state/m.room.avatar",
headers=h,
timeout=_REQUEST_TIMEOUT,
) as r:
if r.status == 200:
d = await r.json()
url = str((d or {}).get("url", "") or "").strip()
return url if url.startswith("mxc://") else None
except Exception:
pass
return None
async def _enrich_avatars_online(self) -> int:
"""Vul avatars in via de publieke matrixrooms.info zoek-API.
Alleen voor rooms met NULL avatar: query op alias-kern (of naam),
match op room_id/alias/naam, en sla de gevonden avatar-URL op.
Gebruikt de HTTP-URL direct (de matrixrooms-media wordt via de
homeserver-media-proxy van de kijker opgehaald); is daarna stabiel
genoeg voor het overzicht. Retourneert aantal gevulde rooms.
"""
rows = await self.database.fetch(
"SELECT room_id, display_name, canonical_alias FROM roomsoverview_rooms "
"WHERE alive AND (avatar IS NULL OR avatar = '')"
)
base = "https://api.matrixrooms.info/search"
filled = 0
headers = {"Accept": "application/json", "User-Agent": "maubot-roomsoverview"}
for row in rows:
rid = row["room_id"]
alias = (row.get("canonical_alias") or "").strip()
name = (row.get("display_name") or "").strip()
q = (alias.lstrip("#") if alias else name)
q = q.split(":")[0].strip() # zonder server-deel (API geeft 204 op 'alias:server')
if not q:
continue
try:
async with self.http.get(
f"{base}?q={quote(q)}", headers=headers,
timeout=_REQUEST_TIMEOUT,
) as r:
if r.status != 200:
continue
res = await r.json()
except Exception:
continue
if not isinstance(res, list) or not res:
continue
hit = next((x for x in res if x.get("id") == rid), None)
if not hit:
hit = next((x for x in res if x.get("alias") == alias), None)
if not hit:
nl = (name or "").lower()
hit = next((x for x in res if (x.get("name") or "").lower() == nl), None)
if not hit:
continue
av = (str(hit.get("avatar_url") or hit.get("avatar") or "")).strip()
if not av:
continue
await self.database.execute(
"UPDATE roomsoverview_rooms SET avatar = $2 WHERE room_id = $1",
rid, av,
)
filled += 1
if filled:
self.log.info(f"avatars gevuld via matrixrooms.info: {filled}")
return filled
def _mxc_to_http(self, mxc: Optional[str]) -> Optional[str]:
"""mxc://server/media_id -> HTTPS-thumbnail-URL op de eigen homeserver.
Element/federatie mogen media van vreemde servers niet zomaar laden;
via de eigen (bot)homeserver geproxied werkt het overal. 64px-crop is
klein genoeg voor een kaart-icoon.
"""
if not mxc or not mxc.startswith("mxc://"):
return None
try:
server, media = mxc[6:].split("/", 1)
except ValueError:
return None
if not server or not media:
return None
return (
f"{self._base_url()}/_matrix/media/v3/thumbnail/"
f"{quote(server, safe='')}/{quote(media, safe='')}"
f"?width=64&height=64&method=crop"
)
async def _list_rooms(self) -> List[Dict[str, Any]]:
rows = await self.database.fetch(
"SELECT room_id, display_name, canonical_alias, topic, member_count "
"SELECT room_id, display_name, canonical_alias, topic, member_count, avatar "
"FROM roomsoverview_rooms WHERE alive ORDER BY "
"COALESCE(display_name, canonical_alias, room_id) ASC"
)
@ -317,50 +436,82 @@ class RoomsOverviewPlugin(Plugin):
rooms.sort(key=sort_key)
rooms_blocks = []
# Elke room wordt een blockquote-kaart: icoontje + naam/leden als kop,
# omschrijving eronder, onderaan de join-link. De formatted_body bouwen
# we zelf op (veilig ge-escaped), zodat we per kaart een klein avatar-
# icoon kunnen tonen — plain markdown kan geen width/height sturen en
# allow_html=True op user-content is een injectierisico.
#
# Avatar-bron: ofwel een al-publieke HTTP-URL (bijv. via de
# matrixrooms.info media-CDN, opgeslagen in de DB), ofwel een mxc://
# die we via de eigen homeserver als thumbnail tonen (werkt ook voor
# remote media, mits die server publiek serveert).
cards = []
for r in rooms:
display = r.get("display_name") or ""
alias = r.get("canonical_alias") or ""
display = (r.get("display_name") or "").strip()
alias = (r.get("canonical_alias") or "").strip()
room_id = r["room_id"]
member_count = r.get("member_count")
topic = (r.get("topic") or "").strip().replace("\n", " ")
# Elke room wordt een blockquote-kaart: een afgebakend blok met
# gekleurde kantlijn, naam + ledental als kop, omschrijving eronder
# en onderaan de join-link.
#
# Regels binnen de kaart scheiden met HARD BREAKS (twee trailing
# spaties), niet met een kale newline: mautrix rendert een soft
# newline binnen een paragraaf als spatie, en dan plakken naam,
# topic en link aan elkaar. Twee spaties voor \n geven <br /> in
# de formatted_body (echte nieuwe regel in Element).
avatar = r.get("avatar") or ""
if avatar.startswith("https://") or avatar.startswith("http://"):
avatar_http = avatar
else:
avatar_http = self._mxc_to_http(avatar) if avatar.startswith("mxc://") else None
link_target = alias if alias else room_id
address = _escape_md(alias if alias else room_id)
lines = []
title = _escape_md(display) if display else address
meta = f"**{title}**"
address = alias if alias else room_id
name = display if display else address
# ---- plain text (body) ----
plain_lines = []
plain_title = name
if member_count is not None:
meta += f" · {member_count} leden"
lines.append(meta)
plain_title += f" · {member_count} leden"
plain_lines.append(plain_title)
if topic:
lines.append(_escape_md(topic))
# Linktekst = full matrix.to URI (fragment mag rauw # en : bevatten)
lines.append(f"[{address}](https://matrix.to/#/{link_target})")
cards = []
for i, ln in enumerate(lines):
suffix = " " if i < len(lines) - 1 else ""
cards.append(f"> {ln}{suffix}")
rooms_blocks.append("\n".join(cards))
plain_lines.append(topic)
plain_lines.append(f"{address} (https://matrix.to/#/{link_target})")
plain = "\n".join(plain_lines)
# ---- formatted HTML ----
e = html.escape
avatar_html = ""
if avatar_http:
avatar_html = (
f'<img src="{e(avatar_http, quote=True)}" width="32" height="32" '
f'alt="" style="vertical-align:middle;margin-right:6px">'
)
title_html = f"<b>{e(name)}</b>"
if member_count is not None:
title_html += f" · {member_count} leden"
body_parts = [f"<blockquote><p>{avatar_html}{title_html}<br>"]
if topic:
body_parts.append(f"{e(topic)}<br>")
body_parts.append(
f'<a href="https://matrix.to/#/{e(link_target, quote=True)}">'
f"{e(address)}</a></p></blockquote>"
)
cards.append(("".join(body_parts), plain))
chunk_size = max(1, int(self.config.get("rooms_per_message", 50) or 50))
chunks = [
rooms_blocks[i : i + chunk_size]
for i in range(0, len(rooms_blocks), chunk_size)
]
total = len(rooms_blocks)
chunks = [cards[i : i + chunk_size] for i in range(0, len(cards), chunk_size)]
total = len(cards)
for idx, chunk in enumerate(chunks, start=1):
header = f"**Rooms overzicht ({total} rooms getoond, deel {idx}/{len(chunks)}):**\n\n"
await evt.respond(header + "\n\n".join(chunk), markdown=True)
header = f"Rooms overzicht ({total} rooms getoond, deel {idx}/{len(chunks)}):"
html_body = (
f'<p><b>{html.escape(header)}</b></p>'
+ "".join(html_part for html_part, _ in chunk)
)
plain_body = header + "\n\n" + "\n\n".join(plain for _, plain in chunk)
content = TextMessageEventContent(
msgtype=MessageType.TEXT,
body=plain_body,
format=Format.HTML,
formatted_body=html_body,
)
await evt.respond(content)
@command.new(
name="rooms",
@ -396,15 +547,16 @@ class RoomsOverviewPlugin(Plugin):
return
await self.database.execute(
"INSERT INTO roomsoverview_rooms "
"(room_id, display_name, canonical_alias, topic, member_count, added_by, added_at, last_checked) "
"VALUES ($1, $2, $3, $4, $5, $6, $7, $7) "
"(room_id, display_name, canonical_alias, topic, member_count, avatar, added_by, added_at, last_checked) "
"VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8) "
"ON CONFLICT (room_id) DO UPDATE SET display_name = $2, canonical_alias = $3, "
"topic = $4, member_count = $5, last_checked = $7",
"topic = $4, member_count = $5, avatar = $6, last_checked = $8",
room_id,
info["name"] or None,
info["alias"] or None,
info["topic"] or None,
info["members"],
info["avatar"],
evt.sender,
int(time.time()),
)
@ -432,13 +584,15 @@ class RoomsOverviewPlugin(Plugin):
else:
await evt.reply(f"Room `{room_id}` stond niet in de index.")
@handle_rooms.subcommand(name="refresh", help="Valideer de index nu (moderator+)")
@handle_rooms.subcommand(name="refresh", help="Valideer de index en vul avatars aan (moderator+)")
async def refresh_index(self, evt: MessageEvent) -> None:
if not await self._is_mod(evt.room_id, evt.sender):
await evt.reply("Geen permissie: moderator of hoger vereist.")
return
removed, total = await self._validate_all()
filled = await self._enrich_avatars_online()
await evt.reply(
f"Index gecontroleerd: {total - removed} rooms over, "
f"{removed} verwijderd (bestonden niet meer)."
f"{removed} verwijderd (bestonden niet meer). "
f"{filled} avatar(s) aangevuld."
)