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"

{avatar_html}{title_html}
"] + if topic: + body_parts.append(f"{e(topic)}
") + body_parts.append( + f'' + f"{e(address)}

" + ) + 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'

{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