Avatar icons in room cards: fetch m.room.avatar + matrixrooms.info enrichment, store in DB, render 32px icon; v2.2.1
This commit is contained in:
parent
efc68c6f06
commit
11c8c5ca15
3 changed files with 210 additions and 56 deletions
|
|
@ -1,6 +1,6 @@
|
|||
maubot: 0.1.0
|
||||
id: nl.roomsoverview
|
||||
version: 2.1.7
|
||||
version: 2.2.1
|
||||
license: MIT
|
||||
modules:
|
||||
- roomsoverview
|
||||
|
|
|
|||
Binary file not shown.
264
roomsoverview.py
264
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,25 @@ 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"
|
||||
)
|
||||
|
||||
|
||||
class Config(BaseProxyConfig):
|
||||
|
|
@ -159,10 +168,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 +194,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,
|
||||
}
|
||||
|
||||
|
|
@ -272,12 +283,14 @@ class RoomsOverviewPlugin(Plugin):
|
|||
async def _validate_all(self) -> Tuple[int, int]:
|
||||
"""Valideer: verwijder alleen rooms die niet meer bestaan (404).
|
||||
|
||||
Werkt metadata NOOIT bij, alleen een duidelijke 404 op meerdere
|
||||
endpoints verwijdert een rij. (removed, total)
|
||||
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:
|
||||
|
|
@ -287,13 +300,119 @@ class RoomsOverviewPlugin(Plugin):
|
|||
"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,
|
||||
)
|
||||
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"
|
||||
)
|
||||
|
|
@ -317,50 +436,82 @@ class RoomsOverviewPlugin(Plugin):
|
|||
|
||||
rooms.sort(key=sort_key)
|
||||
|
||||
rooms_blocks = []
|
||||
# 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 ""
|
||||
alias = r.get("canonical_alias") or ""
|
||||
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", " ")
|
||||
# Elke room wordt een blockquote-kaart: een afgebakend blok met
|
||||
# gekleurde kantlijn, naam + ledental als kop, omschrijving eronder
|
||||
# en onderaan de join-link.
|
||||
#
|
||||
# Regels binnen de kaart scheiden met HARD BREAKS (twee trailing
|
||||
# spaties), niet met een kale newline: mautrix rendert een soft
|
||||
# newline binnen een paragraaf als spatie, en dan plakken naam,
|
||||
# topic en link aan elkaar. Twee spaties voor \n geven <br /> in
|
||||
# de formatted_body (echte nieuwe regel in Element).
|
||||
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 = _escape_md(alias if alias else room_id)
|
||||
lines = []
|
||||
title = _escape_md(display) if display else address
|
||||
meta = f"**{title}**"
|
||||
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:
|
||||
meta += f" · {member_count} leden"
|
||||
lines.append(meta)
|
||||
plain_title += f" · {member_count} leden"
|
||||
plain_lines.append(plain_title)
|
||||
if topic:
|
||||
lines.append(_escape_md(topic))
|
||||
# Linktekst = full matrix.to URI (fragment mag rauw # en : bevatten)
|
||||
lines.append(f"[{address}](https://matrix.to/#/{link_target})")
|
||||
cards = []
|
||||
for i, ln in enumerate(lines):
|
||||
suffix = " " if i < len(lines) - 1 else ""
|
||||
cards.append(f"> {ln}{suffix}")
|
||||
rooms_blocks.append("\n".join(cards))
|
||||
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))
|
||||
|
||||
chunk_size = max(1, int(self.config.get("rooms_per_message", 50) or 50))
|
||||
chunks = [
|
||||
rooms_blocks[i : i + chunk_size]
|
||||
for i in range(0, len(rooms_blocks), chunk_size)
|
||||
]
|
||||
total = len(rooms_blocks)
|
||||
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"**Rooms overzicht ({total} rooms getoond, deel {idx}/{len(chunks)}):**\n\n"
|
||||
await evt.respond(header + "\n\n".join(chunk), markdown=True)
|
||||
header = f"Rooms overzicht ({total} rooms getoond, 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)
|
||||
|
||||
@command.new(
|
||||
name="rooms",
|
||||
|
|
@ -396,15 +547,16 @@ 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()),
|
||||
)
|
||||
|
|
@ -432,13 +584,15 @@ 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="Valideer de index en vul 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."
|
||||
)
|
||||
Loading…
Reference in a new issue