Reaction Roles und die Tücke der Events
Auf ein Emoji klicken, eine Rolle bekommen. Das einfachste Feature überhaupt — bis man merkt, dass Discord Ereignisse für alte Nachrichten gar nicht erst liefert.
971 Wörter · 5 Min. Lesezeit
- yurna
- discord
- architektur
Reaction Roles gehören zu den Features, die man in zwanzig Minuten baut und danach ein halbes Jahr lang nachbessert. Die Idee: Eine Nachricht mit mehreren Emoji, wer draufklickt, bekommt eine Rolle, wer die Reaktion entfernt, verliert sie wieder.
client.on("messageReactionAdd", async (reaktion, nutzer) => {
const zuordnung = await zuordnungFinden(reaktion.message.id, reaktion.emoji.name);
if (!zuordnung) return;
const mitglied = await reaktion.message.guild.members.fetch(nutzer.id);
await mitglied.roles.add(zuordnung.rolleId);
});Funktioniert. Auf frischen Nachrichten. Und genau da fängt das Problem an.
Der Zwischenspeicher, der alles erklärt
Ein Discord-Bot hält im Speicher, was er mitbekommen hat: Server, Kanäle, Rollen, und die Nachrichten, die während seiner Laufzeit geschrieben wurden. Für Ereignisse zu Objekten, die er nicht kennt, gibt es standardmäßig nichts.
Praktisch bedeutet das: Nach jedem Neustart funktioniert die Reaction-Role-Nachricht von vorgestern nicht mehr, weil der Bot sie nicht im Speicher hat. Für den Nutzer sieht das aus wie ein kaputtes Feature, ohne dass sich irgendetwas geändert hätte.
Die Lösung besteht aus zwei Teilen. Erstens: die Ereignisse auch für nicht zwischengespeicherte Objekte anfordern — bei discord.js über die Angabe von Teilobjekten:
const client = new Client({
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessageReactions],
partials: [Partials.Message, Partials.Channel, Partials.Reaction],
});Zweitens: bei solchen Teilobjekten den vollständigen Datensatz nachladen, bevor man damit arbeitet:
if (reaktion.partial) {
try {
await reaktion.fetch();
} catch {
return; // Nachricht gelöscht — dann gibt es auch nichts zu tun
}
}Diese beiden Blöcke sind der Unterschied zwischen einem Feature, das im Test funktioniert, und einem, das im Betrieb funktioniert. Ich habe sie nach der dritten Beschwerde eingebaut.
Die weiteren Kanten
Der Bot reagiert auf sich selbst. Wenn der Bot die Emoji unter seine eigene Nachricht setzt, löst das Ereignisse aus. Ohne Prüfung vergibt er Rollen an sich.
Rollenhierarchie. Der Bot kann nur Rollen vergeben, die unter seiner höchsten eigenen Rolle stehen. Der Fehler dabei ist unspezifisch, und die Ursache liegt in Discords Einstellungen, nicht im Code. Ich habe deshalb eine Prüfung eingebaut, die beim Einrichten warnt statt beim ersten Klick zu scheitern.
Gelöschte Rollen und Nachrichten. Beides passiert ständig. Zuordnungen zu nicht mehr existierenden Objekten müssen aufgeräumt werden, sonst sammeln sich Karteileichen, die bei jedem Ereignis geprüft werden.
Mehrere Klicks in Folge. Wer schnell mehrere Emoji anklickt, löst mehrere gleichzeitige Rollenänderungen aus. Discord kann dabei durcheinanderkommen, weil jede Änderung vom Zustand davor ausgeht. Eine Warteschlange pro Mitglied hat das gelöst.
Infobox
Genau dieses Feature war für mich der Anlass, das erste Mal ernsthaft über Ereignisverarbeitung nachzudenken. Ein Ereignis ist keine Tatsache, sondern eine Mitteilung — sie kann fehlen, doppelt kommen oder sich auf einen Zustand beziehen, der nicht mehr gilt. Wer Ereignisse behandelt, als wären sie zuverlässig und einmalig, baut Systeme, die im Kleinen funktionieren und im Betrieb driften.
Der Umbau auf Knöpfe
Als Discord Interaktionskomponenten einführte, habe ich das Feature ein zweites Mal gebaut — mit Knöpfen und Auswahlmenüs statt Reaktionen. Das löst mehrere Probleme auf einmal:
- Es gibt kein Ereignis für „Reaktion entfernt", sondern einen ausdrücklichen Klick, auf den man mit Umschalten reagiert.
- Der Bot bekommt eine Interaktion mit vollständigem Kontext, kein Teilobjekt.
- Man kann eine private Antwort geben („Du hast jetzt die Rolle X"), was vorher nicht ging.
- Es sieht besser aus, und Nutzer verstehen einen Knopf sofort.
Beide Varianten liefen danach parallel, weil bestehende Nachrichten weiter funktionieren sollten. Das ist ein wiederkehrendes Muster in diesem Projekt: Der Übergang dauert länger als der Neubau.
Was Ereignisse unzuverlässig macht
Der Umgang mit Reaktionen war mein erster Kontakt mit einer Eigenschaft, die für alle ereignisbasierten Systeme gilt: Ein Ereignis ist eine Mitteilung, kein Zustand.
Vier Fälle, die daraus folgen und die man behandeln muss:
Das Ereignis kommt nicht. Verbindungsabbruch, Neustart, ein Objekt außerhalb des Zwischenspeichers. Wer sich darauf verlässt, jede Änderung mitzubekommen, hat irgendwann einen Zustand, der nicht stimmt.
Das Ereignis kommt doppelt. Nach einem Verbindungsabbruch werden Ereignisse teilweise nachgeliefert. Eine Verarbeitung, die nicht wiederholbar ist, erzeugt dabei Fehler.
Das Ereignis kommt zu spät. Es bezieht sich auf einen Zustand, der inzwischen anders ist. Eine Rolle, die vergeben werden soll, existiert nicht mehr.
Die Ereignisse kommen in anderer Reihenfolge. Bei parallelen Verarbeitungen kann „hinzugefügt" nach „entfernt" ankommen.
Die Gegenmittel sind immer dieselben: Verarbeitung unschädlich wiederholbar machen, den Zielzustand prüfen statt anzunehmen, und regelmäßig abgleichen statt sich nur auf Meldungen zu verlassen.
Der Abgleich, den ich nachgerüstet habe
Genau diesen dritten Punkt habe ich später ergänzt: Beim Betreten eines Servers prüft der Bot, ob die Rollen einer Person zu ihrem gespeicherten Zustand passen, und korrigiert die Abweichung.
Damit ist jedes verpasste Ereignis nur noch eine Verzögerung, kein dauerhafter Fehler. Das System repariert sich beim nächsten Kontakt selbst.
Diese Denkweise — einen gewünschten Zustand beschreiben und regelmäßig abgleichen, statt jede Änderung einzeln zu verarbeiten — ist mir seitdem in ganz anderen Bereichen wiederbegegnet. Sie ist aufwendiger als eine reine Ereignisverarbeitung und deutlich robuster, weil sie keine Annahme über Zustellung enthält.
Was ich über Zwischenspeicher gelernt habe
Der Kern dieses Problems war ein Zwischenspeicher, dessen Grenzen ich nicht kannte. Daraus sind drei Fragen geworden, die ich bei jeder Bibliothek mit eigenem Zwischenspeicher stelle.
Was landet überhaupt darin? Bei einem Discord-Client sind das die Objekte, die während der Laufzeit vorkamen — nicht alles, was existiert.
Was passiert bei einem Zugriff auf etwas, das nicht darin ist? Kommt eine leere Antwort, ein Fehler oder wird nachgeladen? Bei mir kam ein Teilobjekt, das man erst vervollständigen muss — und das ist die Antwort, mit der man am wenigsten rechnet.
Wie groß darf er werden? Ein Zwischenspeicher ohne Obergrenze ist ein Speicherleck mit Verzögerung. Bei langlaufenden Prozessen ist das einer der häufigsten Gründe für langsam steigenden Verbrauch.
Die dritte Frage habe ich mir erst später gestellt — und dabei festgestellt, dass mein Bot Objekte sammelte, die er nie wieder brauchte.
Was ich mitnehme
Reaction Roles sind mein Lieblingsbeispiel dafür, wie irreführend die Einschätzung „das ist einfach" sein kann. Die Kernfunktion sind fünf Zeilen. Alles, was es zu einem verlässlichen Feature macht — Teilobjekte, Aufräumen, Reihenfolge, Hierarchieprüfung, Fehlerbehandlung — ist ein Vielfaches davon.
Ich habe mir angewöhnt, bei Aufwandsschätzungen nicht die Kernfunktion zu schätzen, sondern eine einzige Frage zu beantworten: Was passiert damit nach einem Neustart, nach einem Löschvorgang und bei zehn gleichzeitigen Aufrufen? Wenn ich das nicht in einem Satz beantworten kann, ist meine Schätzung wertlos.