Einheit 3 — Nextcloud Talk
Was du nach dieser Einheit kannst: Einen Bot in Nextcloud Talk installieren, seine Nachrichten in einem 42°OS-Workflow empfangen und signierte Antworten in den Chat schicken — als Antwort auf die ursprüngliche Nachricht.
Überblick
| Art der Anbindung | Talk-Bot mit Webhook (seit Nextcloud 27.1 / Talk 17.1) |
| Eingang | Talk sendet jede Nachricht eines Chats, in dem der Bot aktiv ist, per POST an eine URL |
| Echtheitsnachweis | HMAC-SHA256-Signatur über Zufallswert und Body |
| Antwort | POST /ocs/v2.php/apps/spreed/api/v1/bot/{token}/message, signiert |
| Einrichtung | nur über die Kommandozeile (occ) — aus Sicherheitsgründen gibt es keine Oberfläche dafür |
| Doku | Nextcloud Talk — Bots · occ-Befehle |
Du brauchst: Shell-Zugriff auf den Nextcloud-Server mit Rechten für occ, Moderationsrechte im Chat, in dem der Bot arbeiten soll.
Schritt 1 — Ein gemeinsames Secret erzeugen
Bot und Workflow teilen sich ein Geheimnis, mit dem beide Seiten ihre Nachrichten signieren. Es muss 40 bis 128 Zeichen lang sein:
openssl rand -hex 32
Das ergibt 64 Zeichen. Hinterlege das Secret in 42°OS als Credential mit dem Namen nextcloud_bot_secret (Kurs 5, Einheit 2).
Schritt 2 — Den Eingang in 42°OS anlegen
Lege einen neuen Workflow Kanal | Nextcloud Talk an und darin einen Webhook Agent nc_eingang:
| Option | Wert |
|---|---|
secret | eine weitere, lange Zufallszeichenkette (nicht das Bot-Secret) |
verbs | post |
payload_path | . |
response | OK |
code | 200 |
ip_allowlist | öffentliche IP-Adresse deines Nextcloud-Servers |
Speichere den Agent und kopiere die Webhook-URL.
Schritt 3 — Den Bot installieren
Auf dem Nextcloud-Server (Benutzer und Pfad je nach Installation, häufig www-data):
sudo -u www-data php occ talk:bot:install \
-f webhook -f response \
"Assistent" \
"<BOT-SECRET aus Schritt 1>" \
"<WEBHOOK-URL aus Schritt 2>" \
"Digitaler Assistent der Firma"
| Teil | Bedeutung |
|---|---|
-f webhook | Talk schickt Nachrichten an die URL |
-f response | der Bot darf antworten |
"Assistent" | Anzeigename des Bots im Chat (1–64 Zeichen) |
--no-setup | optional: Moderatoren können den Bot dann nicht selbst ein- und ausschalten |
Die ID des Bots zeigt:
sudo -u www-data php occ talk:bot:list
Schritt 4 — Den Bot in einem Chat aktivieren
Ein Bot arbeitet nur in Chats, in denen er ausdrücklich aktiviert ist. Das Token eines Chats ist der letzte Teil seiner Adresse: https://cloud.example.com/call/k7x2mqpa.
sudo -u www-data php occ talk:bot:setup <BOT-ID> <CHAT-TOKEN>
Ohne --no-setup können Moderatoren den Bot auch in den Einstellungen des Chats unter Bots ein- und ausschalten.
Lege für jede Person, die den Assistenten nutzt, eine eigene Unterhaltung mit dem Bot an. Dann sieht niemand die Fragen und Antworten anderer — und der Gesprächsverlauf (Kurs 7) bleibt sauber getrennt.
Schritt 5 — Nachrichten übersetzen
Talk liefert eine Nachricht in dieser Form:
{
"type": "Create",
"actor": { "type": "Person", "id": "users/m.mueller", "name": "Maria Müller" },
"object": {
"type": "Note",
"id": "48211",
"name": "message",
"content": "{\"message\":\"Wie lief der Umsatz im {mention-call1}?\",\"parameters\":{…}}",
"mediaType": "text/markdown"
},
"target": { "type": "Collection", "id": "k7x2mqpa", "name": "Assistent" }
}
Zwei Besonderheiten:
object.contentist ein JSON-String im JSON — er muss ein zweites Mal geparst werden.- Erwähnungen und Dateien stehen als Platzhalter wie
{mention-call1}oder{file}im Text, die Details unterparameters.
Hinter den Webhook Agent kommt ein JavaScript Agent nc_uebersetzen:
Agent.receive = function() {
var p = this.incomingMessage().payload || {};
var body = p.object ? p : (p.web_request || p);
// Nur Nachrichten von Personen (keine Bots, keine Systemereignisse wie Join/Leave/Reaktionen)
if (!body.actor || String(body.actor.id).indexOf('users/') !== 0) { return; }
if (body.type !== 'Create' && body.type !== 'Activity') { return; }
if (!body.object || !body.target) { return; }
var content;
try { content = JSON.parse(body.object.content); } catch (e) { return; }
// Platzhalter wie {mention-call1} durch Namen ersetzen
var params = content.parameters;
var text = String(content.message || '');
var datei = null;
if (params && !Array.isArray(params)) {
for (var key in params) {
if (params.hasOwnProperty(key) && params[key] && params[key].name) {
text = text.split('{' + key + '}').join(params[key].name);
}
}
if (params.file) {
datei = { id: String(params.file.id || ''), name: String(params.file.name || ''),
mime: String(params.file.mimetype || ''), groesse: String(params.file.size || '') };
}
}
// Erwähnung des Bots am Anfang entfernen
text = text.replace(/^@?Assistent[\s,:]*/i, '').trim();
var token = String(body.target.id || '').replace(/[^A-Za-z0-9]/g, '');
var msgId = String(body.object.id || '').replace(/[^0-9]/g, '');
if (!token || !msgId) { return; }
this.createMessage({
kanal: 'nextcloud',
nachricht_id: 'nc-' + msgId,
unterhaltung_id: token,
absender_id: String(body.actor.id).replace('users/', '').replace(/[^A-Za-z0-9._@-]/g, ''),
absender_name: String(body.actor.name || ''),
text: text,
datei: datei,
antwort_auf: msgId
});
};
nc- vor der Nachrichten-ID?Nachrichten-IDs sind nur innerhalb eines Messengers eindeutig. Mit einem Präfix je Kanal kann eine Nextcloud-Nachricht 48211 nie mit einer Synology-Nachricht 48211 verwechselt werden — wichtig für den Doppelschutz.
Dateien
Teilt jemand eine Datei im Chat, enthält die Nachricht nur Name, Typ, Größe und die Datei-ID, nicht die Datei selbst. Um sie zu verarbeiten, lädt der Workflow sie per WebDAV aus Nextcloud — mit einem eigenen technischen Benutzerkonto, das Zugriff auf die geteilten Dateien hat. Prüfe die genaue Struktur von parameters.file im Log einer echten Nachricht, bevor du darauf aufbaust.
Signatur des Eingangs prüfen
Talk signiert jede Nachricht: X-Nextcloud-Talk-Signature = HMAC-SHA256 (Bot-Secret, X-Nextcloud-Talk-Random + Body), als Hex-Zeichenkette. Für die Prüfung braucht der Workflow die beiden Header und den Body exakt so, wie er gesendet wurde. Sieh im Log deines Webhook Agents nach, ob deine Instanz diese Angaben mitliefert. Wenn ja, kannst du die Signatur mit der Funktion hmacSha256Hex aus Schritt 6 im JavaScript Agent prüfen. Wenn nicht, schützen geheime URL, IP-Freigabe und die Nutzerprüfung im Router den Eingang (Einheit 1).
Schritt 6 — Antworten senden
Eine Antwort an Talk muss ebenfalls signiert sein: Header X-Nextcloud-Talk-Bot-Signature = HMAC-SHA256 (Bot-Secret, Zufallswert + Nachrichtentext). Achtung: Signiert wird nur der Text, nicht der ganze JSON-Body.
Der JavaScript Agent nc_signieren erzeugt Zufallswert und Signatur. Weil die Skript-Umgebung keine Krypto-Bibliothek mitbringt, enthält er eine kompakte SHA-256-Implementierung:
Agent.receive = function() {
var p = this.incomingMessage().payload || {};
var secret = this.credential('nextcloud_bot_secret');
if (!secret) { this.error('Credential nextcloud_bot_secret fehlt'); return; }
var text = String(p.antwort_text || '');
if (!text || !p.unterhaltung_id) { return; }
var random = '';
for (var i = 0; i < 64; i++) { random += Math.floor(Math.random() * 16).toString(16); }
this.createMessage({
token: String(p.unterhaltung_id),
text: text,
// replyTo nur auf echte Talk-Nachrichten (Zahl), sonst lehnt Talk mit 400 ab
reply_to: /^[0-9]+$/.test(String(p.antwort_auf || '')) ? String(p.antwort_auf) : '',
random: random,
signature: hmacSha256Hex(secret, random + text)
});
};
function utf8Bytes(str) {
var out = [];
for (var i = 0; i < str.length; i++) {
var c = str.charCodeAt(i);
if (c >= 0xD800 && c <= 0xDBFF && i + 1 < str.length) {
var c2 = str.charCodeAt(i + 1);
if (c2 >= 0xDC00 && c2 <= 0xDFFF) { c = 0x10000 + ((c - 0xD800) << 10) + (c2 - 0xDC00); i++; }
}
if (c < 0x80) out.push(c);
else if (c < 0x800) out.push(0xC0 | (c >> 6), 0x80 | (c & 63));
else if (c < 0x10000) out.push(0xE0 | (c >> 12), 0x80 | ((c >> 6) & 63), 0x80 | (c & 63));
else out.push(0xF0 | (c >> 18), 0x80 | ((c >> 12) & 63), 0x80 | ((c >> 6) & 63), 0x80 | (c & 63));
}
return out;
}
var K256 = [
0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,
0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,
0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,
0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,
0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,
0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,
0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,
0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2
];
function rotr(x, n) { return (x >>> n) | (x << (32 - n)); }
function sha256(bytes) {
var h = [0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19];
var len = bytes.length, m = bytes.slice();
m.push(0x80);
while (m.length % 64 !== 56) m.push(0);
var hi = Math.floor(len * 8 / 0x100000000), lo = (len * 8) >>> 0;
m.push((hi >>> 24) & 255, (hi >>> 16) & 255, (hi >>> 8) & 255, hi & 255, (lo >>> 24) & 255, (lo >>> 16) & 255, (lo >>> 8) & 255, lo & 255);
var w = new Array(64);
for (var i = 0; i < m.length; i += 64) {
var t;
for (t = 0; t < 16; t++) { w[t] = (m[i + t * 4] << 24) | (m[i + t * 4 + 1] << 16) | (m[i + t * 4 + 2] << 8) | m[i + t * 4 + 3]; }
for (t = 16; t < 64; t++) {
var s0 = rotr(w[t - 15], 7) ^ rotr(w[t - 15], 18) ^ (w[t - 15] >>> 3);
var s1 = rotr(w[t - 2], 17) ^ rotr(w[t - 2], 19) ^ (w[t - 2] >>> 10);
w[t] = (w[t - 16] + s0 + w[t - 7] + s1) | 0;
}
var a = h[0], b = h[1], c = h[2], d = h[3], e = h[4], f = h[5], g = h[6], hh = h[7];
for (t = 0; t < 64; t++) {
var S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25), ch = (e & f) ^ (~e & g);
var t1 = (hh + S1 + ch + K256[t] + w[t]) | 0;
var S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22), maj = (a & b) ^ (a & c) ^ (b & c);
var t2 = (S0 + maj) | 0;
hh = g; g = f; f = e; e = (d + t1) | 0; d = c; c = b; b = a; a = (t1 + t2) | 0;
}
h[0] = (h[0] + a) | 0; h[1] = (h[1] + b) | 0; h[2] = (h[2] + c) | 0; h[3] = (h[3] + d) | 0;
h[4] = (h[4] + e) | 0; h[5] = (h[5] + f) | 0; h[6] = (h[6] + g) | 0; h[7] = (h[7] + hh) | 0;
}
var out = [];
for (i = 0; i < 8; i++) out.push((h[i] >>> 24) & 255, (h[i] >>> 16) & 255, (h[i] >>> 8) & 255, h[i] & 255);
return out;
}
function toHex(bytes) { var s = ''; for (var i = 0; i < bytes.length; i++) s += (bytes[i] < 16 ? '0' : '') + bytes[i].toString(16); return s; }
function hmacSha256Hex(key, message) {
var k = utf8Bytes(key);
if (k.length > 64) k = sha256(k);
while (k.length < 64) k.push(0);
var ipad = [], opad = [];
for (var i = 0; i < 64; i++) { ipad.push(k[i] ^ 0x36); opad.push(k[i] ^ 0x5c); }
return toHex(sha256(opad.concat(sha256(ipad.concat(utf8Bytes(message))))));
}
Dahinter der Post Agent nc_senden:
| Option | Wert |
|---|---|
post_url | https://cloud.example.com/ocs/v2.php/apps/spreed/api/v1/bot/{{ token }}/message |
method | post |
content_type | json |
payload | {"message": "{{ text }}", "replyTo": "{{ reply_to }}"} |
headers | {"OCS-APIRequest": "true", "X-Nextcloud-Talk-Bot-Random": "{{ random }}", "X-Nextcloud-Talk-Bot-Signature": "{{ signature }}"} |
emit_messages | true |
emit_on_error | true |
Ersetze cloud.example.com durch die Adresse deiner Nextcloud.
Die Signatur gilt für genau den Text, den nc_signieren erzeugt hat. Verändert irgendetwas den Text auf dem Weg zum Post Agent — ein Liquid-Filter, ein Kürzen, ein zusätzliches Leerzeichen —, lehnt Talk die Nachricht mit 401 ab.
Schritt 7 — Testen
- Schreib im aktivierten Chat eine Nachricht.
- Prüfe im Log von
nc_eingang, dass sie angekommen ist, und im Log vonnc_uebersetzen, dass das Einheitsformat stimmt. - Verbinde
nc_uebersetzentestweise direkt mit einem JavaScript Agent, derantwort_text: 'Echo: ' + p.textsetzt, und den mitnc_signieren. Die Echo-Antwort sollte im Chat als Antwort auf deine Nachricht erscheinen.
Die Antwortcodes von Talk helfen bei der Fehlersuche (Feld status in der Ausgabe von nc_senden):
| Code | Bedeutung | Ursache |
|---|---|---|
201 | gesendet | — |
400 | ungültige Anfrage | leerer Text oder replyTo verweist auf keine gültige Nachricht |
401 | Signatur falsch | anderes Secret, Text verändert, Zufallswert nicht übereinstimmend |
404 | Bot nicht aktiv | Bot im Chat nicht aktiviert (Schritt 4) oder falsches Token |
413 | zu lang | Text über 32.000 Zeichen |
429 | zu viele Fehlversuche | nach vielen 401 sperrt Talk vorübergehend |
Häufige Stolpersteine
- Keine Nachricht kommt an. Erreicht der Nextcloud-Server die 42°OS-Instanz? Steht die richtige IP in
ip_allowlist? Ein Test mitcurlvon der Nextcloud-Shell aus auf die Webhook-URL zeigt es sofort. - Nextcloud im lokalen Netz, Webhook ebenfalls lokal. Nextcloud blockiert standardmäßig Anfragen an lokale Adressen. Dann kann in der
config.phpdie Einstellung'allow_local_remote_servers' => truenötig sein. - Der Bot antwortet sich selbst. Der Eingang lässt nur
actor.idmitusers/durch — Bots habenbots/…. Entfernst du diese Prüfung, entsteht eine Endlosschleife. - Unbekannte Ereignisse. Talk meldet auch, wenn der Bot einem Chat hinzugefügt oder entfernt wird (
Join,Leave), und — mit der Funktionreaction— Reaktionen (Like,Undo). Der Eingang verwirft alles außer neuen Nachrichten.
Kurzer Selbst-Check
Talk antwortet mit 401. Woran liegt es am wahrscheinlichsten?
An der Signatur: Entweder wurde über den falschen Inhalt signiert (über den JSON-Body statt nur den Text), der Text wurde nach dem Signieren verändert, oder das Credential enthält ein anderes Secret als bei talk:bot:install angegeben.