diff --git a/.gitignore b/.gitignore index 8182bc4..e14b68d 100644 --- a/.gitignore +++ b/.gitignore @@ -101,6 +101,10 @@ jspm_packages/ ehthumbs.db Thumbs.db +# Python +__pycache__/ +*.pyc + # Test files test/fixtures/ test/sample-pdfs/ diff --git a/maubot.yaml b/maubot.yaml index 3f17638..a41b9dd 100644 --- a/maubot.yaml +++ b/maubot.yaml @@ -1,6 +1,6 @@ maubot: 0.1.0 id: nl.roomsoverview -version: 2.1.2 +version: 2.4.0 license: MIT modules: - roomsoverview diff --git a/roomsoverview.mbp b/roomsoverview.mbp index fd48316..1b34b3d 100644 Binary files a/roomsoverview.mbp and b/roomsoverview.mbp differ diff --git a/roomsoverview.py b/roomsoverview.py index 9e69e09..677cf8c 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,42 @@ 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" + ) + + +def _parse_edit_args(args: str) -> Optional[Tuple[str, str, str]]: + """Parse `!rooms edit =`. + + Retourneert (room_ref, field, value) of None bij ongeldige vorm. + field is genormaliseerd naar lowercase; value mag leeg zijn (wissen). + """ + parts = args.strip().split(" ", 1) + if len(parts) != 2: + return None + room_ref, kv = parts + if "=" not in kv: + return None + field, _, value = kv.partition("=") + field = field.strip().lower() + return room_ref, field, value.strip() class Config(BaseProxyConfig): @@ -159,10 +185,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 +211,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, } @@ -244,103 +272,369 @@ class RoomsOverviewPlugin(Plugin): pl = int(users.get(user_id, default) or 0) return pl >= MOD_POWER_LEVEL + async def _room_exists(self, room_id: str) -> bool: + """Bestaat de room nog? Alleen een duidelijke 404 telt als "weg". + + 200 en 403 (geen lid) betekenen "bestaat". Netwerk- en andere fouten + worden als "bestaat" behandeld, zodat er nooit per ongeluk data + verdwijnt. + """ + base = self._base_url() + h = self._headers() + rid = quote(room_id, safe="") + for path in ( + f"{base}/_matrix/client/v3/rooms/{rid}/state/m.room.create", + f"{base}/_matrix/client/v3/rooms/{rid}/joined_members", + ): + try: + async with self.http.get( + path, headers=h, timeout=_REQUEST_TIMEOUT + ) as r: + if r.status == 404: + continue + return True # 200, 403, … alles behalve 404 = bestaat + except Exception: + return True + return False + async def _validate_all(self) -> Tuple[int, int]: - """Valideer alle rooms in de index; verwijder verdwenen. (removed, total)""" + """Valideer: verwijder alleen rooms die niet meer bestaan (404). + + 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: rid = row["room_id"] - info = await self._get_room_info(rid) - if info is None: + if not await self._room_exists(rid): await self.database.execute( "DELETE FROM roomsoverview_rooms WHERE room_id = $1", rid ) removed += 1 - else: - await self.database.execute( - "UPDATE roomsoverview_rooms SET display_name = $2, " - "canonical_alias = $3, topic = $4, member_count = $5, " - "last_checked = $6 WHERE room_id = $1", - rid, - info["name"] or None, - info["alias"] or None, - info["topic"] or None, - info["members"], - int(time.time()), - ) + 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" ) return [dict(r) for r in rows] + def _sort_rooms(self, rooms: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + return sorted( + rooms, + key=lambda r: ( + r.get("display_name") or r.get("canonical_alias") or r["room_id"] + ).lower(), + ) + + def _build_cards(self, rooms: List[Dict[str, Any]]) -> List[Tuple[str, str]]: + """Bouw per room een blockquote-kaart (html, plain).""" + + # 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 "").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", " ") + 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 = 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: + plain_title += f" · {member_count} leden" + plain_lines.append(plain_title) + if topic: + 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)) + return cards + + async def _send_cards( + self, + evt: MessageEvent, + cards: List[Tuple[str, str]], + subject: str, + count_label: str, + ) -> None: + chunk_size = max(1, int(self.config.get("rooms_per_message", 50) or 50)) + 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"{subject} ({total} {count_label}, 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) + async def _send_list(self, evt: MessageEvent) -> None: - rooms = await self._list_rooms() + rooms = self._sort_rooms(await self._list_rooms()) if not rooms: await evt.reply( "De room-index is nog leeg. Moderators kunnen rooms toevoegen met " "`!rooms add `." ) return + await self._send_cards( + evt, self._build_cards(rooms), "Rooms overzicht", "rooms getoond" + ) - def sort_key(r: Dict[str, Any]) -> str: - return ( - r.get("display_name") - or r.get("canonical_alias") - or r["room_id"] - ).lower() + async def _search_rooms(self, terms: str) -> List[Dict[str, Any]]: + """Zoek rooms: alle termen moeten matchen (case-insensitive, substring) + in naam, alias of omschrijving.""" + needles = [t for t in (part.lower() for part in terms.split()) if t] + if not needles: + return [] + found = [] + for r in await self._list_rooms(): + hay = " ".join( + str(r.get(field) or "").lower() + for field in ("display_name", "canonical_alias", "topic") + ) + if all(n in hay for n in needles): + found.append(r) + return self._sort_rooms(found) - rooms.sort(key=sort_key) + async def _send_search(self, evt: MessageEvent, query: str) -> None: + found = await self._search_rooms(query) + query_label = " ".join(query.split()) + if not found: + await evt.reply(f"Geen rooms gevonden voor `{query_label}`.") + return + await self._send_cards( + evt, + self._build_cards(found), + f"Zoekresultaten voor '{query_label}'", + "gevonden", + ) - lines = [] - for r in rooms: - display = r.get("display_name") or "" - alias = r.get("canonical_alias") or "" - room_id = r["room_id"] - link_target = quote(alias if alias else room_id, safe="!") - member_count = r.get("member_count") - topic = (r.get("topic") or "").strip().replace("\n", " ") - suffix = f" ({member_count} leden)" if member_count is not None else "" - if display: - label = _escape_md(display) - else: - label = room_id - link = f"[{_escape_md(alias) if alias else room_id}](https://matrix.to/#/{link_target})" - if display: - line = f"* **{label}**{suffix} — {link}" - else: - line = f"* {link}{suffix}" - if topic: - line += f" — {_escape_md(topic)}" - lines.append(line) - - chunk_size = max(1, int(self.config.get("rooms_per_message", 50) or 50)) - chunks = [lines[i : i + chunk_size] for i in range(0, len(lines), chunk_size)] - total = len(lines) - - 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".join(chunk), markdown=True) + async def _send_help(self, evt: MessageEvent) -> None: + """Uitgebreide Nederlandstalige handleiding voor !rooms.""" + help_text = ( + "**!rooms** — overzicht van de room-index\n\n" + "Toont alle rooms als kaarten met naam, ledental, omschrijving " + "en een directe join-link. Ruimtes met een eigen icoon tonen die " + "naast de naam.\n\n" + "**Basis:**\n" + "* `!rooms` — toon de volledige lijst (bij veel rooms verdeeld " + "over meerdere berichten)\n" + "* `!rooms over ` — zoek rooms op naam, alias en/of " + "omschrijving (bijv. `!rooms over linux`); alle termen moeten " + "matchen\n\n" + "**Subcommando's (moderator of hoger vereist):**\n" + "* `!rooms add ` — voeg een room toe aan de index\n" + "* `!rooms edit =` — wijzig een " + "veld van een room\n" + "* `!rooms remove ` — verwijder een room uit de index\n" + "* `!rooms refresh` — controleer of rooms nog bestaan en vul " + "ontbrekende avatars aan\n\n" + "**Veld bij edit:** `name` (naam), `topic` (omschrijving) of " + "`alias` (adres). Een lege waarde wist het veld.\n\n" + "**Voorbeelden:**\n" + "* `!rooms add #algemeen:mtux.nl`\n" + "* `!rooms edit #algemeen:mtux.nl name=Algemeen Overleg`\n" + "* `!rooms edit #algemeen:mtux.nl topic=` (wist de omschrijving)\n" + "* `!rooms remove #algemeen:mtux.nl`\n\n" + "Alleen moderators (macht ≥ 50) mogen rooms toevoegen, wijzigen " + "of verwijderen. Iedereen kan de lijst bekijken." + ) + await evt.respond(help_text, markdown=True) @command.new( name="rooms", - help="Toon de room-index; subcommand: add, remove, refresh", + help="Toon de room-index als kaarten; subcommands: over (zoeken), add, edit, remove, refresh, help", require_subcommand=False, ) async def handle_rooms(self, evt: MessageEvent) -> None: await evt.mark_read() await self._send_list(evt) - @handle_rooms.subcommand(name="add", help="Voeg een room toe (moderator+)") + @handle_rooms.subcommand(name="help", help="Toon deze handleiding") + async def rooms_help(self, evt: MessageEvent) -> None: + await self._send_help(evt) + + @handle_rooms.subcommand( + name="over", + help="Zoek rooms op naam, alias of omschrijving: !rooms over ", + ) + @command.argument("terms", pass_raw=True, required=True) + async def search_over(self, evt: MessageEvent, terms: str) -> None: + if not terms.strip(): + await evt.reply( + "Gebruik: `!rooms over ` (bijv. `!rooms over linux`).\n" + "Zoekt op naam, alias en omschrijving; alle termen moeten matchen." + ) + return + await self._send_search(evt, terms) + + @handle_rooms.subcommand( + name="add", + help="Voeg een room toe aan de index: !rooms add (moderator+)", + ) @command.argument("room", pass_raw=True, required=True) async def add_room(self, evt: MessageEvent, room: str) -> None: if not await self._is_mod(evt.room_id, evt.sender): @@ -365,25 +659,82 @@ 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()), ) target = info["alias"] or room_id await evt.reply( f"Room toegevoegd: **{info['name'] or room_id}** " - f"([{target}](https://matrix.to/#/{quote(target, safe='!')}))" + f"([{target}](https://matrix.to/#/{target}))" ) - @handle_rooms.subcommand(name="remove", help="Verwijder een room (moderator+)") + @handle_rooms.subcommand( + name="edit", + help="Wijzig een veld van een room: !rooms edit " + "= (moderator+)", + ) + @command.argument("args", pass_raw=True, required=True) + async def edit_room(self, evt: MessageEvent, args: str) -> None: + if not await self._is_mod(evt.room_id, evt.sender): + await evt.reply("Geen permissie: moderator of hoger vereist.") + return + parsed = _parse_edit_args(args) + if parsed is None: + await evt.reply( + "Gebruik: `!rooms edit =`\n" + "Lege waarde wist het veld (bijv. `alias=`)." + ) + return + room_ref, field, value = parsed + field_ok = {"name": "display_name", "topic": "topic", "alias": "canonical_alias"} + if field not in field_ok: + await evt.reply("Veld moet `name`, `topic` of `alias` zijn.") + return + if field == "alias" and value and not value.startswith("#"): + await evt.reply("Alias moet beginnen met `#` (bijv. `#mijn-room:server.nl`).") + return + room_id = room_ref if room_ref.startswith("!") else await self._resolve_alias(room_ref) + if not room_id: + await evt.reply(f"Room `{room_ref}` kon niet gevonden worden.") + return + new_value = value or None # lege waarde wist het veld (NULL) + column = field_ok[field] + result = await self.database.execute( + f"UPDATE roomsoverview_rooms SET {column} = $2, last_checked = $3 " + "WHERE room_id = $1", + room_id, + new_value, + int(time.time()), + ) + if result != "UPDATE 1": + await evt.reply(f"Room `{room_id}` stond niet in de index.") + return + row = await self.database.fetchrow( + "SELECT display_name, canonical_alias FROM roomsoverview_rooms " + "WHERE room_id = $1", + room_id, + ) + label = row["display_name"] or row["canonical_alias"] or room_id + shown = new_value if new_value is not None else "(leeg)" + field_name = {"name": "naam", "topic": "omschrijving", "alias": "alias"}[field] + await evt.reply( + f"Room bijgewerkt: **{label}** — {field_name} is nu `{shown}`." + ) + + @handle_rooms.subcommand( + name="remove", + help="Verwijder een room uit de index: !rooms remove (moderator+)", + ) @command.argument("room", pass_raw=True, required=True) async def remove_room(self, evt: MessageEvent, room: str) -> None: if not await self._is_mod(evt.room_id, evt.sender): @@ -401,13 +752,18 @@ 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="Controleer of rooms bestaan en vul ontbrekende 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