Discord Ticket Bot selbst bauen: Support-Tickets in Python ohne Fremd-Bot
Wenn auf deinem Game-Server etwas schiefgeht, landen die Meldungen meistens im öffentlichen Chat oder als DM bei irgendeinem Admin. Ein Ticket-System bringt da Ordnung rein. Ein Spieler tippt /ticket, wählt ein Thema und beschreibt das Problem. Dann öffnet sich ein privater Thread (ein eigener Gesprächsstrang im Channel), den nur er und das Team sehen. In diesem Guide baust du so ein System in Python selbst, ohne einen fremden Ticket-Bot mit Dashboard und Premium-Stufen. Als Beispiel dient der Cobblemon-Server CobbleBox, auf dessen Discord genau dieses Script läuft.
Kurz erklärt
- Wofür: Jede Support-Anfrage bekommt einen eigenen Platz, den nur der Spieler und dein Team sehen. Nichts geht mehr im Chat oder in Admin-DMs unter.
- So sieht es für Mitglieder aus:
/tickettippen, Thema wählen, Problem beschreiben. Dann landen sie in einem privaten Thread, das Team wird gepingt, und ein roter Button „Ticket schließen“ beendet das Ticket. - Für wen: Owner und Moderatoren einer Discord-Community, etwa rund um einen Game-Server, die keinen fremden Ticket-Bot wollen.
- Was du brauchst: eine Discord-Application mit Bot (kostenlos im Developer Portal), Python 3.10 oder neuer, etwas Python-Erfahrung, Admin-Rechte auf deinem Discord-Server und eine öffentlich erreichbare HTTPS-Adresse, etwa über einen Reverse Proxy wie Caddy oder nginx.
- Aufwand: rund 200 Zeilen Python plus ein kleines Empfangs-Script, keine Datenbank nötig. Außer der Standard-Library brauchst du nur das Paket PyNaCl für die Signaturprüfung.
Was das Ticket-System können soll
Der Funktionsumfang ist bewusst klein gehalten:
/ticketmit zwei Pflichtfeldern: Thema (Auswahlliste) und Beschreibung.- Pro Ticket ein privater Thread in einem eigenen Channel
#tickets, benannt nach dem Muster#007 Bug · Spielername. - Der Spieler wird in den Thread geholt, die Team-Rollen werden gepingt.
- Ein Button „Ticket schließen“, den der Ersteller oder das Team drücken kann. Der Thread wird dann archiviert und gesperrt.
- Pro Nutzer nur ein offenes Ticket gleichzeitig.
- Ein Log aller Tickets in einer JSON-Datei.
Auf CobbleBox gibt es fünf Themen: Bug, Spieler melden, Verlust / Rückerstattung, Frage und Sonstiges. Die Beschreibung ist auf 1000 Zeichen begrenzt.
Das Script spricht die Discord-API direkt an
Viele Tutorials nehmen discord.py (eine Python-Library für Bots) mit Cogs, Views und Decorators. Das CobbleBox-Script kommt mit weniger aus. Es spricht die Discord-REST-API direkt an, also die Schnittstelle, über die Programme mit Discord reden. Dafür reicht ein kleiner Helper call(method, path, body), der den Bot-Token (das geheime Passwort deines Bots) als Header mitschickt. Slash Commands (Befehle mit /), Buttons und Antworten sind dabei einfach JSON-Objekte, so wie sie in der Discord-Doku stehen. JSON ist ein Textformat aus Name-Wert-Paaren. Das Ticket-Modul selbst hat nur Standard-Library-Imports (json, os, threading, time, urllib). Das gilt für das Modul. Damit die Eingaben überhaupt bei deinem Bot ankommen, brauchst du noch einen kleinen Empfangsteil, und der kommt nicht ganz ohne Zusatzpaket aus.
Interactions sind Eingaben wie Befehle oder Button-Klicks. Die beiden Handler (die Funktionen für Befehl und Button) bekommen das Interaction-Objekt als Dictionary (Pythons Liste aus Name-Wert-Paaren) und geben auch die Antwort so zurück. Discord bietet zwei Wege, auf denen Interactions ankommen, und du nutzt immer nur einen davon:
- Gateway: eine dauerhafte WebSocket-Verbindung zu Discord, so wie sie discord.py aufbaut. Die Antwort schickst du dann selbst per
POST /interactions/<interaction-id>/<interaction-token>/callbackmit dem Dictionary als Body. Für das Gateway brauchst du eine WebSocket-Library, mit der Standard-Library allein wird das mühsam. - HTTP Interactions Endpoint: Discord schickt jede Interaction als POST an eine Web-Adresse, die du im Developer Portal einträgst. Die Antwort ist einfach der Body deiner HTTP-Antwort. Dafür musst du jede Anfrage per Ed25519-Signatur prüfen (Header
X-Signature-Ed25519undX-Signature-Timestamp).
Für einen neuen Bot nimmst du am besten den HTTP-Endpoint. Er passt genau zu Handlern, die ein Dictionary zurückgeben, und du brauchst nur ein Paket: PyNaCl für die Signaturprüfung. Den fertigen Empfänger findest du in Schritt 8. Läuft dein Bot schon mit discord.py über das Gateway, lässt du ihn dort und schickst das Dictionary der Handler über den Callback-Endpoint oben. Sobald im Developer Portal eine Endpoint-URL eingetragen ist, kommen Interactions nicht mehr über das Gateway.
Vorbereitung: Bot anlegen und einladen
Im Discord Developer Portal legst du mit „New Application“ eine App an. Auf der Seite „General Information“ findest du die Application ID und den Public Key, beide brauchst du gleich. Unter „Bot“ erzeugst du mit „Reset Token“ den Bot-Token. Den Token zeigst du niemandem. Speicher ihn als Umgebungsvariable, dann landet er nie aus Versehen im Code:
pip install pynacl
export DISCORD_TOKEN="dein-bot-token"
export DISCORD_APP_ID="deine-application-id"
export DISCORD_PUBLIC_KEY="dein-public-key"
Eingeladen wird der Bot mit den Scopes bot und applications.commands. Die Rechte im Link sind Kanal anzeigen (View Channel), Threads verwalten (Manage Threads), Private Threads erstellen (Create Private Threads) und Nachrichten in Threads senden (Send Messages in Threads). Zusammen ergibt das den Wert 360777253888. Den Link kannst du dir unter „OAuth2“ im URL Generator zusammenklicken oder direkt so bauen:
https://discord.com/oauth2/authorize?client_id=<app-id>&scope=bot+applications.commands&permissions=360777253888
Öffne den Link, wähle deinen Server und bestätige. Für die IDs in der Config schaltest du in Discord unter Benutzereinstellungen → Erweitert den Entwicklermodus ein. Danach bekommst du per Rechtsklick auf einen Channel, eine Rolle (Server-Einstellungen → Rollen) oder das Server-Icon den Eintrag „ID kopieren“.
Die Helper: API-Aufrufe, Config und JSON-Datei
Die Code-Beispiele in den Schritten benutzen ein paar kleine Funktionen. Sie gehören an den Anfang deiner Datei tickets.py. call schickt eine Anfrage an die Discord-API, mit dem Bot-Token und dem User-Agent im Format DiscordBot (url, version), das Discord verlangt. Trag dort die Adresse deines Projekts ein:
import json, os, threading, time, urllib.error, urllib.request
API = "https://discord.com/api/v10"
TOKEN = os.environ["DISCORD_TOKEN"]
HERE = os.path.dirname(os.path.abspath(__file__))
TICKETS_FILE = os.environ.get("TICKETS_FILE", os.path.join(HERE, "tickets.json"))
EPHEMERAL = 64
lock = threading.Lock()
def call(method, path, body=None):
req = urllib.request.Request(API + path, method=method,
data=None if body is None else json.dumps(body).encode(),
headers={"Authorization": f"Bot {TOKEN}",
"User-Agent": "DiscordBot (https://example.de, 1.0)",
"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=10) as r: # wirft HTTPError bei 4xx/5xx
raw = r.read()
return json.loads(raw) if raw else {}
def read_json(path, default):
try:
with open(path, encoding="utf-8") as f:
return json.load(f)
except FileNotFoundError:
return default
cfg = read_json(os.path.join(HERE, "config.json"), {})
TICKET_CHANNEL = cfg["ticket_channel_id"]
def private(text): # Antwort, die nur der Klickende sieht
return {"type": 4, "data": {"content": text, "flags": EPHEMERAL,
"allowed_mentions": {"parse": []}}}
def edit_original(inter, text): # die "denkt nach …"-Antwort ersetzen
call("PATCH", f"/webhooks/{inter['application_id']}/{inter['token']}/messages/@original",
{"content": text, "allowed_mentions": {"parse": []}})
def open_ticket_of(user_id): # offenes Ticket, das in Discord noch existiert
for tid, t in read_json(TICKETS_FILE, {"tickets": {}})["tickets"].items():
if t["user_id"] != user_id or t["status"] != "open":
continue
try:
ch = call("GET", f"/channels/{tid}")
except urllib.error.HTTPError as e:
if e.code == 404: # Thread wurde gelöscht
continue
raise
if not ch.get("thread_metadata", {}).get("archived"):
return tid
return None
def mark_closed(tid, user):
with lock:
data = read_json(TICKETS_FILE, {"next": 1, "tickets": {}})
data["tickets"][tid].update(status="closed", closed=int(time.time()),
closed_by=user.get("username"))
write_json(data)
def create_safe(inter, *args): # Fehler im Hintergrund dem Spieler melden
try:
create(inter, *args)
except Exception as e:
print("Ticket-Fehler:", e)
edit_original(inter, "Das Ticket konnte nicht erstellt werden. Bitte schreib dem Team direkt.")
write_json kommt in Schritt 7. Daneben legst du eine config.json an. IDs schreibst du als Strings, weil Discord sie auch so schickt:
{
"ticket_channel_id": "123456789012345678",
"ticket_role_ids": ["234567890123456789", "345678901234567890"]
}
Schritt 1: Den Slash Command definieren und registrieren
Der Command ist ein JSON-Objekt. Option-Typ 3 ist ein String (ein Textfeld), und über choices wird daraus eine feste Auswahlliste. Registrieren kannst du ihn als Guild Command (gilt nur auf einem Server) per PUT. Dann ist er auf deinem Server sofort verfügbar. Der Code legt Themen und Eingabefelder fest und meldet den Befehl bei Discord an:
TOPICS = {"bug": "Bug", "spieler": "Spieler melden", "verlust": "Verlust / Rückerstattung",
"frage": "Frage", "sonstiges": "Sonstiges"}
COMMAND = {
"name": "ticket",
"description": "Support-Ticket öffnen: privater Thread mit dem Team",
"options": [
{"type": 3, "name": "thema", "description": "Worum geht es?", "required": True,
"choices": [{"name": label, "value": key} for key, label in TOPICS.items()]},
{"type": 3, "name": "beschreibung", "description": "Was ist passiert?",
"required": True, "max_length": 1000},
],
}
TOPICS und COMMAND stehen in tickets.py. Registriert wird der Command mit einer eigenen kleinen Datei register.py im selben Ordner. Ersetze <guild-id> durch die ID deines Servers (Rechtsklick auf das Server-Icon → ID kopieren):
import os
from tickets import call, COMMAND
# einmalig registrieren (überschreibt alle Guild Commands dieser App)
call("PUT", f"/applications/{os.environ['DISCORD_APP_ID']}/guilds/<guild-id>/commands", [COMMAND])
Führ die Datei einmal mit python3 register.py aus, mit denselben Umgebungsvariablen wie in der Vorbereitung. Danach taucht /ticket auf deinem Server auf. Achte darauf, dass der PUT die komplette Liste ersetzt. Hat dein Bot schon andere Guild Commands, gehören die mit in die Liste.
Schritt 2: Den Ticket-Channel anlegen
Der Channel #tickets ist so eingestellt, dass alle ihn lesen, aber niemand dort schreiben oder selbst Threads erstellen kann. Erlaubt ist für @everyone nur „Send Messages in Threads“, damit Spieler in ihrem eigenen Ticket antworten können. Das CobbleBox-Script legt den Channel beim ersten direkten Aufruf selbst an und setzt diese Permission-Overwrites (Rechte-Ausnahmen, die nur für diesen Channel gelten). Danach speichert es die Channel-ID in der Bot-Config (Einstellungsdatei) und postet eine Info-Nachricht mit Anleitung. Dafür braucht der Bot einmalig Admin-Rechte.
Von Hand geht es genauso schnell, und dein Bot braucht dann nie Admin-Rechte. Leg einen Textkanal #tickets an und öffne Kanal bearbeiten → Berechtigungen. Setze dort diese Ausnahmen (die englischen Namen in Klammern helfen, falls dein Client die Rechte etwas anders übersetzt):
@everyone: Kanal anzeigen (View Channel) und Nachrichtenverlauf lesen (Read Message History) erlauben, Nachrichten in Threads senden (Send Messages in Threads) erlauben. Nachrichten senden (Send Messages), Öffentliche Threads erstellen (Create Public Threads) und Private Threads erstellen (Create Private Threads) verbieten.- Dein Bot (über „Mitglieder oder Rollen hinzufügen“ den Bot auswählen): Kanal anzeigen, Private Threads erstellen, Nachrichten in Threads senden und Threads verwalten (Manage Threads) und Links einbetten (Embed Links) erlauben. Ohne Links einbetten zeigt Discord das Embed im Ticket nicht an.
- Optional: Schreib selbst eine Info-Nachricht in den Kanal, bevor du Nachrichten senden für alle sperrst, etwa „Tippe /ticket, um ein Ticket zu öffnen“.
Die Config enthält danach zwei Werte, die das Ticket-Modul braucht: ticket_channel_id und ticket_role_ids (die Rollen, die gepingt werden und Tickets schließen dürfen). Auf CobbleBox sind das die Moderatoren-Rolle und die Owner-Rolle. Beide IDs kopierst du mit dem Entwicklermodus per Rechtsklick und trägst sie in config.json ein.
📸 [Screenshot: #tickets-Channel mit Info-Post und der /ticket-Eingabe mit Themen-Auswahl, Lion fügt ein]
Schritt 3: /ticket beantworten und den privaten Thread erstellen
Discord erwartet eine Antwort auf eine Interaction innerhalb von 3 Sekunden. Einen Thread anlegen, den Spieler hinzufügen und die erste Nachricht posten sind mehrere API-Calls (Anfragen an Discord), das kann knapp werden. Deshalb antwortet der Handler sofort mit Typ 5 (deferred, „denkt nach …“). Das ist eine ephemerale Nachricht, die nur der Spieler sieht. Den Rest erledigt er in einem Hintergrund-Thread (ein zweiter Arbeitsstrang im Programm, kein Discord-Thread). Am Ende wird die ursprüngliche Antwort über den Interaction-Token bearbeitet, den Schlüssel für genau diese Antwort. Im Code antwortet open_ticket sofort, create legt im Hintergrund das Ticket an. create_safe aus den Helpern ruft create auf und ersetzt die Antwort durch eine Fehlermeldung, falls dabei etwas schiefgeht.
EPHEMERAL = 64
def open_ticket(inter, opts):
user = (inter.get("member") or {}).get("user") or {}
if not inter.get("guild_id"):
return private("Tickets gehen nur auf dem Discord-Server.")
threading.Thread(target=create_safe, args=(inter, user, opts["thema"], opts["beschreibung"]),
daemon=True).start()
return {"type": 5, "data": {"flags": EPHEMERAL}} # sofort antworten, Rest im Hintergrund
def create(inter, user, topic, text):
with lock:
if open_ticket_of(user["id"]):
return edit_original(inter, "Du hast schon ein offenes Ticket.")
data = read_json(TICKETS_FILE, {"next": 1, "tickets": {}})
n = data["next"]
name = f"#{n:03d} {TOPICS[topic]} · {user['username']}"[:100]
thread = call("POST", f"/channels/{TICKET_CHANNEL}/threads",
{"name": name, "type": 12, "invitable": False, "auto_archive_duration": 10080})
data["next"] = n + 1
data["tickets"][thread["id"]] = {"n": n, "user_id": user["id"], "topic": topic,
"text": text, "status": "open", "opened": int(time.time())}
write_json(data)
call("PUT", f"/channels/{thread['id']}/thread-members/{user['id']}")
edit_original(inter, f"Dein Ticket ist offen: <#{thread['id']}>")
Beim Thread kommt es auf drei Werte an: type: 12 ist ein privater Thread, invitable: False verhindert, dass normale Mitglieder weitere Leute hinzufügen, und auto_archive_duration: 10080 sind 7 Tage in Minuten. Thread-Namen dürfen maximal 100 Zeichen haben, deshalb wird der Name abgeschnitten.
Die Bearbeitung der ursprünglichen Antwort läuft per PATCH (ändert nur einzelne Felder): PATCH /webhooks/<application_id>/<interaction-token>/messages/@original. Beides steht im Interaction-Objekt, das du bekommst.
Schritt 4: Team pingen und den Schließen-Button posten
Die erste Nachricht im Thread erwähnt den Spieler und die Team-Rollen. In einem privaten Thread wird jeder automatisch hinzugefügt, den du per Mention (also mit @ im Text) erwähnst und der den Channel sehen darf. Du musst die Team-Mitglieder also nicht einzeln einladen. Bei Rollen klappt das nur unter zwei Bedingungen: Die Rolle hat weniger als 100 Mitglieder, und der Bot darf sie überhaupt pingen. Dafür stellst du in den Rollen-Einstellungen ein, dass jeder die Rolle erwähnen darf („Allow anyone to @mention this role“), oder gibst dem Bot das Recht, alle Rollen zu erwähnen (Mention @everyone, @here, and All Roles). Pro Nachricht zählen höchstens 10 Rollen. Mit allowed_mentions legst du fest, dass wirklich nur dieser Spieler und diese Rollen gepingt werden.
Der folgende Block gehört in create aus Schritt 3 (also mit vier Leerzeichen eingerückt), direkt hinter die Zeile mit call("PUT", … /thread-members/ …) und vor das abschließende edit_original. Dort sind thread, user, n, topic und text schon gesetzt. Der Code postet die Nachricht mit Pings, einem Embed (ein Info-Kasten) und dem roten Button:
roles = [str(r) for r in cfg["ticket_role_ids"]]
call("POST", f"/channels/{thread['id']}/messages", {
"content": " ".join([f"<@{user['id']}>"] + [f"<@&{r}>" for r in roles]),
"allowed_mentions": {"users": [user["id"]], "roles": roles},
"embeds": [{"title": f"Ticket #{n:03d} · {TOPICS[topic]}", "description": text}],
"components": [{"type": 1, "components": [{
"type": 2, "style": 4, # Button, rot
"label": "Ticket schließen",
"custom_id": "ticket_close",
"emoji": {"name": "🔒"},
}]}],
})
Im Embed zeigt CobbleBox zusätzlich den Minecraft-Namen des Spielers an, falls sein Discord-Account mit seinem Minecraft-Account verknüpft ist. Die Zuordnung kommt aus einer separaten JSON-Datei, die der Server ohnehin pflegt. Ist nichts verknüpft, steht dort „nicht verknüpft“.
Schritt 5: Der Button nach einem Neustart
In discord.py brauchst du für Buttons, die einen Bot-Neustart überleben, eine „persistent View“, die beim Start neu registriert wird. Ohne Library fällt das weg. Der Button hat einen festen custom_id, eine Kennung, die Discord bei jedem Klick mitschickt. Dein Interaction-Dispatcher (der Teil des Bots, der Eingaben verteilt) leitet jeden Klick mit ticket_close an den Close-Handler weiter. Welcher Thread gemeint ist, ergibt sich aus der channel_id der Interaction, und der Status steht in der JSON-Datei. Im Bot-Prozess liegt kein Zustand, der bei einem Neustart verloren gehen kann.
Schritt 6: Rechte prüfen und das Ticket schließen
Den Button sieht jeder im Thread, also muss der Handler selbst prüfen, wer klickt. Erlaubt sind der Ersteller des Tickets, alle mit einer der Team-Rollen und alle mit Administrator-Recht (Bit 3 im Permission-Feld des Members). Der Code prüft das, markiert das Ticket als geschlossen und sperrt den Thread kurz danach:
def close_button(inter):
tid = inter["channel_id"]
member = inter.get("member") or {}
user = member.get("user") or {}
t = read_json(TICKETS_FILE, {"tickets": {}})["tickets"].get(tid)
if not t:
return private("Das ist kein Ticket-Thread.")
if t["status"] == "closed": # vom Team wieder geöffnet? Dann darf man erneut schließen
if call("GET", f"/channels/{tid}").get("thread_metadata", {}).get("archived"):
return private("Das Ticket ist schon geschlossen.")
team = set(map(str, cfg["ticket_role_ids"]))
is_admin = int(member.get("permissions") or 0) & (1 << 3)
if user.get("id") != t["user_id"] and not (team & set(member.get("roles", []))) and not is_admin:
return private("Nur wer das Ticket geöffnet hat oder das Team kann es schließen.")
mark_closed(tid, user) # status="closed", closed=Zeitstempel, closed_by=Name
def archive():
time.sleep(1.5) # erst die Antwort posten lassen, ein gesperrter Thread nimmt nichts mehr an
call("PATCH", f"/channels/{tid}", {"archived": True, "locked": True})
threading.Thread(target=archive, daemon=True).start()
return {"type": 4, "data": {"content": f"🔒 Ticket geschlossen von <@{user['id']}>.",
"allowed_mentions": {"parse": []}}}
locked: True sorgt dafür, dass der Spieler den Thread nicht einfach durch eine neue Nachricht wieder öffnen kann. Das Team kann ihn in Discord trotzdem wieder aufmachen. Für diesen Fall prüft der Handler, ob ein als geschlossen markiertes Ticket gerade wirklich archiviert ist. Das erledigt der if t["status"] == "closed"-Block oben: Hat das Team den Thread wieder geöffnet, darf er noch einmal geschlossen werden.
📸 [Screenshot: Ticket-Thread mit Embed, Team-Ping und rotem „Ticket schließen“-Button, Lion fügt ein]
Schritt 7: Das JSON-Log
Eine Datenbank brauchst du für ein Community-Ticket-System nicht. tickets.json hat nur zwei Keys: next für die laufende Ticket-Nummer und tickets, ein Dictionary mit der Thread-ID als Schlüssel. Pro Ticket stehen dort Nummer, User-ID, Thema, Text, Status, Öffnungszeit und nach dem Schließen auch Zeit und wer es geschlossen hat.
Geschrieben wird atomar, also in einem Rutsch: erst in eine temporäre Datei, dann per os.replace drüber. So bleibt nie eine halb geschriebene JSON-Datei liegen, falls der Prozess mitten im Schreiben stirbt. Die Funktion dazu:
def write_json(data):
tmp = TICKETS_FILE + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(data, f, indent=1, ensure_ascii=False)
os.replace(tmp, TICKETS_FILE)
Den Pfad liest das CobbleBox-Script optional aus einer Umgebungsvariable (eine Einstellung beim Programmstart), sonst liegt die Datei neben dem Script. Hosten kannst du das Ganze überall, wo dein Bot sowieso läuft. Das Ticket-Modul ist nur eine weitere Python-Datei, die der Bot importiert.
Schritt 8: Interactions empfangen und verteilen
Jetzt fehlt nur noch der Teil, der die Eingaben von Discord annimmt und an open_ticket oder close_button weitergibt. Das Script server.py nutzt den HTTP-Endpoint: Es prüft die Signatur mit PyNaCl, beantwortet den PING, den Discord beim Speichern der URL schickt, und verteilt Befehle (Typ 2) und Button-Klicks (Typ 3). Der Rückgabewert der Handler geht direkt als Antwort zurück:
import json, os
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from nacl.exceptions import BadSignatureError
from nacl.signing import VerifyKey # pip install pynacl
import tickets
VERIFY_KEY = VerifyKey(bytes.fromhex(os.environ["DISCORD_PUBLIC_KEY"]))
def dispatch(inter):
if inter["type"] == 1: # PING
return {"type": 1}
data = inter.get("data", {})
if inter["type"] == 2 and data.get("name") == "ticket": # Slash Command
opts = {o["name"]: o["value"] for o in data.get("options", [])}
return tickets.open_ticket(inter, opts)
if inter["type"] == 3 and data.get("custom_id") == "ticket_close": # Button
return tickets.close_button(inter)
return tickets.private("Unbekannte Aktion.")
class Handler(BaseHTTPRequestHandler):
def do_POST(self):
body = self.rfile.read(int(self.headers.get("Content-Length", 0)))
try:
VERIFY_KEY.verify(self.headers["X-Signature-Timestamp"].encode() + body,
bytes.fromhex(self.headers["X-Signature-Ed25519"]))
except (BadSignatureError, AttributeError, TypeError, ValueError):
self.send_response(401) # Discord testet auch mit falschen Signaturen
self.end_headers()
return
out = json.dumps(dispatch(json.loads(body))).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(out)))
self.end_headers()
self.wfile.write(out)
ThreadingHTTPServer(("127.0.0.1", 8080), Handler).serve_forever()
Das Script lauscht nur lokal auf Port 8080. Von außen erreichbar machst du es über deinen Reverse Proxy, zum Beispiel mit Caddy, der sich auch um das HTTPS-Zertifikat kümmert:
tickets.example.de {
reverse_proxy 127.0.0.1:8080
}
Starte python3 server.py (mit den Umgebungsvariablen aus der Vorbereitung) und trag im Developer Portal auf „General Information“ unter „Interactions Endpoint URL“ die Adresse https://tickets.example.de ein. Discord schickt beim Speichern einen PING und prüft, ob dein Script falsche Signaturen ablehnt. Nur wenn beides klappt, lässt sich die URL speichern. Für den Dauerbetrieb packst du server.py in einen systemd-Service oder einen Docker-Container.
So prüfst du, ob es läuft
Teste am besten mit einem zweiten Discord-Account, der keine Team-Rolle hat. Mit deinem Admin-Account siehst du private Threads sowieso und merkst Rechte-Fehler nicht.
- Tippe mit dem Test-Account in
#tickets/ticket, wähle ein Thema und schreib eine Beschreibung. Nach kurzem „denkt nach …“ kommt „Dein Ticket ist offen“ mit einem Link zum Thread. - Im Thread stehen der Ping für den Test-Account und die Team-Rollen, das Embed und der rote Button. Ein drittes Mitglied ohne Team-Rolle darf den Thread nicht sehen.
- Tippe mit dem Test-Account noch einmal
/ticket. Die Antwort muss „Du hast schon ein offenes Ticket.“ sein. - Klick auf „Ticket schließen“. Die Nachricht „Ticket geschlossen von …“ erscheint, kurz danach ist der Thread archiviert und gesperrt.
- In
tickets.jsonsteht das Ticket mit"status": "closed", Zeitpunkt und Namen.
Klappt der erste Schritt nicht, schau in die Ausgabe von server.py. Ein HTTPError 403 heißt fast immer, dass dem Bot im Channel eines der Rechte aus Schritt 2 fehlt.
Typische Probleme und wie das Script sie abfängt
Die meisten Stolperstellen eines Ticket-Bots zeigen sich erst im Betrieb. Für diese Fälle hat der CobbleBox-Code schon eine Lösung eingebaut:
- „Die Anwendung reagiert nicht“: Das passiert, wenn du die 3 Sekunden reißt. Antworte deshalb sofort deferred und erstelle den Thread im Hintergrund. Schlägt dabei etwas fehl, wird die Antwort zu „Das Ticket konnte nicht erstellt werden. Bitte schreib dem Team direkt.“ geändert, damit der Spieler nicht vor einer ewig ladenden Nachricht sitzt.
- Doppelte Tickets bei schnellem Doppelklick: Zwei
/ticket-Aufrufe kurz hintereinander laufen in zwei Hintergrund-Threads. Einthreading.Lock(eine Sperre, die immer nur einen Thread durchlässt) um Prüfung, Thread-Erstellung und Schreiben verhindert, dass beide durchrutschen oder dieselbe Nummer bekommen. - Gelöschte Threads blockieren den Spieler: Löscht jemand im Team einen Ticket-Thread von Hand, steht das Ticket im Log noch auf „open“. Die Prüfung auf ein offenes Ticket fragt deshalb bei Discord nach, ob der Thread noch existiert und nicht archiviert ist, und überspringt ihn sonst.
- Die Schließen-Bestätigung fehlt: Wird der Thread sofort gesperrt, kann die Antwort „Ticket geschlossen von …“ nicht mehr erscheinen. Das Script wartet 1,5 Sekunden, bevor es archiviert und sperrt.
- Ungewollte Pings: Alle Bot-Antworten setzen
allowed_mentionsauf leer, außer der ersten Ticket-Nachricht, die gezielt Spieler und Team-Rollen erwähnt. So kann niemand über die Beschreibung ein@everyoneauslösen. - Tickets per DM: Slash Commands können auch außerhalb des Servers aufgerufen werden. Ohne
guild_id(die Kennung des Servers) lehnt der Handler ab, denn Threads gibt es nur auf dem Server. - Der erste Test zählt mit: Auf CobbleBox war Ticket #001 ein Test-Ticket, das direkt wieder geschlossen wurde. Die Nummerierung läuft danach einfach weiter. Das stört nicht, aber so wunderst du dich später nicht über die Lücke.
Fazit
Mit rund 200 Zeilen Python und der Discord-REST-API bekommst du private Ticket-Threads mit Team-Ping, einen Schließen-Button mit Rechteprüfung und ein Log in einer JSON-Datei. Wie du so einen Cobblemon-Server mit Pelican (ein Web-Panel für Game-Server) aufsetzt, zeigt dir der Guide Cobblemon Server erstellen.
Hat dir der Guide geholfen? Dann spendier mir gern einen Kaffee auf Ko-fi. Damit bleibt MappleBox werbefrei.