Compare commits

..
4 changed files with 77 additions and 437 deletions

4
.gitignore vendored
View file

@ -101,10 +101,6 @@ 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.4.0
version: 2.1.2
license: MIT
modules:
- roomsoverview

Binary file not shown.

View file

@ -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 <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()
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'<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 = 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 <room-id-of-alias>`."
)
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 <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)
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 <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+)",
)
@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 <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+)",
)
@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)."
)