Author SHA1 Message Date
jhsandClaude Opus 5 10ea9277c9 humhub: Probelauf, Zustellprüfung und mail-user-remove (0.4.0)
Die API hat eine Direktnachricht an einen anderen als den in `recipient`
genannten Benutzer zugestellt, obwohl die ID über /user verifiziert war.
Welche Kennung sie wirklich erwartet, ist ungeklärt — und löschen lässt
sich eine Fehlzustellung nicht: DELETE /mail/<id> gibt es in dieser
Version nicht, über die Oberfläche kommt man auch nicht dran.

Deshalb drei Änderungen:

- mail-send sendet nur noch mit --send; ohne das Flag löst es den
  Empfänger auf und zeigt Namen und ID.
- Nach dem Versand wird die Teilnehmerliste gelesen und gemeldet, wer die
  Konversation tatsächlich bekommen hat.
- mail-user-remove nimmt einen Teilnehmer wieder heraus
  (DELETE /mail/<id>/user/<userid>) — das einzige Gegenmittel.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-25 08:31:43 +02:00
jhsandClaude Opus 5 1c9f95a11f humhub: Version 0.3.1
Ohne Versionssprung zieht 'claude plugin marketplace update' den Fix
nicht als neue Fassung.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-24 19:08:58 +02:00
jhsandClaude Opus 5 57c1910734 humhub: mail-send loest Empfaenger zu GUIDs auf
POST /mail nimmt als recipient ausschliesslich GUIDs an. Mit numerischen
User-IDs antwortet die API "400 Validation failed" und "Du kannst keine
Nachricht ohne Empfaenger senden!" - eine Meldung, die nach einem leeren
Feld klingt, obwohl das Feld gefuellt ist.

resolve_user gibt deshalb die GUID zurueck statt der ID; user_guid holt
sie ueber /user/<id> nach, wenn die Benutzerliste sie nicht mitliefert.
Geprueft gegen humhub.inmedias.it mit Benutzername, Profil-URL und
numerischer ID; der Versand mit GUID ist belegt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-24 18:58:02 +02:00
skoandClaude Opus 5 7efb302c10 mediawiki: purge-Subcommand und der Parser-Cache-Fallstrick
wiki.py mit dokumentierer/wiki.py synchronisiert (purge-Subcommand).

SKILL.md: purge und das bisher undokumentierte upload in die
Befehlsliste, dazu ein Abschnitt zu Seiten mit #get_web_data. Die
Vorlagen werden nicht bei jedem Aufruf ausgefuehrt; wer nach einer
Datenaenderung render aufruft und alte Werte sieht, hat meist den Cache
vor sich und nicht kaputte Daten.

description um den Fall erweitert, damit die Skill bei "Wiki zeigt
veraltete Daten" gefunden wird. version 0.1.0 -> 0.2.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 05:10:54 +02:00
5 changed files with 197 additions and 15 deletions

No files matched your search

