From 11c8c5ca15597f83ffba721811ee0c9ddc8f619d Mon Sep 17 00:00:00 2001 From: Eddy de Vink Date: Fri, 4 Sep 2026 17:23:31 +0200 Subject: [PATCH] Avatar icons in room cards: fetch m.room.avatar + matrixrooms.info enrichment, store in DB, render 32px icon; v2.2.1 --- maubot.yaml | 2 +- roomsoverview.mbp | Bin 5853 -> 7558 bytes roomsoverview.py | 264 ++++++++++++++++++++++++++++++++++++---------- 3 files changed, 210 insertions(+), 56 deletions(-) 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 9c1906b3c3c8ab7439d54eee6d147e33da1a7e01..34ecd241db1c9d48655cbe69dc017c9567457ce3 100644 GIT binary patch delta 6907 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 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