Zum Hauptinhalt springen

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 AnbindungTalk-Bot mit Webhook (seit Nextcloud 27.1 / Talk 17.1)
EingangTalk sendet jede Nachricht eines Chats, in dem der Bot aktiv ist, per POST an eine URL
EchtheitsnachweisHMAC-SHA256-Signatur über Zufallswert und Body
AntwortPOST /ocs/v2.php/apps/spreed/api/v1/bot/{token}/message, signiert
Einrichtungnur über die Kommandozeile (occ) — aus Sicherheitsgründen gibt es keine Oberfläche dafür
DokuNextcloud 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:

OptionWert
secreteine weitere, lange Zufallszeichenkette (nicht das Bot-Secret)
verbspost
payload_path.
responseOK
code200
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"
TeilBedeutung
-f webhookTalk schickt Nachrichten an die URL
-f responseder Bot darf antworten
"Assistent"Anzeigename des Bots im Chat (1–64 Zeichen)
--no-setupoptional: 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.

Ein eigener Chat für den Assistenten

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.content ist 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 unter parameters.

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
});
};
Warum 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:

OptionWert
post_urlhttps://cloud.example.com/ocs/v2.php/apps/spreed/api/v1/bot/{{ token }}/message
methodpost
content_typejson
payload{"message": "{{ text }}", "replyTo": "{{ reply_to }}"}
headers{"OCS-APIRequest": "true", "X-Nextcloud-Talk-Bot-Random": "{{ random }}", "X-Nextcloud-Talk-Bot-Signature": "{{ signature }}"}
emit_messagestrue
emit_on_errortrue

Ersetze cloud.example.com durch die Adresse deiner Nextcloud.

Der Text muss exakt gleich bleiben

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​

  1. Schreib im aktivierten Chat eine Nachricht.
  2. Prüfe im Log von nc_eingang, dass sie angekommen ist, und im Log von nc_uebersetzen, dass das Einheitsformat stimmt.
  3. Verbinde nc_uebersetzen testweise direkt mit einem JavaScript Agent, der antwort_text: 'Echo: ' + p.text setzt, und den mit nc_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):

CodeBedeutungUrsache
201gesendet—
400ungültige Anfrageleerer Text oder replyTo verweist auf keine gültige Nachricht
401Signatur falschanderes Secret, Text verändert, Zufallswert nicht übereinstimmend
404Bot nicht aktivBot im Chat nicht aktiviert (Schritt 4) oder falsches Token
413zu langText über 32.000 Zeichen
429zu viele Fehlversuchenach 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 mit curl von 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.php die Einstellung 'allow_local_remote_servers' => true nötig sein.
  • Der Bot antwortet sich selbst. Der Eingang lässt nur actor.id mit users/ durch — Bots haben bots/…. 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 Funktion reaction — 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.