From 437a39a0a06bf76845f55c6fe4f812f74a1f326e Mon Sep 17 00:00:00 2001 From: Eddy de Vink Date: Tue, 1 Sep 2026 23:31:21 +0200 Subject: [PATCH] Curated room index v2: moderator-managed list, no space traversal, validation on schedule --- base-config.yaml | 25 ++- maubot.yaml | 2 +- roomsoverview.mbp | Bin 4583 -> 4674 bytes roomsoverview.py | 394 +++++++++++++++++++++++++++++----------------- 4 files changed, 259 insertions(+), 162 deletions(-) diff --git a/base-config.yaml b/base-config.yaml index 6075ba2..3d2a839 100644 --- a/base-config.yaml +++ b/base-config.yaml @@ -1,19 +1,16 @@ -# De room-ID van de Space die je wilt overzien, bijv. "!abcdefghijk:jouwserver.tld" -# Let op: gebruik de interne !room-id, geen #alias - je vindt hem in Element via -# "Space instellingen" -> "Geavanceerd" -> "Interne room-ID". -space_id: "!vervang-mij:example.org" +# Rooms-overzicht: gecureerde index +# +# Deze plugin houdt GEEN ruimte-hierarchy bij (dat werd onbeheersbaar door +# cyclische subspace-grafen). In plaats daarvan beheren moderators een eigen +# lijst: `!rooms add `, `!rooms remove <...>`, `!rooms refresh`. -# Hoe diep de bot in geneste subspaces mag kijken. -max_depth: 5 +# Hoe vaak (minuten) de bot controleert of de rooms in de index nog bestaan. +# 24 uur is prima: pas bij ~1440 minuten wordt opnieuw gevalideerd. +refresh_interval_minutes: 1440 # Hoeveel rooms er per bericht getoond worden (Matrix-events hebben een max grootte, -# dus bij ~200 rooms wordt de lijst automatisch over meerdere berichten verdeeld). +# dus bij veel rooms wordt de lijst automatisch over meerdere berichten verdeeld). rooms_per_message: 50 -# Harde bovengrens voor het totale aantal rooms dat wordt opgehaald/verstuurd. -# Voorkomt dat een enorme (gefedereerde) space-boom leidt tot duizenden berichten. -max_rooms: 300 - -# Hoe vaak (minuten) de bot de ruimte-hierarchy opnieuw indexeert op de -# achtergrond. 24 uur is prima: pas bij ~1440 minuten wordt opnieuw geïndexeerd. -refresh_interval_minutes: 1440 \ No newline at end of file +# Harde bovengrens voor het aantal rooms in de index. +max_rooms: 300 \ No newline at end of file diff --git a/maubot.yaml b/maubot.yaml index 24f52ef..554abd6 100644 --- a/maubot.yaml +++ b/maubot.yaml @@ -1,6 +1,6 @@ maubot: 0.1.0 id: nl.roomsoverview -version: 1.0.9 +version: 2.0.0 license: MIT modules: - roomsoverview diff --git a/roomsoverview.mbp b/roomsoverview.mbp index 3e9426b694d9c6265303d499b75814578e2156cc..518c4ce0df2652c02c1dcbfe892c2c39b8b261ea 100644 GIT binary patch delta 4516 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 None: ) +@upgrade_table.register(description="Curated room index") +async def upgrade_v2(conn: Connection, scheme: Scheme) -> None: + if scheme == Scheme.SQLITE: + await conn.execute( + """CREATE TABLE roomsoverview_rooms ( + room_id TEXT PRIMARY KEY, + display_name TEXT, + canonical_alias TEXT, + topic TEXT, + member_count INTEGER, + added_by TEXT, + added_at BIGINT NOT NULL, + last_checked BIGINT NOT NULL, + alive INTEGER NOT NULL DEFAULT 1 + )""" + ) + await conn.execute("DROP TABLE IF EXISTS roomsoverview_cache") + else: + await conn.execute( + """CREATE TABLE roomsoverview_rooms ( + room_id TEXT PRIMARY KEY, + display_name TEXT, + canonical_alias TEXT, + topic TEXT, + member_count INTEGER, + added_by TEXT, + added_at BIGINT NOT NULL, + last_checked BIGINT NOT NULL, + alive BOOLEAN NOT NULL DEFAULT TRUE + )""" + ) + await conn.execute("DROP TABLE IF EXISTS roomsoverview_cache") + + def _escape_md(text: str) -> str: """Escape markdown-significant characters when embedding user content.""" return ( @@ -55,35 +90,31 @@ def _escape_md(text: str) -> str: class Config(BaseProxyConfig): def do_update(self, helper: ConfigUpdateHelper) -> None: - helper.copy("space_id") - helper.copy("max_depth") + helper.copy("refresh_interval_minutes") helper.copy("rooms_per_message") helper.copy("max_rooms") - helper.copy("refresh_interval_minutes") class RoomsOverviewPlugin(Plugin): async def start(self) -> None: self.config.load_and_update() - space_id = str(self.config.get("space_id", "") or "") - interval_minutes = max(1, int(self.config.get("refresh_interval_minutes", 1440) or 1440)) - if not space_id or space_id.startswith("!vervang-mij"): - return self._refresh_lock = asyncio.Lock() self._scheduled_tasks = [] - # Direct eenmalig indexeren (prewarm), daarna periodiek. + interval_minutes = max( + 1, int(self.config.get("refresh_interval_minutes", 1440) or 1440) + ) self._scheduled_tasks.append( - self.sched.run_later(2, self._refresh_cache(space_id)) + self.sched.run_later(2, self._validate_all()) ) self._scheduled_tasks.append( self.sched.run_periodically( interval_minutes * 60, - partial(self._refresh_cache, space_id), + partial(self._validate_all), run_task_in_background=True, ) ) self.log.info( - f"start: indexering gepland elke {interval_minutes} minuten" + f"start: index-validatie gepland elke {interval_minutes} minuten" ) async def stop(self) -> None: @@ -98,164 +129,164 @@ class RoomsOverviewPlugin(Plugin): def get_db_upgrade_table(cls) -> UpgradeTable: return upgrade_table - async def _fetch_hierarchy(self, space_id: str) -> Tuple[List[Dict[str, Any]], bool]: - """Fetch the paginated space hierarchy for a room. + def _base_url(self) -> str: + return str(self.client.api.base_url).rstrip("/") - Uses GET /_matrix/client/v1/rooms/{roomId}/hierarchy, which - paginates depth-first over the whole space tree. Retries on 429 - rate limits and deduplicates rooms by ID across pages. Stops - early at ``max_rooms`` to avoid dumping huge federated trees. + def _headers(self) -> Dict[str, str]: + return {"Authorization": f"Bearer {self.client.api.token}"} - Returns (rooms, complete); ``complete`` is False when the fetch - aborted early (rate limits, request errors, excessive pagination - or the ``max_rooms`` cap), meaning the returned list is partial. + async def _get_room_info( + self, room_id: str + ) -> Optional[Dict[str, Any]]: + """Haal naam/topic/alias/leden van een room op. + + Retourneert None als de room niet meer bestaat (M_NOT_FOUND). """ - rooms: List[Dict[str, Any]] = [] - seen: set[str] = set() - since: Optional[str] = None - base_url = str(self.client.api.base_url).rstrip("/") - token = self.client.api.token - max_depth = max(1, int(self.config.get("max_depth", 5) or 5)) - max_rooms = max(1, int(self.config.get("max_rooms", 300) or 300)) - headers = {"Authorization": f"Bearer {token}"} - url = f"{base_url}/_matrix/client/v1/rooms/{quote(space_id, safe='')}/hierarchy" - pages = 0 + base = self._base_url() + h = self._headers() + rid = quote(room_id, safe="") - while True: - pages += 1 - if pages > 100: - self.log.warning("Hierarchy: meer dan 100 paginas, stoppen") - return rooms, False - params = { - "limit": "100", - "max_depth": str(max_depth), - "suggested_only": "false", - } - if since: - params["since"] = since - retries = 0 - while True: - try: - async with self.http.get( - url, params=params, headers=headers, timeout=_REQUEST_TIMEOUT - ) as resp: - if resp.status == 429: - retries += 1 - try: - retry_after = float(resp.headers.get("Retry-After", "1") or "1") - except ValueError: - retry_after = 1 - self.log.warning( - f"Hierarchy rate-limited (429; poging {retries}): retrying in {retry_after}s" - ) - if retries > 3: - self.log.warning("Hierarchy: te veel 429s, stoppen") - return rooms, False - await asyncio.sleep(min(retry_after, 30)) - continue - if resp.status != 200: - body = await resp.text() - self.log.warning(f"Hierarchy request failed ({resp.status}): {body}") - return rooms, False - data = await resp.json() - break - except asyncio.CancelledError: - raise - except Exception as e: - self.log.warning(f"Hierarchy request error: {e}") - return rooms, False - - for r in data.get("rooms", []): - rid = r.get("room_id") - if rid and rid not in seen: - seen.add(rid) - rooms.append(r) - if len(rooms) >= max_rooms: - self.log.warning( - f"Hierarchy: afgekapt op {max_rooms} rooms (boom bevat meer)" - ) - return rooms[:max_rooms], False - since = data.get("next_batch") - if not since: - break - return rooms, True - - async def _refresh_cache(self, space_id: str) -> None: - """Live ophalen en in de cache opslaan (gedeeld via lock, ook achtergrond).""" - async with self._refresh_lock: - entries, complete = await self._fetch_hierarchy(space_id) - rooms = [ - r - for r in entries - if r.get("room_id") != space_id - and (r.get("room_type") or "") != "m.space" - ] - await self._store_cache(space_id, rooms, complete) - self.log.info(f"indexering voltooid ({len(rooms)} rooms)") - - async def _load_cache( - self, space_id: str - ) -> Optional[Tuple[List[Dict[str, Any]], bool, int]]: - row = await self.database.fetchrow( - "SELECT rooms, complete, fetched_at FROM roomsoverview_cache " - "WHERE space_id = $1", - space_id, - ) - if not row: + async def state(typ: str) -> Optional[Dict[str, Any]]: + try: + async with self.http.get( + f"{base}/_matrix/client/v3/rooms/{rid}/state/{typ}", + headers=h, + timeout=_REQUEST_TIMEOUT, + ) as r: + if r.status == 200: + return await r.json() + except Exception: + pass return None - return json.loads(row["rooms"]), bool(row["complete"]), int(row["fetched_at"]) - async def _store_cache( - self, space_id: str, rooms: List[Dict[str, Any]], complete: bool - ) -> None: - await self.database.execute( - "INSERT INTO roomsoverview_cache (space_id, rooms, complete, fetched_at) " - "VALUES ($1, $2, $3, $4) " - "ON CONFLICT (space_id) DO UPDATE SET rooms = $2, complete = $3, fetched_at = $4", - space_id, - json.dumps(rooms, ensure_ascii=False), - complete, - int(time.time()), + name, topic, alias = await asyncio.gather( + state("m.room.name"), + state("m.room.topic"), + state("m.room.canonical_alias"), ) - @command.new(name="rooms", help="Toon alle rooms in de geconfigureerde space") - async def handle_rooms(self, evt: MessageEvent) -> None: - space_id = str(self.config.get("space_id", "") or "") - if not space_id or space_id.startswith("!vervang-mij"): - await evt.reply( - "Er is nog geen geldige `space_id` ingesteld in de plugin-config." + members: Optional[int] = None + try: + async with self.http.get( + f"{base}/_matrix/client/v3/rooms/{rid}/joined_members", + headers=h, + timeout=_REQUEST_TIMEOUT, + ) as r: + if r.status == 200: + d = await r.json() + members = len(d.get("joined", {})) + elif r.status == 404: + return None # room bestaat niet (meer) + # 403: bestaat, maar bot heeft geen toegang + except Exception: + pass + + return { + "name": (name or {}).get("name", "") or "", + "topic": (topic or {}).get("topic", "") or "", + "alias": (alias or {}).get("alias", "") or "", + "members": members, + } + + async def _resolve_alias(self, alias: str) -> Optional[str]: + """Los een #alias op naar een room_id.""" + a = alias.lstrip("#") + try: + async with self.http.get( + f"{self._base_url()}/_matrix/client/v3/directory/room/{quote(a, safe='')}", + headers=self._headers(), + timeout=_REQUEST_TIMEOUT, + ) as r: + if r.status == 200: + return (await r.json()).get("room_id") + except Exception: + pass + return None + + async def _is_mod(self, room_id: str, user_id: str) -> bool: + """Moderator (power level >= 50) in de gegeven room.""" + try: + async with self.http.get( + f"{self._base_url()}/_matrix/client/v3/rooms/{quote(room_id, safe='')}/state/m.room.power_levels", + headers=self._headers(), + timeout=_REQUEST_TIMEOUT, + ) as r: + if r.status != 200: + return False + d = await r.json() + except Exception: + return False + users = d.get("users", {}) or {} + default = int(d.get("users_default", 0) or 0) + pl = int(users.get(user_id, default) or 0) + return pl >= MOD_POWER_LEVEL + + async def _validate_all(self) -> Tuple[int, int]: + """Valideer alle rooms in de index; verwijder verdwenen. (removed, total)""" + async with self._refresh_lock: + rows = await self.database.fetch( + "SELECT room_id FROM roomsoverview_rooms" ) - return + removed = 0 + for row in rows: + rid = row["room_id"] + 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 + 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) - await evt.mark_read() - - cached = await self._load_cache(space_id) - if cached is None: - # Nog nooit geïndexeerd: vraag een indexering aan en wacht erop - # (lock deelt met de achtergrondtaak, dus geen dubbele fetches). - await self._refresh_cache(space_id) - cached = await self._load_cache(space_id) - rooms, complete = cached[0], cached[1] if cached else ([], True) + async def _list_rooms(self) -> List[Dict[str, Any]]: + rows = await self.database.fetch( + "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] + async def _send_list(self, evt: MessageEvent) -> None: + rooms = await self._list_rooms() if not rooms: await evt.reply( - "Er zijn (nog) geen rooms gevonden. Controleer de space_id en of de bot " - "lid is van (of toegang heeft tot) de space." + "De room-index is nog leeg. Moderators kunnen rooms toevoegen met " + "`!rooms add `." ) return def sort_key(r: Dict[str, Any]) -> str: - return (r.get("name") or r.get("canonical_alias") or r["room_id"]).lower() + return ( + r.get("display_name") + or r.get("canonical_alias") + or r["room_id"] + ).lower() rooms.sort(key=sort_key) lines = [] for r in rooms: - name = _escape_md(r.get("name") or r.get("canonical_alias") or r["room_id"]) + name = _escape_md( + r.get("display_name") or r.get("canonical_alias") or r["room_id"] + ) alias = r.get("canonical_alias") room_id = r["room_id"] link_target = quote(alias if alias else room_id, safe="!") - member_count = r.get("num_joined_members") + member_count = r.get("member_count") topic = _escape_md((r.get("topic") or "").strip().replace("\n", " ")) suffix = f" ({member_count} leden)" if member_count is not None else "" line = f"* [{name}](https://matrix.to/#/{link_target}){suffix}" @@ -269,6 +300,75 @@ class RoomsOverviewPlugin(Plugin): for idx, chunk in enumerate(chunks, start=1): header = f"**Rooms overzicht ({total} rooms getoond, deel {idx}/{len(chunks)}):**\n\n" - if idx == 1 and not complete: - header += "*De lijst is mogelijk onvolledig: de laatste indexering was niet compleet.*\n\n" - await evt.respond(header + "\n".join(chunk), markdown=True) \ No newline at end of file + await evt.respond(header + "\n".join(chunk), markdown=True) + + @command.new(name="rooms", help="Toon de room-index; subcommand: add, remove, refresh") + 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+)") + async def add_room(self, evt: MessageEvent, room: str) -> None: + if not await self._is_mod(evt.room_id, evt.sender): + await evt.reply("Geen permissie: moderator of hoger vereist.") + return + room_id = room if room.startswith("!") else await self._resolve_alias(room) + if not room_id: + await evt.reply(f"Room `{room}` kon niet gevonden worden.") + return + info = await self._get_room_info(room_id) + if info is None: + await evt.reply(f"Room `{room_id}` bestaat niet (of is niet meer bereikbaar).") + return + max_rooms = max(1, int(self.config.get("max_rooms", 300) or 300)) + count = await self.database.fetchval( + "SELECT COUNT(*) FROM roomsoverview_rooms" + ) + if count >= max_rooms: + await evt.reply( + f"Index zit vol ({max_rooms} rooms max). Verwijder eerst iets of verhoog max_rooms." + ) + 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) " + "ON CONFLICT (room_id) DO UPDATE SET display_name = $2, canonical_alias = $3, " + "topic = $4, member_count = $5, last_checked = $7", + room_id, + info["name"] or None, + info["alias"] or None, + info["topic"] or None, + info["members"], + evt.sender, + int(time.time()), + ) + await evt.reply( + f"Room toegevoegd: **{info['name'] or room_id}** " + f"(matrix.to/#/{quote(info['alias'] or room_id, safe='!')})" + ) + + @handle_rooms.subcommand(name="remove", help="Verwijder een room (moderator+)") + async def remove_room(self, evt: MessageEvent, room: str) -> None: + if not await self._is_mod(evt.room_id, evt.sender): + await evt.reply("Geen permissie: moderator of hoger vereist.") + return + room_id = room if room.startswith("!") else await self._resolve_alias(room) + if not room_id: + await evt.reply(f"Room `{room}` kon niet gevonden worden.") + return + result = await self.database.execute( + "DELETE FROM roomsoverview_rooms WHERE room_id = $1", room_id + ) + if result == "DELETE 1": + await evt.reply(f"Room verwijderd: `{room_id}`.") + else: + await evt.reply(f"Room `{room_id}` stond niet in de index.") + + @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.") + return + removed, total = await self._validate_all() + await evt.reply(f"Index gecontroleerd: {total - removed} rooms over, {removed} verwijderd (bestonden niet meer).") \ No newline at end of file