From 57c19107347e8993adcce10c6c011ac4a35d3968 Mon Sep 17 00:00:00 2001 From: Jan Date: Thu, 24 Sep 2026 18:58:02 +0200 Subject: [PATCH 1/3] 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/ 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) --- plugins/humhub/skills/humhub/SKILL.md | 7 +++++ plugins/humhub/skills/humhub/humhub.py | 37 +++++++++++++++++++------- 2 files changed, 34 insertions(+), 10 deletions(-) diff --git a/plugins/humhub/skills/humhub/SKILL.md b/plugins/humhub/skills/humhub/SKILL.md index a3885f2..2123d27 100644 --- a/plugins/humhub/skills/humhub/SKILL.md +++ b/plugins/humhub/skills/humhub/SKILL.md @@ -102,6 +102,13 @@ und Wiki-Seiten. Empfänger sind Benutzername, Profil-URL oder numerische ID; mehrere mit Komma. +**`recipient` will GUIDs, keine User-IDs.** `mail-send` löst jede Angabe +deshalb bis zur GUID auf (`/user/` 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//entry`). Die ID steht in der URL der Konversation: `…/mail/mail/index?id=35`. diff --git a/plugins/humhub/skills/humhub/humhub.py b/plugins/humhub/skills/humhub/humhub.py index ada3ec1..5d03861 100755 --- a/plugins/humhub/skills/humhub/humhub.py +++ b/plugins/humhub/skills/humhub/humhub.py @@ -350,8 +350,25 @@ 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//) to a user ID. +def user_guid(session: requests.Session, user: dict) -> str: + """GUID eines Benutzers, notfalls über /user/ 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(session: requests.Session, value: str) -> str: + """Resolve a user ID, username, or profile URL (…/u//) 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. 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 +377,7 @@ def resolve_user(session: requests.Session, value: str) -> int: """ value = value.strip() if value.isdigit(): - return int(value) + return user_guid(session, {"id": int(value)}) name = value if "/" in value: teile = [t for t in urlparse(value).path.split("/") if t] @@ -370,7 +387,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 user_guid(session, r.json()) page = 1 while True: @@ -381,7 +398,7 @@ 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 user_guid(session, u) if len(results) < 50: break page += 1 @@ -390,17 +407,17 @@ def resolve_user(session: requests.Session, value: str) -> int: 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: + guids = [resolve_user(session, teil) for teil in args.to.split(",") if teil.strip()] + if not guids: sys.exit("Error: kein Empfänger angegeben") 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}") + empfaenger = ", ".join(guids) + print(f"Nachricht gesendet (Konversation {data.get('id')}) an {empfaenger}") def cmd_mail_reply(args: argparse.Namespace) -> None: -- 2.39.5 From 1c9f95a11f559544f569d36b7b563a8585b4d816 Mon Sep 17 00:00:00 2001 From: Jan Date: Thu, 24 Sep 2026 19:08:58 +0200 Subject: [PATCH 2/3] 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) --- plugins/humhub/.claude-plugin/plugin.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/humhub/.claude-plugin/plugin.json b/plugins/humhub/.claude-plugin/plugin.json index dad056a..a790f3d 100644 --- a/plugins/humhub/.claude-plugin/plugin.json +++ b/plugins/humhub/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "humhub", - "version": "0.3.0", + "version": "0.3.1", "description": "HumHub via REST-API: Wiki-Seiten, Posts, Umfragen und Kommentare auflisten, anzeigen, anlegen und aktualisieren (humhub.py).", "author": { "name": "inmedias.it" -- 2.39.5 From 10ea9277c94d93cb802733fa95e85ea290b97c61 Mon Sep 17 00:00:00 2001 From: Jan Date: Fri, 25 Sep 2026 08:18:04 +0200 Subject: [PATCH 3/3] =?UTF-8?q?humhub:=20Probelauf,=20Zustellpr=C3=BCfung?= =?UTF-8?q?=20und=20mail-user-remove=20(0.4.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/ 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//user/) — das einzige Gegenmittel. Co-Authored-By: Claude Opus 5 --- plugins/humhub/.claude-plugin/plugin.json | 2 +- plugins/humhub/skills/humhub/SKILL.md | 29 +++++- plugins/humhub/skills/humhub/humhub.py | 113 +++++++++++++++++++--- 3 files changed, 127 insertions(+), 17 deletions(-) diff --git a/plugins/humhub/.claude-plugin/plugin.json b/plugins/humhub/.claude-plugin/plugin.json index a790f3d..1742541 100644 --- a/plugins/humhub/.claude-plugin/plugin.json +++ b/plugins/humhub/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "humhub", - "version": "0.3.1", + "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" diff --git a/plugins/humhub/skills/humhub/SKILL.md b/plugins/humhub/skills/humhub/SKILL.md index 2123d27..c6f8f60 100644 --- a/plugins/humhub/skills/humhub/SKILL.md +++ b/plugins/humhub/skills/humhub/SKILL.md @@ -94,13 +94,17 @@ und Wiki-Seiten. ### Direktnachrichten ```bash -./humhub.py mail-send "" [datei|-] +./humhub.py mail-send "" [datei|-] # Probelauf +./humhub.py mail-send "" [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 [datei|-] +./humhub.py mail-user-remove ``` 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/` liefert sie, die Benutzerliste je nach @@ -113,6 +117,27 @@ falsche Richtung. (`POST /mail//entry`). Die ID steht in der URL der Konversation: `…/mail/mail/index?id=35`. +**Eine Fehlzustellung lässt sich nicht löschen.** `DELETE /mail/` 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//user/`). 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//user/` 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 diff --git a/plugins/humhub/skills/humhub/humhub.py b/plugins/humhub/skills/humhub/humhub.py index 5d03861..821851a 100755 --- a/plugins/humhub/skills/humhub/humhub.py +++ b/plugins/humhub/skills/humhub/humhub.py @@ -362,13 +362,8 @@ def user_guid(session: requests.Session, user: dict) -> str: return str(api_get(session, f"/user/{uid}")["guid"]) -def resolve_user(session: requests.Session, value: str) -> str: - """Resolve a user ID, username, or profile URL (…/u//) 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. +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 @@ -377,7 +372,7 @@ def resolve_user(session: requests.Session, value: str) -> str: """ value = value.strip() if value.isdigit(): - return user_guid(session, {"id": 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] @@ -387,7 +382,7 @@ def resolve_user(session: requests.Session, value: str) -> str: r = session.get(f"{BASE_URL}/user/get-by-username", params={"username": name}) if r.ok: - return user_guid(session, r.json()) + return r.json() page = 1 while True: @@ -398,26 +393,107 @@ def resolve_user(session: requests.Session, value: str) -> str: 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 user_guid(session, u) + 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//) 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() - guids = [resolve_user(session, teil) for teil in args.to.split(",") if teil.strip()] - if not guids: + 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": guids, }) - empfaenger = ", ".join(guids) - print(f"Nachricht gesendet (Konversation {data.get('id')}) an {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/` 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: @@ -605,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)") -- 2.39.5