+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "humhub",
"version": "0.3.0",
"version": "0.4.0",
"description": "HumHub via REST-API: Wiki-Seiten, Posts, Umfragen und Kommentare auflisten, anzeigen, anlegen und aktualisieren (humhub.py).",
"author": {
"name": "inmedias.it"
+34 -2
View File
@@ -94,18 +94,50 @@ und Wiki-Seiten.
### Direktnachrichten
```bash
./humhub.py mail-send <empfaenger> "<betreff>" [datei|-]
./humhub.py mail-send <empfaenger> "<betreff>" [datei|-] # Probelauf
./humhub.py mail-send <empfaenger> "<betreff>" [datei|-] --send # senden
./humhub.py mail-send https://humhub.inmedias.it/u/serge/ "Betreff" entwurf.md
./humhub.py mail-send jan.stobbe,26 "Betreff" -
./humhub.py mail-send jan.stobbe,26 "Betreff" - --send
./humhub.py mail-reply <konversations-id> [datei|-]
./humhub.py mail-user-remove <konversations-id> <empfaenger>
```
Empfänger sind Benutzername, Profil-URL oder numerische ID; mehrere mit Komma.
**Ohne `--send` wird nichts gesendet**, sondern nur aufgelöst und angezeigt,
wer die Nachricht bekäme — der Probelauf ist der Default.
**`recipient` will GUIDs, keine User-IDs.** `mail-send` löst jede Angabe
deshalb bis zur GUID auf (`/user/<id>` liefert sie, die Benutzerliste je nach
Fassung nicht). Mit numerischen IDs antwortet die API `400 Validation failed`
und „Du kannst keine Nachricht ohne Empfänger senden!" — die Meldung klingt
nach einem leeren Feld, obwohl das Feld gefüllt ist, und weist damit in die
falsche Richtung.
`mail-reply` hängt einen Beitrag an eine bestehende Konversation
(`POST /mail/<id>/entry`). Die ID steht in der URL der Konversation:
`…/mail/mail/index?id=35`.
**Eine Fehlzustellung lässt sich nicht löschen.** `DELETE /mail/<id>` gibt es
in dieser API-Version nicht (404, Route unbekannt), und über die Oberfläche
kommt man auch nicht dran. Das einzige Gegenmittel ist
`mail-user-remove`, das einen Teilnehmer wieder aus der Konversation nimmt
(`DELETE /mail/<id>/user/<userid>`). Ob der Empfänger vorher schon eine
Benachrichtigung bekommen hat, bleibt dabei offen.
**Deshalb prüft `mail-send` nach dem Versand die Teilnehmerliste.** Eine
falsche Kennung in `recipient` führt nicht verlässlich zu einer
Fehlermeldung: am 25.09.2026 hat die API einen Versand mit numerischen IDs
mit `HTTP 200` quittiert und die Nachricht an einen **anderen** Benutzer
zugestellt. Der Befehl gibt deshalb aus, wer die Konversation tatsächlich
bekommen hat, und nennt bei einer Abweichung das `mail-user-remove`-Kommando.
Die Teilnehmerliste kann direkt nach dem Anlegen unvollständig sein — im
Zweifel noch einmal nachsehen, bevor man daraus einen Schluss zieht.
Zwei Routen, zwei Kennungen: `POST /mail` will die **GUID**,
`DELETE /mail/<id>/user/<userid>` die **numerische ID**. Beides ist im
Skript gekapselt, `mail-user-remove` nimmt wie alle Befehle Benutzername,
Profil-URL oder ID.
**Slug ≠ Benutzername.** Zur Profil-URL `/u/serge/` gehört der Benutzername
`Serge`, und `get-by-username` unterscheidet Groß-/Kleinschreibung. Wer den
Slug einsetzt, bekommt sonst `User not found!`. `mail-send` fängt das ab: erst
+112 -10
View File
@@ -350,8 +350,20 @@ def cmd_comment_create(args: argparse.Namespace) -> None:
# ── Mail (Direktnachrichten) ─────────────────────────────────────────────────
def resolve_user(session: requests.Session, value: str) -> int:
"""Resolve a user ID, username, or profile URL (…/u/<slug>/) to a user ID.
def user_guid(session: requests.Session, user: dict) -> str:
"""GUID eines Benutzers, notfalls über /user/<id> nachgeladen.
Die Benutzerliste liefert die GUID je nach Fassung nicht mit.
"""
guid = user.get("guid") or (user.get("account") or {}).get("guid")
if guid:
return str(guid)
uid = user.get("id") or (user.get("account") or {}).get("id")
return str(api_get(session, f"/user/{uid}")["guid"])
def resolve_user_info(session: requests.Session, value: str) -> dict:
"""Benutzerdatensatz zu numerischer ID, Benutzername oder Profil-URL.
Slug und Benutzername sind nicht dasselbe: zu https://…/u/serge/ gehört der
Benutzername "Serge". `get-by-username` kennt nur den Benutzernamen und
@@ -360,7 +372,7 @@ def resolve_user(session: requests.Session, value: str) -> int:
"""
value = value.strip()
if value.isdigit():
return int(value)
return api_get(session, f"/user/{int(value)}")
name = value
if "/" in value:
teile = [t for t in urlparse(value).path.split("/") if t]
@@ -370,7 +382,7 @@ def resolve_user(session: requests.Session, value: str) -> int:
r = session.get(f"{BASE_URL}/user/get-by-username",
params={"username": name})
if r.ok:
return int(r.json()["id"])
return r.json()
page = 1
while True:
@@ -381,26 +393,107 @@ def resolve_user(session: requests.Session, value: str) -> int:
slug_teile = [t for t in urlparse(u.get("url", "")).path.split("/") if t]
slug = slug_teile[-1] if slug_teile else ""
if name.lower() in (username.lower(), slug.lower()):
return int(u["id"])
return u
if len(results) < 50:
break
page += 1
sys.exit(f"Error: Benutzer {value!r} nicht gefunden")
def resolve_user(session: requests.Session, value: str) -> str:
"""Resolve a user ID, username, or profile URL (…/u/<slug>/) to a user GUID.
**GUID, nicht ID.** `POST /mail` nimmt als `recipient` ausschließlich
GUIDs an; mit numerischen IDs antwortet die API `400 Validation failed`
und „Du kannst keine Nachricht ohne Empfänger senden!" — eine Meldung,
die nach einem leeren Feld klingt, obwohl das Feld gefüllt ist.
"""
return user_guid(session, resolve_user_info(session, value))
def mail_teilnehmer(session: requests.Session, conv_id: int) -> list[dict]:
"""Teilnehmer einer Konversation. Leere Liste, wenn nicht lesbar."""
r = session.get(f"{BASE_URL}/mail/{conv_id}/users")
if not r.ok:
return []
daten = r.json()
return daten if isinstance(daten, list) else daten.get("results", [])
def eigene_guid(session: requests.Session) -> str:
"""GUID des eigenen Kontos, leer wenn nicht ermittelbar."""
r = session.get(f"{BASE_URL}/user/me")
return str(r.json().get("guid", "")) if r.ok else ""
def cmd_mail_send(args: argparse.Namespace) -> None:
session, _ = get_session()
ids = [resolve_user(session, teil) for teil in args.to.split(",") if teil.strip()]
if not ids:
leute = [resolve_user_info(session, teil)
for teil in args.to.split(",") if teil.strip()]
if not leute:
sys.exit("Error: kein Empfänger angegeben")
guids = [user_guid(session, u) for u in leute]
namen = {g: u.get("display_name", "?") for g, u in zip(guids, leute)}
for g in guids:
print(f"Empfänger: {namen[g]} ({g})")
if not args.send:
print("PROBELAUF — nichts gesendet. Mit --send abschicken.")
return
message = read_file(args.file)
data = api_post(session, "/mail", {
"title": args.title,
"message": message,
"recipient": ids,
"recipient": guids,
})
empfaenger = ", ".join(str(i) for i in ids)
print(f"Nachricht gesendet (Konversation {data.get('id')}) an Benutzer {empfaenger}")
conv_id = data.get("id")
print(f"Nachricht gesendet (Konversation {conv_id})")
# Gegenprobe. Am 25.09.2026 hat die API eine Nachricht mit numerischen IDs
# in `recipient` mit HTTP 200 quittiert und sie an einen *anderen*
# Benutzer zugestellt — sie meldet einen falschen Empfaenger also nicht
# zwingend als Fehler. Deshalb nach jedem Versand nachsehen, wer die
# Konversation tatsaechlich bekommen hat. Loeschen laesst sie sich nicht
# (`DELETE /mail/<id>` gibt es nicht), nur Teilnehmer entfernen.
teilnehmer = mail_teilnehmer(session, conv_id)
if not teilnehmer:
print("ACHTUNG: Teilnehmerliste nicht lesbar — Zustellung nicht geprüft.")
return
print("Teilnehmer laut Server:")
for u in teilnehmer:
print(f" {u.get('display_name')} ({u.get('guid')})")
drin = {str(u.get("guid")) for u in teilnehmer}
ich = eigene_guid(session)
fehlend = [g for g in guids if g not in drin]
fremd = [u for u in teilnehmer
if str(u.get("guid")) not in guids and str(u.get("guid")) != ich]
if fehlend:
print("ACHTUNG: nicht zugestellt an " +
", ".join(f"{namen[g]} ({g})" for g in fehlend))
if fremd:
print("ACHTUNG: unerwartete Teilnehmer — entfernen mit:")
for u in fremd:
print(f" ./humhub.py mail-user-remove {conv_id} {u.get('id')}")
def cmd_mail_user_remove(args: argparse.Namespace) -> None:
"""Nimmt einen Teilnehmer aus einer Konversation.
Der einzige Weg, eine Fehlzustellung zurueckzunehmen: die Konversation
selbst laesst sich weder ueber die API noch ueber die Oberflaeche
loeschen. Anders als beim Versand will diese Route die **numerische ID**,
nicht die GUID.
"""
session, _ = get_session()
user = resolve_user_info(session, args.user)
uid = user.get("id")
r = session.delete(f"{BASE_URL}/mail/{args.id}/user/{uid}")
if not r.ok:
sys.exit(f"Error {r.status_code}: {r.text[:200]}")
print(f"{user.get('display_name')} (ID {uid}) aus Konversation "
f"{args.id} entfernt. Verbleibend:")
for u in mail_teilnehmer(session, args.id):
print(f" {u.get('display_name')} ({u.get('guid')})")
def cmd_mail_reply(args: argparse.Namespace) -> None:
@@ -588,8 +681,17 @@ def main() -> None:
p.add_argument("to", help="Benutzername, Profil-URL oder ID; mehrere mit Komma")
p.add_argument("title", help="Betreff")
p.add_argument("file", help="Markdown-Datei oder '-' für stdin")
p.add_argument("--send", action="store_true",
help="wirklich senden (ohne: Probelauf mit Empfängernamen)")
p.set_defaults(func=cmd_mail_send)
# mail-user-remove
p = sub.add_parser("mail-user-remove",
help="Teilnehmer aus einer Konversation entfernen (Fehlzustellung)")
p.add_argument("id", type=int, help="Konversations-ID")
p.add_argument("user", help="Benutzername, Profil-URL oder ID")
p.set_defaults(func=cmd_mail_user_remove)
# mail-reply
p = sub.add_parser("mail-reply", help="In einer bestehenden Konversation antworten")
p.add_argument("id", type=int, help="Konversations-ID (aus /mail/mail/index?id=NN)")
+25 -2
View File
@@ -1,7 +1,7 @@
---
name: mediawiki
description: MediaWiki-Seiten und -Templates lesen, rendern und schreiben. Use when editing/fetching wiki pages (wiki.inmedias.it, devwiki.inmedias.it) or when the user mentions "wiki", "Vorlage/Template", "Kundenseite".
version: 0.1.0
description: MediaWiki-Seiten und -Templates lesen, rendern, schreiben und deren Parser-Cache leeren. Use when editing/fetching wiki pages (wiki.inmedias.it, devwiki.inmedias.it) or when the user mentions "wiki", "Vorlage/Template", "Kundenseite", or when a page shows stale API data.
version: 0.2.0
---
# MediaWiki (wiki.py)
@@ -37,6 +37,8 @@ oder anderen direkten Server-Eingriffen. Reine Lese-/Render-Checks
./wiki.py -w devwiki search "Suchbegriff" [-n <namespace>] # Titelsuche
./wiki.py -w devwiki update "Kunde:Beispiel GmbH" datei.wiki --summary "..." # speichern
./wiki.py -w wiki update "Titel" - --summary "..." # Inhalt von stdin
./wiki.py -w wiki purge "Titel" ["Titel 2" ...] [--links] # Parser-Cache leeren
./wiki.py -w wiki upload /pfad/bild.png bild.png --description "..." # Datei hochladen
```
Tipp: Beim Schreiben den Inhalt aus einer Datei (`datei.wiki`) oder `-` (stdin)
@@ -57,6 +59,27 @@ dann `.env` im aktuellen Verzeichnis, dann bereits gesetzte Env-Variablen). Sieh
Es empfiehlt sich ein eigener Bot-/Service-Account mit den nötigen Schreibrechten.
## Seiten, die ihre Daten aus einer API holen
Vorlagen mit `#get_web_data` (External Data) werden **nicht** bei jedem
Seitenaufruf ausgeführt. MediaWiki liefert die Seite aus dem Parser-Cache und
fragt die API gar nicht erst an. Ändern sich die Daten hinter der API, zeigt
die Seite weiter beliebig alte Werte — ohne Fehlermeldung, ohne dass es
jemandem auffällt.
Deshalb gehört nach jedem Datenupload ein Purge:
```bash
./wiki.py -w wiki purge "Nextcloud (Hosting)"
```
`--links` schaltet zusätzlich `forcelinkupdate` ein (langsamer, aktualisiert
die Linktabellen) — für reine Datenaktualisierungen nicht nötig.
Das betrifft auch die Fehlersuche: Wer nach einer Änderung `render` aufruft und
die alten Werte sieht, hat meist nicht die Daten kaputt, sondern nur den Cache
vor sich. Erst purgen, dann urteilen.
## Hinweise
- `search` liefert Titel; `render` ist nützlich, um vor/nach dem Schreiben das
Ergebnis zu prüfen, ohne die Seite im Browser zu öffnen.
@@ -110,6 +110,25 @@ def cmd_render(args: argparse.Namespace) -> None:
print(result["parse"]["text"]["*"], end="")
def cmd_purge(args: argparse.Namespace) -> None:
"""Parser-Cache einer oder mehrerer Seiten leeren.
Noetig fuer Seiten, die ihre Daten per #get_web_data aus einer API holen:
MediaWiki rendert sonst aus dem Parser-Cache weiter und fragt die API gar
nicht erst an - die Seite zeigt dann beliebig alte Werte, ohne dass das
irgendwo auffaellt.
"""
site = get_site(args.wiki)
result = site.api("purge", titles="|".join(args.titles),
forcelinkupdate=1 if args.links else None)
for entry in result.get("purge", []):
title = entry.get("title", "?")
if "missing" in entry:
print(f"Error: page '{title}' does not exist", file=sys.stderr)
else:
print(f"Purged '{title}'")
def cmd_upload(args: argparse.Namespace) -> None:
path = Path(args.file_path)
if not path.exists():
@@ -161,6 +180,12 @@ def main() -> None:
render_p.add_argument("title", help="Page title")
render_p.set_defaults(func=cmd_render)
purge_p = sub.add_parser("purge", help="Clear the parser cache of one or more pages")
purge_p.add_argument("titles", nargs="+", help="Page title(s)")
purge_p.add_argument("--links", action="store_true",
help="Also run forcelinkupdate (slower, updates link tables)")
purge_p.set_defaults(func=cmd_purge)
upload_p = sub.add_parser("upload", help="Upload a local file to the wiki file store")
upload_p.add_argument("file_path", help="Absolute path to the local file")
upload_p.add_argument("wiki_filename", help="Target filename on the wiki (without 'File:' prefix)")