diff --git a/.gitignore b/.gitignore index e14b68d..8182bc4 100644 --- a/.gitignore +++ b/.gitignore @@ -101,10 +101,6 @@ 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 a41b9dd..3f17638 100644 --- a/maubot.yaml +++ b/maubot.yaml @@ -1,6 +1,6 @@ maubot: 0.1.0 id: nl.roomsoverview -version: 2.4.0 +version: 2.1.2 license: MIT modules: - roomsoverview diff --git a/roomsoverview.mbp b/roomsoverview.mbp index 1b34b3d..fd48316 100644 Binary files a/roomsoverview.mbp and b/roomsoverview.mbp differ diff --git a/roomsoverview.py b/roomsoverview.py index 677cf8c..9e69e09 100644 --- a/roomsoverview.py +++ b/roomsoverview.py @@ -1,5 +1,4 @@ import asyncio -import html import json import time from functools import partial @@ -10,7 +9,6 @@ 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 @@ -76,42 +74,18 @@ async def upgrade_v2(conn: Connection, scheme: Scheme) -> None: await conn.execute("DROP TABLE IF EXISTS roomsoverview_cache") -@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() +def _escape_md(text: str) -> str: + """Escape markdown-significant characters when embedding user content.""" + return ( + text.replace("\\", "\\\\") + .replace("`", "\\`") + .replace("*", "\\*") + .replace("_", "\\_") + .replace("[", "\\[") + .replace("]", "\\]") + .replace("<", "\\<") + .replace(">", "\\>") + ) class Config(BaseProxyConfig): @@ -185,11 +159,10 @@ class RoomsOverviewPlugin(Plugin): pass return None - name, topic, alias, avatar = await asyncio.gather( + name, topic, alias = await asyncio.gather( state("m.room.name"), state("m.room.topic"), state("m.room.canonical_alias"), - state("m.room.avatar"), ) members: Optional[int] = None @@ -211,7 +184,6 @@ 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,369 +244,103 @@ 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: 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) - """ + """Valideer alle rooms in de index; verwijder verdwenen. (removed, total)""" async with self._refresh_lock: rows = await self.database.fetch( - "SELECT room_id, avatar FROM roomsoverview_rooms" + "SELECT room_id FROM roomsoverview_rooms" ) removed = 0 for row in rows: rid = row["room_id"] - if not await self._room_exists(rid): + info = await self._get_room_info(rid) + if info is None: await self.database.execute( "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, - ) + 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()), + ) 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, avatar " + "SELECT room_id, display_name, canonical_alias, topic, member_count " "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 = self._sort_rooms(await self._list_rooms()) + 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" - ) - 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) + 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 _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", - ) + rooms.sort(key=sort_key) - 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) + 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) @command.new( name="rooms", - help="Toon de room-index als kaarten; subcommands: over (zoeken), add, edit, remove, refresh, help", + help="Toon de room-index; subcommand: add, remove, refresh", require_subcommand=False, ) async def handle_rooms(self, evt: MessageEvent) -> None: await evt.mark_read() await self._send_list(evt) - @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+)", - ) + @handle_rooms.subcommand(name="add", help="Voeg een room toe (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): @@ -659,82 +365,25 @@ class RoomsOverviewPlugin(Plugin): return await self.database.execute( "INSERT INTO roomsoverview_rooms " - "(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) " + "(room_id, display_name, canonical_alias, topic, member_count, added_by, added_at, last_checked) " + "VALUES ($1, $2, $3, $4, $5, $6, $7, $7) " "ON CONFLICT (room_id) DO UPDATE SET display_name = $2, canonical_alias = $3, " - "topic = $4, member_count = $5, avatar = $6, last_checked = $8", + "topic = $4, member_count = $5, last_checked = $7", 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/#/{target}))" + f"([{target}](https://matrix.to/#/{quote(target, safe='!')}))" ) - @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+)", - ) + @handle_rooms.subcommand(name="remove", help="Verwijder een room (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): @@ -752,18 +401,13 @@ class RoomsOverviewPlugin(Plugin): else: await evt.reply(f"Room `{room_id}` stond niet in de index.") - @handle_rooms.subcommand( - name="refresh", - help="Controleer of rooms bestaan en vul ontbrekende avatars aan (moderator+)", - ) + @handle_rooms.subcommand(name="refresh", help="Valideer de index nu (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"{filled} avatar(s) aangevuld." + f"{removed} verwijderd (bestonden niet meer)." ) \ No newline at end of file