Compare commits

...
4 changed files with 437 additions and 77 deletions

4
.gitignore vendored
View file

@ -101,6 +101,10 @@ jspm_packages/
ehthumbs.db
Thumbs.db
# Python
__pycache__/
*.pyc
# Test files
test/fixtures/
test/sample-pdfs/

View file

@ -1,6 +1,6 @@
maubot: 0.1.0
id: nl.roomsoverview
version: 2.1.2
version: 2.4.0
license: MIT
modules:
- roomsoverview

Binary file not shown.

View file

@ -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 <room> <veld>=<waarde>`.
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'<img src="{e(avatar_http, quote=True)}" width="32" height="32" '
f'alt="" style="vertical-align:middle;margin-right:6px">'
)
title_html = f"<b>{e(name)}</b>"
if member_count is not None:
title_html += f" · {member_count} leden"
body_parts = [f"<blockquote><p>{avatar_html}{title_html}<br>"]
if topic:
body_parts.append(f"{e(topic)}<br>")
body_parts.append(
f'<a href="https://matrix.to/#/{e(link_target, quote=True)}">'
f"{e(address)}</a></p></blockquote>"
)
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'<p><b>{html.escape(header)}</b></p>'
+ "".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 <room-id-of-alias>`."
)
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 <zoekterm ...>` — 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 <room-id of #alias>` — voeg een room toe aan de index\n"
"* `!rooms edit <room-id of #alias> <veld>=<waarde>` — wijzig een "
"veld van een room\n"
"* `!rooms remove <room-id of #alias>` — 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 <zoekterm ...>",
)
@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 <zoekterm ...>` (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 <room-id of #alias> (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 <room-id of #alias> "
"<name|topic|alias>=<waarde> (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 <room-id-of-alias> <name|topic|alias>=<waarde>`\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 <room-id of #alias> (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."
)