diff --git a/maubot.yaml b/maubot.yaml
index 645e404..3548c09 100644
--- a/maubot.yaml
+++ b/maubot.yaml
@@ -1,6 +1,6 @@
maubot: 0.1.0
id: nl.roomsoverview
-version: 2.1.7
+version: 2.2.1
license: MIT
modules:
- roomsoverview
diff --git a/roomsoverview.mbp b/roomsoverview.mbp
index 9c1906b..34ecd24 100644
Binary files a/roomsoverview.mbp and b/roomsoverview.mbp differ
diff --git a/roomsoverview.py b/roomsoverview.py
index 105bbde..2c4bbc9 100644
--- a/roomsoverview.py
+++ b/roomsoverview.py
@@ -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
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''
+ )
+ title_html = f"{e(name)}"
+ if member_count is not None:
+ title_html += f" · {member_count} leden"
+ body_parts = [f"
" + ) + 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'{avatar_html}{title_html}
"] + if topic: + body_parts.append(f"{e(topic)}
") + body_parts.append( + f'' + f"{e(address)}
{html.escape(header)}
' + + "".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." ) \ No newline at end of file