Interne Dokumentation
INTERNAL — Architektur, Betrieb, Admin-API
Interne Dokumentation für Betreiber. Nicht an Kunden geben. Kundenseitige Dokumente: API.md und QUEUE.md. Betriebsabläufe (Deploy, Rollback, Notfall) stehen in ../RUNBOOK.md und werden hier nicht wiederholt, nur verlinkt.
Diese Datei ist die Quelle für public/admin-api-docs.html. Das HTML wird generiert, nie von Hand bearbeitet — siehe Dokumentation und Generator.
Inhalt
- Architektur in einem Bild
- Prozessmodell
- Netz, Bind-Adressen, Shared Secret
- Queue — Aufbau und Datenmodell
- Memory-Guard
- Browser-Liveness und Quarantäne
- Proxy-Relay und SSRF-Schutz
- Medienablage
- Webhooks intern
- Admin-API (JWT)
- Interne Instanz-API (X-Internal-Auth)
- Konfiguration und Umgebungsvariablen
- Deploy und Rollback
· 13.1 Inbetriebnahme der Queue — gestufte Schrittfolge
- Dokumentation und Generator
- Bekannte Grenzen und offene Punkte
- Abweichungen zwischen Kontrakt und Code
1. Architektur in einem Bild
Internet
│ TLS, nginx
▼
┌──────────────────────────────────────────┐
│ admin-server.js :3001 │
│ ─ Admin-Panel (JWT) │
│ ─ Externe API (Bearer <apiKey>) │
│ ─ 6 Bestandsrouten + ~45 v2-Routen │
│ ─ reine Durchreichung, KEINE Umformung │
└───────┬───────────────┬──────────────────┘
│ HTTP 127.0.0.1, X-Internal-Auth
┌─────────▼───┐ ┌─────▼───────┐ …
│ instance- │ │ instance- │
│ server.js │ │ server.js │ ein Kindprozess je Instanz
│ :3013 │ │ :3014 │ Bind ausschliesslich 127.0.0.1
│ │ │ │
│ ┌─────────┐ │ │ ┌─────────┐ │
│ │ lib/ │ │ │ │ lib/ │ │ Outbox-Queue je Prozess,
│ │ queue │ │ │ │ queue │ │ genau EIN Schreiber je Instanz
│ └────┬────┘ │ │ └────┬────┘ │
│ │ │ │ │ │
│ ┌────▼────┐ │ │ ┌────▼────┐ │
│ │ wwebjs │ │ │ │ wwebjs │ │ whatsapp-web.js 1.34.7
│ │ + Chrome│ │ │ │ + Chrome│ │ LocalAuth, sessions/<id>/
│ └─────────┘ │ │ └─────────┘ │
└──────┬──────┘ └──────┬──────┘
│ │
└────────┬──────────┘
▼
PostgreSQL 17.7, DB chatbot_management, Schema wa_gatewayRedis läuft auf dem Host, wird von uns aber nicht benutzt. Begründung siehe §4.
2. Prozessmodell
| Prozess | Datei | Start | Port |
|---|---|---|---|
| Gateway/Admin | admin-server.js | PM2 (ecosystem.config.js), Prozessname whatsapp-api | 3001 |
| Instanz | instance-server.js | Kindprozess, gespawnt von admin-server.js | 3010+ je Instanz |
- Eine Instanz = ein Node-Prozess = ein Chrome-Baum = eine WhatsApp-Nummer.
- Der Kindprozess erbt
process.envvom Admin-Prozess. AlleQUEUE_*-Variablen und das
Shared Secret brauchen deshalb keinen eigenen Übergabeweg.
- Die Instanzkonfiguration (id, name, apiKey, port, proxy, webhook, queue) kommt als
INSTANCE_CONFIG-JSON in der Umgebung des Kindprozesses.
- Registry:
data/instances.json. Die einzige Quelle der Wahrheit für Limits.
loadInstances() ergänzt fehlende queue-Blöcke beim Lesen aus DEFAULTS_EXISTING — die Migration der Registry ist damit ein reiner Lesevorgang, es gibt keine Migrationsdatei.
- Autostart nach einem Neustart des Gateways: gestaffelt,
AUTOSTART_STAGGER_MS.
Wichtig: genau ein Schreiber je Instanz
Die Queue-Zähler werden nicht separat geführt, sondern per Indexabfrage aus genau den Zeilen abgeleitet, die den Versand protokollieren. Sie können deshalb nicht driften. Das funktioniert, weil es je Instanz genau einen schreibenden Prozess gibt. Diese Annahme darf nicht gebrochen werden — zwei Prozesse auf derselben instance_id würden sich gegenseitig über das Limit schieben, obwohl FOR UPDATE SKIP LOCKED Doppelversand verhindert.
3. Netz, Bind-Adressen, Shared Secret
| Regel | Umsetzung |
|---|---|
| Instanzen sind nie extern erreichbar | app.listen(PORT, process.env.INSTANCE_BIND_HOST \|\| '127.0.0.1') (instance-server.js:3577) |
| Jeder Aufruf des Gateways an eine Instanz trägt das Shared Secret | internalHeaders() setzt X-Internal-Auth: $INSTANCE_SHARED_SECRET |
| Die Instanz prüft es zeitkonstant | safeCompareSecret(req.get('X-Internal-Auth'), process.env.INSTANCE_SHARED_SECRET) (instance-server.js:250) |
| Ausnahmen von der Prüfung | ausschliesslich /health und /health/deep (INTERNAL_AUTH_ALLOWLIST) |
| Fehlt/falsch | 401 {code:'INTERNAL_AUTH_REQUIRED'} |
| Das Secret darf den Loopback nie verlassen | Der v2-Proxy setzt maxRedirects: 0. Ohne das würde axios einem Redirect mit dem Header folgen. |
INSTANCE_SHARED_SECRET ist Pflicht. Ohne den Schlüssel bricht deploy.sh ab. Die .env muss 0600 gehören — sie enthält Shared Secret, JWT_SECRET, Admin-Passwort und die DB-URL.
INSTANCE_AUTH_MODE=auditist ein Notausgang, kein Betriebsmodus. In diesem Modus wird ein Aufruf ohne gültigesX-Internal-Authdurchgelassen und nur als WARN geloggt (unauthenticated internal request …). Gedacht ist er ausschliesslich für die Umstellungsphase, in der man sehen will, wer den Header noch nicht setzt. Default und Zielzustand istenforce. Wer ihn dauerhaft aufauditstehen lässt, hat die Loopback-Grenze effektiv aufgehoben: jeder Prozess auf der Maschine — inklusive des fremden Chatbot-Dienstes unter derselben UID — darf dann senden. Nach dem Deploy prüfen:grep INSTANCE_AUTH_MODE .envmuss leer sein oderenforceliefern.
Ein Trailing Slash wird vor der Prüfung normalisiert, damit /health/ nicht fälschlich 401 liefert.
Externe Autorisierung
| Ebene | Verfahren |
|---|---|
Admin-Panel und alle /api/instances/** | JWT, 24 h Laufzeit, POST /api/auth/login |
Kunden-API (6 Bestandsrouten + alle /api/v2/**) | Authorization: Bearer <apiKey>, validateApiKey, zeitkonstanter Vergleich, zusätzlich Bedingung instance.status === 'online' |
| QR-Freigabelink | shareToken, abgeleitet je Instanz, /qr/:instanceId/:shareToken |
/health am Gateway | keine — deshalb dort nur Zähler, keine IDs, keine Namen |
4. Queue — Aufbau und Datenmodell
Warum PostgreSQL und nicht Redis
Der Host-Redis läuft mit appendonly no und save 3600 1 300 100 60 10000. Bei einem Host-Crash sind damit bis zu 60 Sekunden Schreibvorgänge verloren. Zusammen mit maxmemory 0 und noeviction nimmt eine volllaufende Queue ausserdem den 7,7-GB-Host mit — inklusive aller Kundeninstanzen. Beides disqualifiziert Redis als Quelle der Wahrheit, nicht als Meinung, sondern per Konfiguration. Der einzige echte Redis-Vorteil (atomares INCR über Prozessgrenzen) wird nicht gebraucht, weil es je Instanz genau einen Schreiber gibt.
Ablage
Schema wa_gateway in der bestehenden DB chatbot_management. Die Rolle chatbot darf kein neues DB anlegen (rolcreatedb=f), aber ein neues Schema. Alle Statements sind explizit schema-qualifiziert, kein Verlass auf search_path.
| Tabelle | Zweck |
|---|---|
wa_gateway.wa_outbox | eine Zeile je Nachricht, inkl. Zustand, Zeitplan, Ergebnis |
wa_gateway.wa_instance_state | last_dispatch_at, plan_epoch, limits_hash je Instanz |
wa_gateway.wa_media | Metadaten der Medienablage (DDL vorhanden; der Code führt den Index heute in data/media/<instanceId>/index.json, siehe §16) |
Migration:
npm run queue:migrate # legt Schema/Tabellen/Indizes idempotent an
npm run queue:verify # prueft Existenz, exit 0/1
npm run queue:test # Selbsttests ohne DB
npm run queue:test:pg # Selbsttests gegen die echte DBDDL liegt in lib/queue/schema.sql, der Runner in lib/queue/migrate.js.
Module
| Datei | Verantwortung |
|---|---|
lib/queue/index.js | Fassade: init, submit, awaitTerminal, get, list, cancel, requeue, applyConfig, summary, pause, drainInFlight, shutdown |
lib/queue/accept.js | Annahmepfad T1+T2 in einer Transaktion (Idempotenz → Kapazität → INSERT → Advisory-Lock → Planung) |
lib/queue/store.js / store-reads.js | Sämtliches SQL, schema-qualifiziert |
lib/queue/scheduler.js | Reiner Planungskern (computePlan, planCursor, resolveSlot, randDelay, exitTime) plus store-gebundener Planner |
lib/queue/tz.js | applyQuietHours über Intl.DateTimeFormat, DST-sicher, ohne neue Abhängigkeit |
lib/queue/dispatcher.js | Sendeschleife, single-flight, Zustandsübergänge T4–T9, Fehlerklassifikation |
lib/queue/recovery.js | Wiederanlauf beim Start, Reaper (60 s), Retention (7 Tage, stündlich) |
lib/queue/gate.js | Limitprüfung unmittelbar vor dem Versand. Reine Funktion. Wird sowohl vom PG-Dispatcher (im Claim) als auch vom Memory-Fallback ausgewertet. |
lib/queue/job.js | Normalisiert die Speicherzeile (PG snake_case oder Fallback camelCase) auf ein Job-Schema für sendFn. Genau einmal, im Kern. |
lib/queue/memory-fallback.js | Degraded-/Disabled-Modus, kapselt die alte In-Memory-MessageQueue, wertet aber dasselbe Gate aus |
lib/queue/limits.js | Defaults, Validierung, limitsHash, needsReplan |
lib/queue/ids.js, view.js | jobId/mediaId/eventId, QueueError, Zeile → API-Objekt |
Zwei Schichten, nicht eine
scheduled_at ist eine Planung, keine Durchsetzung. Der Planner verteilt, das Gate setzt durch. Ohne die zweite Schicht wird jeder Stillstand (Disconnect, geordneter Restart, Limitänderung, Uhrumstellung, Wiederanlauf) zu einem Burst, weil schlagartig viele Slots gleichzeitig fällig sind — exakt das Muster, auf das WhatsApps Spam-Erkennung reagiert. Verletzt ein Job das Gate, geht er mit neuem scheduled_at zurück in die Warteschlange und wird nicht gesendet.
Queue.limitsEnforced() sagt, ob gerade durchgesetzt wird: true in pg und in memory-degraded, false nur in disabled. Der Wert steht in stats() und summary().
Zustände und die eine Regel
queued → scheduled → sending → {sent | failed | unknown}, dazu cancelled und expired.
scheduledverlässt den Prozess nicht. Die Unterscheidungqueued(kein Slot vergeben) /scheduled(Slot steht) ist eine interne Planungsstufe, keine fachliche Aussage — in beiden Fällen ist die Nachricht angenommen und noch nicht raus.v2SubmitResponse()(instance-server.js:3245) bildet deshalbscheduled → queuedab, bevor geantwortet wird; die Rohstufe bleibt daneben alsqueueStatesichtbar. Terminale Zustände werden nicht eingeebnet, sonst meldete ein Idempotenz-Replay einer längst zugestellten Nachrichtqueued./api/send-bulkantwortet aus demselben Grund je Eintrag mitstatus:'queued'. Ebenso gibt es keinmessage.scheduled-Ereignis: die Annahme feuert immermessage.queued(lib/queue/accept.js:138).
INVARIANTE D: Sobald der wwebjs-Aufruf angestossen wurde, gibt es keinen Uebergang
mehr in einen sendefaehigen Zustand. Weder durch Code, noch durch Recovery,
noch durch einen Operator.Der Zustandswechsel nach sending wird vor dem wwebjs-Aufruf committed. Findet ein Neustart sending-Zeilen vor, werden sie zu unknown mit process_crash_while_sending — nie zu einem Retry. Genau diese Regel hält den historischen Duplikat-Vorfall geschlossen.
Es gibt keinen automatischen Retry. Die einzige Rückkehr aus sending ist der Fall "beweisbar noch nicht gesendet" (WhatsApp getrennt, Seite während der Nummernvalidierung gestorben) mit Backoff 5 s → 60 s, maximal 10 Mal, danach failed / REQUEUE_EXHAUSTED.
Modi
| Modus | Auslöser | Persistent | Limits | /api/v2/send-* |
|---|---|---|---|---|
pg | queue.enabled = true, PG erreichbar | ja | durchgesetzt (Gate im Claim) | normal |
memory-degraded | enabled = true, PG beim Start oder zur Laufzeit weg | nein | durchgesetzt (Gate im Fallback) | 503 QUEUE_STORE_UNAVAILABLE |
disabled | queue.enabled = false (Default aller Bestandsinstanzen) | nein | keine (limitsEnforced: false) — die Zufallspause minDelayMs…maxDelayMs läuft trotzdem, genau wie in der alten In-Memory-Queue | 503 QUEUE_STORE_UNAVAILABLE |
unavailable | Modul liess sich nicht laden | nein | keine | 503 |
Laufzeit-Umschaltung. Queue._onStoreFailure() prüft über Queue.isConnectivityError(), ob ein Speicherfehler „die Datenbank ist gerade nicht da" bedeutet (PG-SQLSTATE 08*, 57P0*, 53300/53400 und die üblichen Socket-Codes). Nur dann wird umgeschaltet — ein Constraint-Verstoss oder ein SQL-Tippfehler ist ein Programmfehler und darf die Instanz nicht still in den Fallback rutschen lassen. Alle übrigen Speicherfehler kommen als QueueError('QUEUE_STORE_UNAVAILABLE', 503) heraus statt als nackter pg-Fehler mit 500.
Rückweg: Reconnect-Probe alle QUEUE_DEGRADED_PROBE_MS (30 s). Bei Erfolg wird der In-Memory-Rückstand zuerst leergefahren (_adoptFromMemory), dann übernimmt der Dispatcher. Das im Moment des Umschaltens laufende Item bleibt beim Fallback — eine Übernahme wäre ein Duplikat.
Angenommene Items im Degraded-Modus werden zusätzlich forensisch nach data/queue/<instanceId>.degraded.jsonl geschrieben. Kein automatisches Replay — das würde exakt den Duplikat-Vorfall reproduzieren.
Im Degraded-Modus fallen die message.*-Webhooks aus. lib/queue/memory-fallback.js hat keinen einzigen emit-Aufruf; message.queued/sent/failed/unknown kommen ausschliesslich aus accept.js und dispatcher.js, also nur aus dem PG-Pfad. Gesendet wird weiter, benachrichtigt nicht. Die Lücke ist von aussen exakt eingerahmt: queue.degraded markiert ihren Anfang, queue.recovered ihr Ende. Wer Geschäftslogik an message.sent hängt, muss diese Spanne nachziehen (GET /api/v2/queue/items, sobald mode wieder pg ist). Das gehört in die Ankündigung an jeden Kunden, der Queue-Events abonniert — bisher tut das niemand, weil keines dieser Ereignisse in WEBHOOK_DEFAULT_EVENTS steht.
QUEUE_PAUSED gilt bewusst nur für /api/v2/*. Die sechs Bestandsrouten kennen dieses Fehlerbild heute nicht; sie werden während eines geordneten Restarts angenommen und warten im normalen Ablauf. Der LEGACY_SYNC_TIMEOUT_MS-Timeout (Default 20 s, gedeckelt auf 25 s) ist dort die vorhandene Absicherung.
5. Memory-Guard
Ein Chrome-Baum je Instanz auf einem 7,7-GB-Host. Der Guard misst den Prozessbaum, nicht nur den Node-Heap.
| Variable | Default | Bedeutung |
|---|---|---|
INSTANCE_MEM_CHECK_MS | 60000 | Messintervall |
INSTANCE_MEM_SOFT_MB | 2600 | weiche Grenze, geordneter Selbst-Restart |
INSTANCE_MEM_HARD_MB | 3000 | harte Grenze |
INSTANCE_MEM_HOST_MIN_AVAIL_MB | 1000 | unterschreitet der Host das, greift der Guard auch unterhalb der Instanzgrenze |
INSTANCE_MEM_GRACE_MS | 1800000 | Schonfrist nach dem Start |
INSTANCE_MEM_MIN_INTERVAL_MS | 21600000 | Mindestabstand zwischen zwei speichergetriebenen Restarts |
Werte stehen live in /health.memory (heapUsedMB, rssMB, browserTreeMB, totalMB, softLimitMB, hardLimitMB, hostAvailMB).
Der geordnete Restart (orderedRestart()) setzt sendPaused = true, pausiert den Dispatcher, wartet bis zu 35 s auf das laufende Item (drainInFlight), speichert den Message-Store und startet erst dann neu. Ein sauberer Restart hinterlässt deshalb keine sending-Zeile und erzeugt kein unknown.
Herleitung der Schwellen und die ehrliche Grenze ("Ausreisser-Detektor, kein Kapazitätsplan"): RUNBOOK.md §8.
6. Browser-Liveness und Quarantäne
Der klassische Zombie: Node lebt, WhatsApp Web meldet weiterhin connected und empfängt Nachrichten, aber client.info ist weg und senden ist unmöglich. Das erholt sich nie von selbst.
| Variable | Default | Bedeutung |
|---|---|---|
BROWSER_PROBE_MS | 60000 | Probe-Intervall |
BROWSER_PROBE_TIMEOUT_MS | 15000 | Timeout je Probe |
BROWSER_PROBE_FAILS | 5 | Hard-Fails hintereinander ⇒ Browser gilt als tot |
BROWSER_PROBE_SOFT_FAILS | 10 | Soft-Fails hintereinander ⇒ Restart |
GET /healthliefert 503, sobald der Browser als tot gilt — das ist die einzige
Verhaltensänderung gegenüber früher. /api/status bleibt bewusst unverändert und leitet status/connected weiterhin nur aus clientStatus ab, weil externe Kundenintegrationen darauf matchen. Die Browser-Wahrheit steht rein additiv in data.browser.
GET /health/deeperzwingt eine sofortige Probe (statusneutral, ändertbrowserStatenicht).- Zusätzlich:
clientStatus === 'authenticated'länger alsAUTH_STUCK_MSgilt als hängend.
Quarantäne (admin-server.js): häufen sich Restarts im Fenster RESTART_WINDOW_MS, wird die Instanz auf status = 'quarantined' gesetzt, quarantinedAt/quarantineReason gefüllt und ein Alarm an ALERT_WEBHOOK_URL geschickt. Eine quarantänierte Instanz wird nicht automatisch wieder gestartet — nur über POST /api/instances/:id/start. Vorgehen: RUNBOOK.md §4.
7. Proxy-Relay und SSRF-Schutz
Ist für eine Instanz ein SOCKS5-Upstream konfiguriert, öffnet der Prozess ein lokales Relay auf 127.0.0.1:<relayPort> und schickt allen Node-Egress (Medien-Download, Webhooks an externe Ziele) als socks5h:// dorthin — Remote-DNS, eine Credential-Quelle, ein Pfad. Chrome bekommt zusätzlich --host-resolver-rules=MAP * ~NOTFOUND , EXCLUDE 127.0.0.1.
Webhooks an Loopback-Ziele (z. B. der Chatbot auf 127.0.0.1:3000) gehen bewusst nicht über den Proxy. Ist das Relay nicht bereit, wird der Webhook verworfen und laut geloggt statt ungeproxyt rauszugehen (fail-closed).
SSRF-Prüfung für mediaUrl (isPublicHttpUrl + resolveMediaUrl):
- Schema http/https, keine privaten/Loopback-/Link-Local-Ziele, DNS-Auflösung wird geprüft.
- Redirects werden selbst verfolgt (
redirect: 'manual'), jedeLocationerneut geprüft,
maximal 3 Hops.
- Vorprüfung per
HEAD, nicht per Range-GET — ein Vorab-GET entwertet signierte Einmal-URLs.
Antwortet der Server nicht sinnvoll auf HEAD, wird die bereits validierte Original-URL unverändert benutzt; der finale Abruf läuft mit redirect:'manual' und kann nicht umgelenkt werden.
- Grössenabbruch bei
MEDIA_MAX_BYTES(16 MiB) durchnode-fetch. - Die Prüfung läuft beim Dispatch ein zweites Mal. Zwischen Annahme und Versand können
Stunden liegen und DNS kann sich ändern.
8. Medienablage
| Aspekt | Wert |
|---|---|
| Eingehende Medien speichern | Opt-in je Instanz: queue.storeIncomingMedia, Default false |
| Ablageort | data/media/<instanceId>/<YYYY-MM-DD>/<mediaId>.<ext>, Datei 0600, Verzeichnis 0700 |
mediaId | md_ + 32 Hexzeichen, geprüft gegen ^md_[0-9a-f]{32}$ |
| Index | data/media/<instanceId>/index.json |
| Aufbewahrung | MEDIA_RETENTION_HOURS, Default 72 |
| Grenze je Datei | MEDIA_STORE_MAX_BYTES, 16 MiB — darüber stored:false, reason:'too_large' |
| Grenze je Instanz | MEDIA_STORE_MAX_MB, 2048 — LRU-Verdrängung der ältesten Dateien |
| Globale Bremse | unter MEDIA_LOW_DISK_MIN_MB (5120) freiem Platz wird die Ablage abgeschaltet, stored:false, reason:'low_disk', Alarm media_low_disk |
| Ausgehende Bytes | data/outbox-media/<jobId>.bin, gelöscht beim Übergang in einen terminalen Zustand |
| Pfadsicherheit | Der Pfad wird ausschliesslich aus rec.relPath gebildet, nie aus der URL. Path-Traversal ist ausgeschlossen. |
Bytes gehören nicht in wa_outbox.payload. In der Zeile steht nur eine media_ref: {type:'url'|'file'|'store', …}.
9. Webhooks intern
- Opt-in je Instanz. Es gibt keinen globalen
WEBHOOK_URL-Fallback mehr — der hätte alle
Bestandsinstanzen auf einen Schlag scharfgeschaltet.
- Default-Events ohne eigene Auswahl:
message, ready, disconnected, instance_restarting, test.
Bewusst ohne message_ack/message_sent/message_reaction (bis zu 4 Events je Nachricht).
*abonniert alles.- Umschlag v2:
{v, eventId, event, instanceId, timestamp, data}.event/instanceId/
timestamp/data behalten Name, Typ und Reihenfolge — der Chatbot bricht nicht.
- Signatur: der Body wird einmal serialisiert; genau diese Bytes werden signiert und
gesendet. Zwei Formen parallel: X-Webhook-Signature: sha256=<hex> (Legacy, das ist was der Chatbot erwartet) und X-Webhook-Signature-V2: t=<ts>,v1=<hex> über "<ts>.<body>".
- Hochfrequente Events: 1 Versuch statt 3 (
message_ack,message_sent,
message_reaction, unread_count, media_uploaded, message_ciphertext, message_ciphertext_failed, change_battery). Ohne das flutet ein toter Empfänger das Log — das war der 414-MB-Vorfall.
remote_session_savedwird nicht abonniert: es feuert nur unterRemoteAuth, wir nutzen
LocalAuth.
- Das Event
codetransportiert den Pairing-Code. Er darf in keiner Logzeile stehen. - Jeder der 19 zusätzlichen Listener läuft in
try/catchund mit.catch(). Eine geworfene
Exception in einem Listener würde sonst zur unhandledRejection und träfe verifyBrowserOrRestart — ein Mapper-Bug würde die ganze Instanz neu starten.
10. Admin-API (JWT)
Basis: https://wa.outrnk.io. Alle Routen ausser /api/auth/login und /health brauchen Authorization: Bearer <JWT>.
Anmelden
TOKEN=$(curl -s -X POST "$WA_URL/api/auth/login" \
-H "Content-Type: application/json" \
-d '{"username":"'"$ADMIN_USERNAME"'","password":"'"$ADMIN_PASSWORD"'"}' \
| python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["token"])')Token läuft nach 24 h ab.
Instanzverwaltung
| Methode | Pfad | Zweck |
|---|---|---|
GET | /api/instances | Liste. sanitizeInstance entfernt proxy.password und webhookSecret; der apiKey ist enthalten (sonst bräche das Panel). |
GET | /api/instances/:id/api-key | Gezielter Abruf, erzeugt eine eigene WARN-Logzeile |
POST | /api/instances | {name, proxy?, webhookUrl?, webhookSecret?, webhookEvents?, queue?} — ohne queue gilt DEFAULTS_NEW |
PUT | /api/instances/:id | Felder ändern. id/apiKey/port/createdAt/status sind gegen Mass-Assignment gesperrt, queue ist erlaubt. |
DELETE | /api/instances/:id | Instanz entfernen |
POST | /api/instances/:id/start | Starten. Hebt eine Quarantäne auf — der einzige Weg dafür. |
POST | /api/instances/:id/stop | Stoppen |
GET | /api/instances/:id/qr | QR der Instanz |
GET | /api/instances/:id/status | Registry + live (Antwort von /api/status der Instanz). Aktualisiert nebenbei queueRuntime. |
GET | /api/instances/:id/share-token | Token für den öffentlichen QR-Link |
curl -s -X POST "$WA_URL/api/instances" -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"kunde-xy","queue":{"enabled":true,"maxPerHour":40,"maxPer24h":500}}'Webhooks
| Methode | Pfad | Body |
|---|---|---|
GET | /api/instances/:id/webhook | — |
POST | /api/instances/:id/webhook | {url, secret?, events?} |
DELETE | /api/instances/:id/webhook | — |
Reihenfolge: erst persistieren, dann live pushen. Vorher überlebte die Konfiguration keinen Instanz-Neustart (sie lag nur im Heap).
Limits und Queue
| Methode | Pfad | Zweck |
|---|---|---|
GET | /api/instances/:id/limits | {queue, recommended, defaults, runtime, live, liveError} — die persistierte Konfiguration kommt immer, auch wenn die Instanz steht |
PUT | /api/instances/:id/limits | Setzen. Akzeptiert flach ({maxPerHour:40}) und umschliessend ({queue:{…}}). Erst saveInstances(), dann Live-Push. |
GET | /api/instances/:id/queue | Durchreichung von /api/v2/queue |
GET | /api/instances/:id/queue/items | Durchreichung inkl. Querystring |
DELETE | /api/instances/:id/queue/items/:jobId | Job abbrechen |
POST | /api/instances/:id/queue/replan | Replan erzwingen — siehe §16, aktuell wirkungslos |
curl -s -X PUT "$WA_URL/api/instances/$ID/limits" -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"enabled":true,"maxPerHour":40,"maxPer24h":500,"minDelayMs":4000,"maxDelayMs":12000,
"quietHours":{"start":"22:00","end":"07:00","tz":"Europe/Berlin"}}'Antwort: {id, queue, persisted:true, livePushed:boolean, liveError:string|null}. Ist die Instanz nicht erreichbar, ist persisted trotzdem true — beim nächsten Start greift die Konfiguration.
Ist die Instanz gestoppt, antworten die Queue-Routen mit 503 INSTANCE_UNREACHABLE; im data-Block steht der persistierte Stand als Kontext. Das ist im Panel ein normaler Betriebszustand, keine Ausnahme.
Nachrichten und Chats (Panel-Ansicht)
| Methode | Pfad |
|---|---|
GET | /api/instances/:id/chats |
GET | /api/instances/:id/chats/:chatId/messages?limit= |
GET | /api/instances/:id/messages/recent?limit= |
Gateway-Health
GET /health (öffentlich, keine Auth):
{ "success": true, "service": "WhatsApp Multi-Session API", "version": "1.0.0",
"timestamp": "…",
"instances": { "total":5, "online":4, "offline":0, "starting":1, "degraded":0, "quarantined":0 },
"queue": { "degraded": 0 } }total/online/offline heissen aus Kompatibilität weiter so. starting/degraded/quarantined sind neu, damit online + offline < total nicht länger stillschweigend Instanzen aus der Überwachung fallen lässt. Bewusst nur Zähler: die Route ist unauthentifiziert. Details (quarantinedAt, quarantineReason) stehen in der JWT-geschützten Instanzliste und gehen zusätzlich per ALERT_WEBHOOK_URL raus.
queue.degraded > 0 bedeutet: mindestens eine laufende Instanz meldet den Modus memory-degraded — PostgreSQL ist für sie gerade nicht erreichbar. Die Limits gelten dort weiter: lib/queue/memory-fallback.js ruft dasselbe evaluateGate() auf wie der PG-Pfad, und queue.degraded als Webhook trägt limitsEnforced: true. (Die frühere Fassung dieses Absatzes behauptete das Gegenteil; das war der Stand vor Entscheidung F4 und ist seither falsch — ein DB-Ausfall wäre sonst die einfachste Methode gewesen, die Drosselung abzuschalten.)
Der verbleibende Unterschied ist einer, und er ist real: der Zähler des Fallbacks ist prozesslokal. Er lebt im Heap des Instanzprozesses (_dispatchTimes), startet beim Umschalten bei null und verschwindet beim Neustart. Das 24-h-Fenster aus der Datenbank geht im Moment des Umschaltens verloren. Folgen, in dieser Reihenfolge:
- Die Drosselung wird im Ausfallfenster zu grosszügig, nie zu streng — schon versandte
Nachrichten der letzten Stunde zählen nicht mehr mit. In der Spitze kann eine Instanz direkt nach dem Umschalten maxPerHour ein zweites Mal ausschöpfen.
- Ein Prozessneustart im Degraded-Modus setzt den Zähler erneut auf null.
remainingHour/remaining24hin den API-Antworten sind in diesem Modusnull
(limitsBlockLive() zählt nur im Modus pg) — Kontingente sind also nicht ablesbar, obwohl sie durchgesetzt werden.
Zum Unterscheiden der Modi ohne Ratespiel: GET /api/v2/queue liefert mode, limitsEnforced und degradedSince. queue.degraded in /api/status ist dagegen ein grober Monitoring-Boolean (queueMode() !== 'pg') und ist deshalb auch im Default-Modus disabled true — nur der Zähler in /health filtert auf memory-degraded.
Externe v2-Durchreichung — Grundsätze
Der Admin-Server reicht alle /api/v2/** 1:1 durch und formt nichts um:
- Statuscode und Body der Instanz gehen unverändert nach aussen (
validateStatus: () => true).
Bei v2 ist der Statuscode Teil des Kontrakts: 202 (neu eingereiht) gegen 200 (Idempotenz-Replay). Die alten sechs Routen kollabieren jeden Fehler auf 500 — genau das darf hier nicht passieren.
- Der Header
X-Idempotency-Keyreist mit. Wer ihn verliert, macht die zentrale Zusage der
Welle C wirkungslos.
maxRedirects: 0— das Shared Secret darf den Loopback nie verlassen.- Binäre Routen (
…/media) werden gestreamt, nicht gepuffert. - Log auf
debug, nichtinfo— eine Zeile je Aufruf war der Hauptverursacher der 414-MB-Datei. - Transportfehler:
504 INSTANCE_TIMEOUT(Nachricht kann angenommen worden sein) gegen
502 INSTANCE_UNREACHABLE (Instanz hat den Request nie gesehen). Der Unterschied ist für den Aufrufer entscheidungsrelevant.
Timeouts: Senden 60 s, Interaktion/Lesen 30 s, Queue-Status 10 s, Upload 120 s.
11. Interne Instanz-API (X-Internal-Auth)
Nur über Loopback erreichbar. Alles ausser /health und /health/deep braucht den Header.
| Methode | Pfad | Zweck |
|---|---|---|
GET | /health, /health/deep | ohne Auth, siehe §6 |
GET | /api/status, /api/qr | Zustand |
POST | /api/send-message, /api/send-media, /api/send-bulk | Bestandsrouten |
POST | /api/send-direct | Nicht extern durchgereicht. Umgeht Queue und sendSeen per pupPage.evaluate. Diagnosewerkzeug. |
POST | /api/send-raw | Nicht extern durchgereicht. Diagnosewerkzeug. |
GET | /api/chats, /api/chats/:chatId/messages, /api/messages/recent, /api/messages/stats | Message-Store (lokal, kein Puppeteer) |
GET/POST/DELETE | /api/webhook, POST /api/webhook/test | Webhook-Konfiguration |
GET | /api/check-number/:phone, /api/contact/:phone, /api/contact/:phone/picture | Kontakte |
GET | /api/queue | alte In-Memory-Statistik |
GET/POST | /api/limits | Queue-Konfiguration lesen/live setzen — Muster exakt wie POST /api/webhook |
| alle | /api/v2/** | siehe API.md |
POST /api/limits akzeptiert {queue:{…}} oder den flachen Block, validiert über lib/queue/limits.js und ruft queueCore.applyConfig(). Schlägt der Live-Push fehl, antwortet die Route 500 QUEUE_APPLY_FAILED — die Persistenz liegt beim Admin-Server und ist davon unberührt.
12. Konfiguration und Umgebungsvariablen
Vollständige Liste mit Kommentaren: .env.example. Hier die Schlüssel, die man kennen muss.
Pflicht
| Variable | Bedeutung |
|---|---|
INSTANCE_SHARED_SECRET | Loopback-Autorisierung Gateway ↔ Instanz. Ohne bricht deploy.sh ab. |
JWT_SECRET | Admin-Token |
ADMIN_USERNAME, ADMIN_PASSWORD | Panel-Login |
Queue
| Variable | Default | Bedeutung |
|---|---|---|
QUEUE_PG_URL | — | postgresql://<user>:<pw>@127.0.0.1:5432/chatbot_management |
QUEUE_SCHEMA | wa_gateway | Schema |
QUEUE_PG_POOL_MAX | 3 | Budget: 5 Instanzen × 3 + Admin 2 = 17 von max_connections=100. Gleichzeitige Verbraucher je Instanz sind Dispatcher-Claim, Planner und Annahmepfad — bei 2 lief davon regelmässig einer in connectionTimeoutMillis. Quelle: lib/queue/index.js (process.env.QUEUE_PG_POOL_MAX \|\| 3). |
QUEUE_PG_CONNECT_TIMEOUT_MS | 3000 | |
QUEUE_PG_STATEMENT_TIMEOUT_MS | 5000 | |
QUEUE_MAX_PENDING | 5000 | ⇒ 422 QUEUE_FULL |
QUEUE_INLINE_REPLAN_MAX | 500 | Obergrenze für einen Replan im Annahmepfad |
QUEUE_DEGRADED_PROBE_MS | 30000 | Reconnect-Probe |
QUEUE_SUMMARY_REFRESH_MS | 15000 | Zwischenspeicher für /health und /api/status |
LEGACY_SYNC_TIMEOUT_MS | 20000 | Wartezeit der v1-Routen auf das Ergebnis. Hier stand bis zum Review 40000 mit der Begründung „unter dem 45-s-axios-Timeout des Gateways". Beides war falsch: der Code-Default ist 20000 (instance-server.js:186) und der massgebliche Bezugspunkt ist nicht das Gateway, sondern der Aufrufer. Der Wert wird zusätzlich hart auf max(5000, LEGACY_CALLER_TIMEOUT_MS − 5000) gedeckelt, heute also auf 25 s — eine .env-Fehlkonfiguration kann den Duplikat-Vorfall nicht zurückholen. Der Sekundenwert erscheint wörtlich im 504-Fehlertext. |
LEGACY_CALLER_TIMEOUT_MS | 30000 | Angenommene Geduld des Aufrufers (Chatbot). Nur Bezugsgrösse für den Deckel oben, kein eigener Timer. |
LEGACY_RETRY_GRACE_MS | 120000 | Fenster, in dem ein inhaltsgleicher v1-Sendewunsch nach Abbruch/504 als Wiederholung gilt und denselben Idempotenzschlüssel bekommt. |
LEGACY_IDEM_MAX_ENTRIES | 2000 | Deckel der Fingerabdruck-Tabelle (~240 KB). |
pg-native ist verboten — es braucht libpq + node-gyp und würde npm ci brechen.
Medien
MEDIA_MAX_BYTES 16 MiB · MEDIA_BASE64_MAX_BYTES 7 MiB · MEDIA_UPLOAD_MAX_BYTES 64 MiB · MEDIA_RETENTION_HOURS 72 · MEDIA_STORE_MAX_BYTES 16 MiB · MEDIA_STORE_MAX_MB 2048 · MEDIA_LOW_DISK_MIN_MB 5120 · MEDIA_ALLOW_HTTP · MEDIA_URL_ALLOWLIST
v2-Grenzen
INTERACTION_RATE_POINTS 60 / INTERACTION_RATE_DURATION_S 60 · FETCH_MESSAGES_MAX 200 / FETCH_MESSAGES_DEADLINE_MS 30000 · V2_MAX_BULK_ITEMS 500
Betrieb
ALERT_WEBHOOK_URL · AUTOSTART_STAGGER_MS (15000) · INSTANCE_BIND_HOST (127.0.0.1) · ADMIN_BIND_HOST · INSTANCE_AUTH_MODE (enforce, siehe §3) · INSTANCE_MEM_* · BROWSER_PROBE_* · BROWSER_GRACE_MS · READY_TIMEOUT_MS · RESTART_WINDOW_MS / RESTART_MAX_IN_WINDOW (Quarantäne-Schwelle) · RESTART_INIT_TIMEOUT_MS / RESTART_DESTROY_TIMEOUT_MS · ORDERED_RESTART_HARD_MS · OUTBOX_MEDIA_TTL_MS · PROXY_RELAY_PORT_OFFSET / PROXY_SELFTEST_* · WEBHOOK_DEFAULT_EVENTS · INSTANCES_DB_PATH · SESSIONS_PATH · LOG_LEVEL
Achtung PM2. Die vierINSTANCE_MEM_*-Schlüssel stehen imenv-Block vonecosystem.config.js. PM2 setzt sie, bevor dotenv die.envliest, und dotenv überschreibt nichts bereits Gesetztes. Ein abweichender Wert in der.envwirkt deshalb nicht. Details: RUNBOOK.md §0.1.
Infrastruktur-Voraussetzung, die kein Code lösen kann
In ganz /etc/nginx gibt es kein client_max_body_size. Es gilt der Default 1m. Damit beantwortet nginx jeden Upload und jedes grössere Base64 selbst mit 413, bevor unser Code den Request sieht. Nötig im server-Block von wa.outrnk.io:
client_max_body_size 32m;
client_body_timeout 120s;proxy_read_timeout 300s ist bereits gesetzt und reicht.
13. Deploy und Rollback
Nicht hier dokumentiert — RUNBOOK.md ist die Quelle. Insbesondere:
| Thema | Abschnitt |
|---|---|
| Zwei Blocker, die Code nicht lösen kann | RUNBOOK §0 |
| PM2 und Umgebungsvariablen | RUNBOOK §0.1 |
Schritt 0 vor dem ersten Deploy (.env, 0600, Secrets) | RUNBOOK §1.0 |
| Deploy Schritt für Schritt mit Abbruchkriterien | RUNBOOK §1.1 |
| Pflichtprüfungen 30 min nach dem Deploy | RUNBOOK §1.2 |
| Rollback | RUNBOOK §1.3 |
| Notfall-Rollback der Härtung ohne Deploy | RUNBOOK §1.4 |
| Einzelne Instanz neu starten | RUNBOOK §2 |
| Zombie erkennen | RUNBOOK §3 |
| Quarantäne behandeln | RUNBOOK §4 |
| Bibliotheks-Update mit Canary | RUNBOOK §9 |
13.1 Inbetriebnahme der Queue — gestufte Schrittfolge
Der Ausgangszustand nach dem Deploy ist absichtlich ein Nullzustand:
QUEUE_PG_URList nicht gesetzt. Der Queue-Kern wirft deswegen nicht —init()wirft
laut Kontrakt nie —, sondern geht in den Fallback (lib/queue/index.js: kein connectionString ⇒ _enterDegraded('QUEUE_PG_URL not configured')).
- Alle Instanzen stehen auf
queue.enabled = false(QUEUE_DEFAULTS_EXISTING). Für sie
greift der Fallback gar nicht erst: init() bricht vorher mit Modus disabled ab. Ihr Verhalten ist bit-für-bit das von vorher.
Daraus folgt die Reihenfolge. Sie ist nicht beliebig: wird eine Instanz auf enabled = true gestellt, bevor QUEUE_PG_URL in ihrer Prozessumgebung steht, landet sie in memory-degraded statt in pg — sie sendet dann zwar weiter und drosselt korrekt, aber ohne Persistenz und mit 503 auf allen /api/v2/send-*. Das ist kein Ausfall, aber auch nicht das Ziel des Rollouts.
In jedem Schritt ist Rollback ohne Datenverlust möglich.
Schritt 1 — Code deployen, Queue bleibt überall aus.
Normaler Deploy nach RUNBOOK §1.1. QUEUE_PG_URL noch nicht setzen, queue.enabled bei keiner Instanz anfassen.
- Prüfpunkt:
npm run queue:testist grün (118/118, kein Datenbankzugriff nötig).
GET /api/instances/:id/limits zeigt für jede Instanz queue.enabled = false. Die sechs Bestandsrouten antworten unverändert.
- Abbruchkriterium: eine Instanz meldet nach dem Start einen anderen Modus als
disabled,
oder eine Bestandsroute ändert Statuscode oder Antwortform ⇒ Deploy-Rollback nach RUNBOOK §1.3, bevor Schritt 2 beginnt.
Schritt 2 — Schema anlegen.
cd /home/ubuntu/apps/whatsapp-multi-api
# migrate.js lädt KEIN dotenv. Die Variablen müssen in der Shell stehen:
set -a; . ./.env; set +a
QUEUE_PG_URL='postgresql://…@127.0.0.1:5432/chatbot_management' npm run queue:migrateDas Skript ist idempotent und mehrfach ausführbar. Es legt nur wa_gateway an, nimmt keinen Lock auf einer Chatbot-Tabelle, und der laufende Alt-Code sieht davon nichts.
- Prüfpunkt:
npm run queue:verifybeendet mit Exit 0 und meldet nichts Fehlendes
(3 Tabellen, 10 Indizes, Spalte wa_outbox.dispatch_attempted).
- Abbruchkriterium: Exit ≠ 0,
permission denied for database(die Rolle darf kein Schema
anlegen) oder invalid QUEUE_SCHEMA ⇒ stopp. Rückfallpunkt: DROP SCHEMA wa_gateway CASCADE. Kein Schritt 3, solange --verify nicht sauber ist.
Schritt 3 — QUEUE_PG_URL setzen und in die Prozesse bringen.
Eintragen in die .env (nicht in ecosystem.config.js — dort ist es beim nächsten pm2 restart <NAME> weg, siehe RUNBOOK §0.1). Die Vorlage .env.example führt seit dem Review alle acht QUEUE_*-Schlüssel mit Default und Wirkung; von dort abschreiben statt raten. Alle acht sind optional — nur QUEUE_PG_URL entscheidet über pg vs. memory-degraded. Danach muss der Wert zweimal ankommen: dotenv läuft nur im admin-server.js; die Instanzen sind Kindprozesse und erben ihre Umgebung im Moment von spawn(). Ein laufender Kindprozess sieht eine neue .env-Zeile also nie.
pm2 restart ecosystem.config.js --only whatsapp-api --update-env
# danach die Instanzen einzeln durchstarten (RUNBOOK §2), nicht alle gleichzeitig- Prüfpunkt: im Log jeder neu gestarteten Instanz steht nicht mehr
QUEUE_PG_URL not configured. GET /health liefert weiterhin queue.degraded = 0 (die Instanzen stehen ja noch auf disabled und zählen dort nicht mit).
- Abbruchkriterium:
max_connections-Fehler oder53300in irgendeinem Instanzlog ⇒ Zeile
wieder aus der .env entfernen und neu starten. Verbindungsbudget nachrechnen: Instanzen × QUEUE_PG_POOL_MAX (Default 3) + 2 für den Admin.
Schritt 4 — Instanzen einzeln umstellen (Canary).
Pro Instanz, eine nach der anderen, kleinste zuerst:
curl -X PUT "$ADMIN/api/instances/$ID/limits" -H "Authorization: Bearer $JWT" \
-H 'Content-Type: application/json' \
-d '{"enabled":true,"maxPerHour":40,"maxPer24h":500,"minDelayMs":4000,"maxDelayMs":12000}'Ein Neustart ist nicht nötig: applyConfig() startet den PG-Pfad zur Laufzeit (_startPg()), sofern Schritt 3 erledigt ist. Die Antwort ist 200 — bei 207 hat nur das Persistieren geklappt und der Live-Push nicht (warning, liveError lesen).
- Prüfpunkt:
GET /api/instances/:id/queue(bzw.GET /api/v2/queuean der Instanz) meldet
mode: "pg" und limitsEnforced: true. Eine Testnachricht bekommt einen jobId mit Präfix wq_ und erreicht den Zustand sent. unknown24h bleibt 0.
- Abbruchkriterium:
modebleibtmemory-degraded(⇒ Schritt 3 hat diese Instanz nicht
erreicht), oder unknown24h > 0, oder eine Bestandsroute antwortet 504 QUEUE_WAIT_TIMEOUT häufiger als erklärbar ⇒ sofort {"enabled":false} zurücksetzen. Die bereits geschriebenen Zeilen bleiben stehen und gehen bei erneutem Einschalten weiter; verloren geht nichts.
Schritt 5 — beobachten, dann verbreitern.
Erste Instanz 24 h beobachten, danach die nächste. Nach 7 Tagen ohne Befund den Rest.
- Prüfpunkt (täglich):
GET /health→queue.degraded = 0; je Instanzunknown24h = 0und
counts.queued + counts.scheduled läuft nicht monoton nach oben (das wäre ein Rückstau). npm run queue:verify weiterhin Exit 0.
- Abbruchkriterium:
queue.degraded > 0über mehr als zwei Probe-Intervalle
(QUEUE_DEGRADED_PROBE_MS, Default 30 s) hinweg, oder ein Rückstau, der QUEUE_MAX_PENDING (5000, ⇒ 422 QUEUE_FULL) ansteuert ⇒ betroffene Instanz auf enabled = false, Rollout anhalten, Ursache klären.
Vorbedingung für Schritt 4 ist zusätzlich die Prüfliste aus §15 B2: alle dort genannten Nachweise müssen im deployten Stand vorhanden sein. Insbesondere lib/queue/gate.js und lib/queue/job.js müssen existieren und npm run queue:test muss grün sein — ohne sie sind Medienversand, chatId-Ziele und die Limitdurchsetzung defekt.
| Schritt | Rückfallpunkt |
|---|---|
| 1 Deploy | Standard-Deploy-Rollback (RUNBOOK §1.3) |
| 2 Schema | DROP SCHEMA wa_gateway CASCADE |
3 QUEUE_PG_URL | Zeile aus .env entfernen, Prozesse neu starten |
| 4 Instanz umstellen | {"enabled":false} — Zeilen bleiben erhalten |
| 5 Verbreitern | siehe 4, je Instanz einzeln |
Dokumentation und Generator
Es gibt genau drei gepflegte Quellen:
| Quelle | Zielgruppe | Sprache |
|---|---|---|
docs/API.md | Kunden | Englisch |
docs/QUEUE.md | Kunden | Englisch |
docs/INTERNAL.md | Betreiber | Deutsch |
Die HTML-Seiten werden daraus generiert:
node scripts/build-docs.js # schreibt public/api-docs.html + public/admin-api-docs.html
node scripts/build-docs.js --check # exit 1, wenn das HTML nicht mehr zur Quelle passt| Ziel | Quelle |
|---|---|
public/api-docs.html | docs/API.md + docs/QUEUE.md |
public/admin-api-docs.html | docs/INTERNAL.md |
Beide HTML-Dateien tragen oben den Hinweis „generiert, nicht von Hand bearbeiten" und im <head> einen data-doc-hash mit dem SHA-256 der Quelle. --check vergleicht diesen Hash und eignet sich als Deploy-Vorprüfung.
Der Generator ist ~300 Zeilen ohne Abhängigkeiten (eigener Markdown-Renderer für die hier benutzte Teilmenge: Überschriften, Tabellen, Listen, Codeblöcke, Blockzitate, Inline-Code, Fett/Kursiv, Links). Kein Build-Schritt, kein npm-Paket, kein CI nötig.
Regel: Wer eine Route ändert, ändert docs/API.md und führt node scripts/build-docs.js aus. Wer das HTML von Hand anfasst, verliert seine Änderung beim nächsten Lauf.
15. Bekannte Grenzen und offene Punkte
A. Bewusst akzeptierte Lücken
| # | Lücke | Warum akzeptiert |
|---|---|---|
| 1 | Ohne X-Idempotency-Key ist Verlustfreiheit über die Netzwerkgrenze nicht garantierbar. | Physik. Ein Absturz vor dem COMMIT ist vom Aufrufer nicht von „nie angekommen" unterscheidbar. Steht fett in beiden Kundendokumenten. |
| 2 | PG-Ausfall zwischen Dispatch und COMMIT ⇒ unknown, WhatsApp-ID nur in der PM2-Zeile DISPATCH_ID job=… wa=… chat=…. | Automatische Auflösung ist unmöglich; jede Annahme schadet in eine Richtung. |
| 3 | In disabled sind Limits nicht durchgesetzt. | Die Queue ist aus — es gibt nichts durchzusetzen. memory-degraded setzt sie über dasselbe Gate durch. |
| 3b | In memory-degraded gilt die Zusage „kein Verlust" nicht: ein unversandter Rückstand liegt nur im Heap und ist nach einem Prozessabsturz weg. Die JSONL-Datei ist forensisch, kein Replay. | Automatisches Replay wäre der Duplikat-Vorfall. Deshalb lehnen die v2-Senderouten in diesem Modus mit 503 ab, statt etwas Unwahres zuzusagen. |
| 4 | Video→Sticker bleibt unmöglich (kein ffmpeg). | +250 MB und CPU-Last auf einem Host mit fünf Chrome-Bäumen. Route lehnt sauber mit 400 STICKER_VIDEO_UNSUPPORTED ab. |
| 5 | PTT wird nicht transkodiert. | Siehe 4. Nicht-OGG bekommt warnings, keine stille Falschzustellung. |
| 6 | Base64 > ~750 KB scheitert an nginx. | Infrastrukturänderung, kein Code. Siehe §12. |
| 7 | Legacy /api/send-message kann bei aktiven Limits 504 QUEUE_WAIT_TIMEOUT liefern. | Bei maxPerHour = null unerreichbar. Tritt erst nach bewusster Aktivierung auf, und dann ist die Nachricht in der Queue und geht raus. |
| 8 | wa_gateway liegt in der DB eines Fremddienstes (chatbot_management). | Die Rolle chatbot kann kein DB anlegen (rolcreatedb=f). Schema-Isolation ist die stärkste erreichbare Trennung ohne Superuser. |
| 9 | /api/v2/messages/:id ist doppelt belegt (Queue-Job in C4, WhatsApp-Nachricht in D1.3). | Express kann den Pfad nur einmal binden. Unterschieden wird über das Präfix wq_. Eine WhatsApp-Message-ID, die zufällig mit wq_ beginnt, gibt es nicht. |
B. Offener Defekt
Verifiziert gegen den Code in diesem Verzeichnis, Stand dieser Datei.
| # | Defekt | Fundstelle | Wirkung |
|---|---|---|---|
| B1 | POST /api/v2/queue/replan ruft queueCore.replan(). Diese Methode existiert auf der Fassade nicht (typeof queue.replan === 'undefined', gegen den geladenen Kern geprüft), also greift der Rückfall applyConfig(queueCfg) — mit unveränderter Konfiguration. needsReplan() ist damit false. | instance-server.js Route /api/v2/queue/replan, lib/queue/index.js applyConfig() | Die Route antwortet {replanned:false} und tut nichts. Gleiches gilt für POST /api/instances/:id/queue/replan. Kein Schaden, aber eine Zusage, die nicht eingelöst wird. Fix: entweder replan() auf der Fassade ergänzen (planner.replan('manual') plus queue.replanned-Webhook) oder die Route entfernen. |
B2. Was in dieser Runde behoben wurde — zur Kontrolle
Der vorangegangene Review hatte sieben weitere Blocker gemeldet. Sie sind im aktuellen Stand nachweislich geschlossen; hier steht, woran man das erkennt, damit ein Rollback nicht unbemerkt dahinter zurückfällt.
| Ehemaliger Blocker | Nachweis im Code |
|---|---|
sendFn las camelCase, der Dispatcher übergab die rohe snake_case-Zeile ⇒ Medien und chatId gingen verloren | lib/queue/job.js (toSendJob/normalizeJob) normalisiert beide Speicherformen auf ein Schema; sendFn ruft normalizeJob(item) als erste Zeile |
ctx.markDispatchAttempted() wurde nie aufgerufen ⇒ Post-Dispatch-Fehler als failed statt unknown | 6 Aufrufstellen in instance-server.js, jeweils unmittelbar vor dem wwebjs-Aufruf, ohne await dazwischen |
| Dispatcher prüfte beim Claim weder Mindestabstand noch Limits ⇒ Burst nach jedem Stillstand | lib/queue/gate.js evaluateGate(), ausgewertet in Dispatcher._gateDecision() innerhalb des Claims und im Memory-Fallback |
| Kein Übergang in den Degraded-Modus zur Laufzeit ⇒ PG-Ausfall legte den Sendepfad lahm | Queue._onStoreFailure() + Queue.isConnectivityError() + _guard(); _enterDegraded wird jetzt auch aus dem Laufzeitpfad gerufen |
POST /api/v2/groups mit requireTarget:false ⇒ immer 400 | built.target (erster Teilnehmer) wird in v2SendRoute als synthetisches Ziel gesetzt; sendFn fängt kind==='group-create' vor der Zielauflösung ab |
v2SubmitError las e.status, QueueError setzte nur e.http | QueueError spiegelt den Wert: this.status = this.http |
AUTOSTART_STAGGER_MS war auf 45000 zurückgefallen | steht wieder auf 15000 |
C. Betriebliche Grenzen
- Der Memory-Guard ist ein Ausreisser-Detektor, kein Kapazitätsplan (RUNBOOK §8.3).
- Die Browser-Probe erkennt einen toten Browser, nicht einen langsamen.
fetchMessagesmit hohemlimitbleibt der teuerste Aufruf im System (Cap 200, 30-s-Deadline).- Es gibt keine Mandantentrennung innerhalb einer Instanz — eine Instanz = eine Nummer = ein Kunde.
16. Abweichungen zwischen Kontrakt und Code
Der Bauplan der Wellen C+D und der tatsächliche Code weichen an folgenden Stellen ab. Der Code gewinnt, die Dokumente oben beschreiben den Code.
| Kontrakt sagt | Code macht | Bewertung |
|---|---|---|
scripts/queue-migrate.js + scripts/queue-migrate.sql | lib/queue/migrate.js + lib/queue/schema.sql, npm-Skripte queue:migrate / queue:verify | rein kosmetisch |
wa_gateway.wa_media als Metadatenspeicher | DDL vorhanden, der Code führt den Index aber in data/media/<instanceId>/index.json | Medienmetadaten überleben einen Verlust des Datenverzeichnisses nicht. Für 72-h-Daten vertretbar, aber bewusst zu wissen. |
mediaBase64 hart auf 8 MiB Binär | MEDIA_BASE64_MAX_BYTES = 7340032 (7 MiB) | unkritisch |
| PTT-Mimetypes: 7 Werte | zusätzlich audio/opus, audio/mp3, audio/x-wav, audio/webm | grosszügiger, mit Warnung |
POST /api/v2/media liefert 200 | liefert 201 | dokumentiert |
Degraded: /api/v2/* Senderouten ⇒ 503 | zusätzlich auch im Modus disabled ⇒ 503 | wichtig: für die fünf Bestandsinstanzen (Default enabled:false) ist jede v2-Senderoute heute 503 |
queue.replanned-Webhook bei Limitänderung | wird gefeuert (applyConfig), aber die explizite Replan-Route ist wirkungslos | siehe B1 |
prepareFn für den zweiten SSRF-Check | nicht injiziert; der zweite Check läuft stattdessen in sendFn selbst | funktional gleichwertig, aber Fehler dort werden nicht garantiert als T8 gewertet |
Limits ausschliesslich über scheduled_at | zweite Schicht lib/queue/gate.js prüft unmittelbar vor dem Versand erneut | besser als der Kontrakt; ohne sie wäre jeder Stillstand ein Burst |
| Degraded nur beim Start | Queue._onStoreFailure() schaltet auch zur Laufzeit um, mit Positivliste der Verbindungsfehler | besser als der Kontrakt |
QUEUE_PAUSED für alle Routen | nur für /api/v2/*; die sechs Bestandsrouten warten wie bisher | bewusst, verhindert eine Verhaltensänderung an der Aussenkante |
/api/send-direct, /api/send-raw nicht erwähnt | existieren auf der Instanz, sind nicht extern durchgereicht | korrekt so, als Diagnosewerkzeug dokumentiert |
Was der Chatbot heute wirklich benutzt
chatbot-src/services/whatsappIntegrationService.ts:221-238 ruft POST /api/instances/:id/send-message gegen http://localhost:3001. Diese Route existiert im admin-server.js nicht — der Pfad ist tot. Der Chatbot ruft über diesen Service also keine der sechs Bestandsrouten produktiv auf. Trotzdem bleiben alle sechs unverändert; die Annahme „der Chatbot hängt daran" darf nicht die einzige Begründung sein, aber die Kompatibilitätszusage gilt gegenüber jedem Kunden.
Relevant für B1: der Chatbot übergibt ausschliesslich chatId, nie phone. Sobald für seine Instanz queue.enabled = true gesetzt wird, träfe ihn B1 sofort und vollständig.