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
5 changed files with 148 additions and 21 deletions

No files matched your search

+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "humhub", "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).", "description": "HumHub via REST-API: Wiki-Seiten, Posts, Umfragen und Kommentare auflisten, anzeigen, anlegen und aktualisieren (humhub.py).",
"author": { "author": {
"name": "inmedias.it" "name": "inmedias.it"
+34 -2
View File
@@ -94,18 +94,50 @@ und Wiki-Seiten.
### Direktnachrichten ### Direktnachrichten
```bash ```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 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-reply <konversations-id> [datei|-]
./humhub.py mail-user-remove <konversations-id> <empfaenger>
``` ```
Empfänger sind Benutzername, Profil-URL oder numerische ID; mehrere mit Komma. 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 `mail-reply` hängt einen Beitrag an eine bestehende Konversation
(`POST /mail/<id>/entry`). Die ID steht in der URL der Konversation: (`POST /mail/<id>/entry`). Die ID steht in der URL der Konversation:
`…/mail/mail/index?id=35`. `…/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 **Slug ≠ Benutzername.** Zur Profil-URL `/u/serge/` gehört der Benutzername
`Serge`, und `get-by-username` unterscheidet Groß-/Kleinschreibung. Wer den `Serge`, und `get-by-username` unterscheidet Groß-/Kleinschreibung. Wer den
Slug einsetzt, bekommt sonst `User not found!`. `mail-send` fängt das ab: erst 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) ───────────────────────────────────────────────── # ── Mail (Direktnachrichten) ─────────────────────────────────────────────────
def resolve_user(session: requests.Session, value: str) -> int: def user_guid(session: requests.Session, user: dict) -> str:
"""Resolve a user ID, username, or profile URL (…/u/<slug>/) to a user ID. """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 Slug und Benutzername sind nicht dasselbe: zu https://…/u/serge/ gehört der
Benutzername "Serge". `get-by-username` kennt nur den Benutzernamen und 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() value = value.strip()
if value.isdigit(): if value.isdigit():
return int(value) return api_get(session, f"/user/{int(value)}")
name = value name = value
if "/" in value: if "/" in value:
teile = [t for t in urlparse(value).path.split("/") if t] 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", r = session.get(f"{BASE_URL}/user/get-by-username",
params={"username": name}) params={"username": name})
if r.ok: if r.ok:
return int(r.json()["id"]) return r.json()
page = 1 page = 1
while True: 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_teile = [t for t in urlparse(u.get("url", "")).path.split("/") if t]
slug = slug_teile[-1] if slug_teile else "" slug = slug_teile[-1] if slug_teile else ""
if name.lower() in (username.lower(), slug.lower()): if name.lower() in (username.lower(), slug.lower()):
return int(u["id"]) return u
if len(results) < 50: if len(results) < 50:
break break
page += 1 page += 1
sys.exit(f"Error: Benutzer {value!r} nicht gefunden") 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: def cmd_mail_send(args: argparse.Namespace) -> None:
session, _ = get_session() session, _ = get_session()
ids = [resolve_user(session, teil) for teil in args.to.split(",") if teil.strip()] leute = [resolve_user_info(session, teil)
if not ids: for teil in args.to.split(",") if teil.strip()]
if not leute:
sys.exit("Error: kein Empfänger angegeben") 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) message = read_file(args.file)
data = api_post(session, "/mail", { data = api_post(session, "/mail", {
"title": args.title, "title": args.title,
"message": message, "message": message,
"recipient": ids, "recipient": guids,
}) })
empfaenger = ", ".join(str(i) for i in ids) conv_id = data.get("id")
print(f"Nachricht gesendet (Konversation {data.get('id')}) an Benutzer {empfaenger}") 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: 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("to", help="Benutzername, Profil-URL oder ID; mehrere mit Komma")
p.add_argument("title", help="Betreff") p.add_argument("title", help="Betreff")
p.add_argument("file", help="Markdown-Datei oder '-' für stdin") 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) 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 # mail-reply
p = sub.add_parser("mail-reply", help="In einer bestehenden Konversation antworten") 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)") p.add_argument("id", type=int, help="Konversations-ID (aus /mail/mail/index?id=NN)")
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "mediawiki", "name": "mediawiki",
"version": "0.1.1", "version": "0.1.0",
"description": "MediaWiki-Seiten/Templates lesen, rendern und schreiben via wiki.py (fetch/update/render/search, Instanzen wiki & devwiki).", "description": "MediaWiki-Seiten/Templates lesen, rendern und schreiben via wiki.py (fetch/update/render/search, Instanzen wiki & devwiki).",
"author": { "author": {
"name": "inmedias.it" "name": "inmedias.it"
@@ -88,7 +88,6 @@ def cmd_search(args: argparse.Namespace) -> None:
sys.exit(f"Error: unknown namespace '{ns_key}'. " sys.exit(f"Error: unknown namespace '{ns_key}'. "
f"Known: all, {', '.join(NAMESPACES)}") f"Known: all, {', '.join(NAMESPACES)}")
try:
result = site.api( result = site.api(
"query", list="search", "query", list="search",
srsearch=args.query, srsearch=args.query,
@@ -96,12 +95,6 @@ def cmd_search(args: argparse.Namespace) -> None:
srlimit=50, srlimit=50,
srprop="title|snippet", srprop="title|snippet",
) )
except mwclient.errors.APIError as exc:
if exc.code == "cirrussearch-parse-error":
sys.exit(f"Error: Suchanfrage nicht verstanden: {args.query!r}\n"
f"Hinweis: 'insource:wort' funktioniert, die Regex-Form "
f"'insource:/muster/' ist auf dieser Instanz nicht freigeschaltet.")
sys.exit(f"Error: Suche fehlgeschlagen ({exc.code}): {exc.info}")
hits = result["query"]["search"] hits = result["query"]["search"]
if not hits: if not hits:
print("(keine Ergebnisse)", file=sys.stderr) print("(keine Ergebnisse)", file=sys.stderr)