From e00395fc644956742dc9e13bef1b9bd8ed513c2e Mon Sep 17 00:00:00 2001 From: Jan Date: Fri, 11 Sep 2026 08:56:09 +0200 Subject: [PATCH 1/2] humhub: Direktnachrichten senden (mail-send) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die REST-API kann das Mail-Modul, das Skill bisher nicht — Direktnachrichten mussten von Hand in der Weboberflaeche geschrieben werden. Der Stolperstein ist die Empfaengeraufloesung: Profil-Slug und Benutzername sind nicht dasselbe (zu /u/serge/ gehoert "Serge"), und get-by-username unterscheidet Gross-/Kleinschreibung. resolve_user() nimmt deshalb ID, Benutzername oder Profil-URL und faellt auf einen Durchlauf der Benutzerliste zurueck, der beides case-insensitive vergleicht. Kein Befehl zum Lesen von Konversationen: fremde Nachrichten gehoeren nicht ungefragt in einen Modellkontext. Co-Authored-By: Claude Opus 5 (1M context) --- plugins/humhub/skills/humhub/SKILL.md | 19 ++++++++ plugins/humhub/skills/humhub/humhub.py | 62 ++++++++++++++++++++++++++ 2 files changed, 81 insertions(+) diff --git a/plugins/humhub/skills/humhub/SKILL.md b/plugins/humhub/skills/humhub/SKILL.md index 145f442..d4a3a2e 100644 --- a/plugins/humhub/skills/humhub/SKILL.md +++ b/plugins/humhub/skills/humhub/SKILL.md @@ -91,6 +91,25 @@ Beide nehmen wie `fetch` eine **Content-ID** oder Perma-URL (nicht die Post-/ Wiki-Seiten-ID) und lösen das Zielobjekt selbst auf — funktioniert für Posts und Wiki-Seiten. +### Direktnachrichten + +```bash +./humhub.py mail-send "" [datei|-] +./humhub.py mail-send https://humhub.inmedias.it/u/serge/ "Betreff" entwurf.md +./humhub.py mail-send jan.stobbe,26 "Betreff" - +``` + +Empfänger sind Benutzername, Profil-URL oder numerische ID; mehrere mit Komma. + +**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 +`get-by-username`, dann ein Durchlauf der Benutzerliste, der Benutzername und +Slug ohne Rücksicht auf Groß-/Kleinschreibung vergleicht. + +Zum Lesen von Konversationen gibt es bewusst keinen Befehl — Nachrichten +anderer gehören nicht ungefragt in einen Modellkontext. + Inhalte (Markdown) aus Datei oder `-` (stdin) übergeben — nicht als langes Shell-Argument (Sonderzeichen). `container_id` akzeptiert eine numerische Container-ID, einen **Space-Slug** oder eine **Space-URL** (`resolve_container` diff --git a/plugins/humhub/skills/humhub/humhub.py b/plugins/humhub/skills/humhub/humhub.py index 721a208..433798a 100755 --- a/plugins/humhub/skills/humhub/humhub.py +++ b/plugins/humhub/skills/humhub/humhub.py @@ -348,6 +348,61 @@ def cmd_comment_create(args: argparse.Namespace) -> None: print(f"Perma: https://humhub.inmedias.it/content/perma?id={content_id}") +# ── Mail (Direktnachrichten) ───────────────────────────────────────────────── + +def resolve_user(session: requests.Session, value: str) -> int: + """Resolve a user ID, username, or profile URL (…/u//) to a user ID. + + Slug und Benutzername sind nicht dasselbe: zu https://…/u/serge/ gehört der + Benutzername "Serge". `get-by-username` kennt nur den Benutzernamen und + unterscheidet Groß-/Kleinschreibung — deshalb der Fallback über die + Benutzerliste, der auch den Slug vergleicht. + """ + value = value.strip() + if value.isdigit(): + return int(value) + name = value + if "/" in value: + teile = [t for t in urlparse(value).path.split("/") if t] + if teile: + name = teile[-1] + + r = session.get(f"{BASE_URL}/user/get-by-username", + params={"username": name}) + if r.ok: + return int(r.json()["id"]) + + page = 1 + while True: + data = api_get(session, f"/user?limit=50&page={page}") + results = data.get("results", []) + for u in results: + username = (u.get("account") or {}).get("username", "") + 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"]) + if len(results) < 50: + break + page += 1 + sys.exit(f"Error: Benutzer {value!r} nicht gefunden") + + +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: + sys.exit("Error: kein Empfänger angegeben") + message = read_file(args.file) + data = api_post(session, "/mail", { + "title": args.title, + "message": message, + "recipient": ids, + }) + empfaenger = ", ".join(str(i) for i in ids) + print(f"Nachricht gesendet (Konversation {data.get('id')}) an Benutzer {empfaenger}") + + # ── Fetch (perma-/content-id-agnostisch) ───────────────────────────────────── def parse_content_ref(ref: str) -> int: @@ -513,6 +568,13 @@ def main() -> None: p.add_argument("file", help="Markdown-Datei oder '-' für stdin") p.set_defaults(func=cmd_comment_create) + # mail-send + p = sub.add_parser("mail-send", help="Direktnachricht an Benutzer senden") + 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.set_defaults(func=cmd_mail_send) + # post-update p = sub.add_parser("post-update", help="Post aktualisieren") p.add_argument("id", type=int, help="Post-ID") From 2bfc62bcb8eb55a733e8a9a3445737a11929db87 Mon Sep 17 00:00:00 2001 From: Jan Date: Sat, 12 Sep 2026 08:24:23 +0200 Subject: [PATCH 2/2] humhub: in bestehenden Konversationen antworten (mail-reply) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mail-send legt immer eine neue Konversation an; fuer eine Antwort braucht es POST /mail//entry. Die ID steht in der URL der Konversation. Holt den Verlauf nicht ab, sondern prueft nur die Erreichbarkeit — fremde Nachrichten gehoeren nicht ungefragt in einen Modellkontext. Co-Authored-By: Claude Opus 5 (1M context) --- plugins/humhub/skills/humhub/SKILL.md | 15 +++++++++++---- plugins/humhub/skills/humhub/humhub.py | 21 +++++++++++++++++++++ 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/plugins/humhub/skills/humhub/SKILL.md b/plugins/humhub/skills/humhub/SKILL.md index d4a3a2e..a3885f2 100644 --- a/plugins/humhub/skills/humhub/SKILL.md +++ b/plugins/humhub/skills/humhub/SKILL.md @@ -94,13 +94,18 @@ und Wiki-Seiten. ### Direktnachrichten ```bash -./humhub.py mail-send "" [datei|-] -./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 "" [datei|-] +./humhub.py mail-send https://humhub.inmedias.it/u/serge/ "Betreff" entwurf.md +./humhub.py mail-send jan.stobbe,26 "Betreff" - +./humhub.py mail-reply [datei|-] ``` Empfänger sind Benutzername, Profil-URL oder numerische ID; mehrere mit Komma. +`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`. + **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 @@ -108,7 +113,9 @@ Slug einsetzt, bekommt sonst `User not found!`. `mail-send` fängt das ab: erst Slug ohne Rücksicht auf Groß-/Kleinschreibung vergleicht. Zum Lesen von Konversationen gibt es bewusst keinen Befehl — Nachrichten -anderer gehören nicht ungefragt in einen Modellkontext. +anderer gehören nicht ungefragt in einen Modellkontext. Auch `mail-reply` +holt den Verlauf nicht ab, sondern prüft nur, ob die Konversation erreichbar +ist. Inhalte (Markdown) aus Datei oder `-` (stdin) übergeben — nicht als langes Shell-Argument (Sonderzeichen). `container_id` akzeptiert eine numerische diff --git a/plugins/humhub/skills/humhub/humhub.py b/plugins/humhub/skills/humhub/humhub.py index 433798a..ada3ec1 100755 --- a/plugins/humhub/skills/humhub/humhub.py +++ b/plugins/humhub/skills/humhub/humhub.py @@ -403,6 +403,21 @@ def cmd_mail_send(args: argparse.Namespace) -> None: print(f"Nachricht gesendet (Konversation {data.get('id')}) an Benutzer {empfaenger}") +def cmd_mail_reply(args: argparse.Namespace) -> None: + """Antwortet in einer bestehenden Konversation. + + Bewusst ohne Lesebefehl: der Text der Konversation wird nicht abgerufen, + nur ihre Erreichbarkeit geprueft. + """ + session, _ = get_session() + message = read_file(args.file) + r = session.get(f"{BASE_URL}/mail/{args.id}") + if not r.ok: + sys.exit(f"Error {r.status_code}: Konversation {args.id} nicht erreichbar") + data = api_post(session, f"/mail/{args.id}/entry", {"message": message}) + print(f"Antwort geschrieben (Eintrag {data.get('id')}) in Konversation {args.id}") + + # ── Fetch (perma-/content-id-agnostisch) ───────────────────────────────────── def parse_content_ref(ref: str) -> int: @@ -575,6 +590,12 @@ def main() -> None: p.add_argument("file", help="Markdown-Datei oder '-' für stdin") p.set_defaults(func=cmd_mail_send) + # 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)") + p.add_argument("file", help="Markdown-Datei oder '-' für stdin") + p.set_defaults(func=cmd_mail_reply) + # post-update p = sub.add_parser("post-update", help="Post aktualisieren") p.add_argument("id", type=int, help="Post-ID")