WAHA Incident Router — Design Document

Status: Approved (2026-05-12), ready for implementation Branch: feat/waha-incident-router Owner: Radek Konarski Related: waha.md, infrastructure-overview.md


1. Cel

Zamiana WhatsApp jako kanału ad-hoc do raportowania awarii w strukturalny pipeline zgłoszeń serwisowych:

  1. Kierowca/dyspozytor wysyła wiadomość do grupy WhatsApp zaczynającą się od numeru rejestracyjnego.
  2. System automatycznie:
    • Rozpoznaje numer auta (exact match w p24_l_cars.plates_normalized).
    • Zakłada zgłoszenie ZG/YYYY/MM/NNN.
    • Klasyfikuje przez LLM: usterka | awaria | material_request | info.
    • Archiwizuje wszystkie media na Wasabi w strukturze waha/{chat_id}/{plate}/{YYYY}/{MM}/.
    • Odpowiada w grupie potwierdzeniem + numerem zgłoszenia.
    • Dołącza kolejne wiadomości z 30-min sesji do tego samego zgłoszenia.
  3. Dane trafiają do Supabase jako gotowe rekordy — bez triggerów, bez post-processingu.

Architektoniczna zmiana: logika przed bazą, nie w bazie.


2. Zatwierdzone decyzje (2026-05-12)

PytanieDecyzja
ReplyW grupie (kontekst widoczny dla wszystkich uczestników)
Re-open window7 dni od closed_at
Kategorieusterka, awaria, material_request, info
KlasyfikacjaLLM (Claude Haiku przez claude-proxy vps-h1)
Multi-vehicleStrict — każda tablica = osobne ZG, brak active session po multi
Retention mediówForever — bulk delete per-vehicle przy retirement floty
Plates cacheWorkers KV singleton, daily cron sync + 30-min auto-resync przy miss
n8n parallel fallbackTAK — shadow mode 7 dni po cutover dla porównania i rollback
Custom domainwaha.infra.zintegrowana.online (Cloudflare DNS, zone zintegrowana.online)
LLM re-classifyTAK po każdej wiadomości z istotną treścią (tekst >30 znaków, nie czyste media)

3. Architektura

┌──────────────────────┐
│   WhatsApp Group     │
│  (kierowcy + dispo)  │
└──────────┬───────────┘
           │ message
           ▼
┌──────────────────────┐
│  WAHA (vps-h1:13000) │  ← przepinamy webhook
└──────────┬───────────┘
           │ HMAC webhook → 2 odbiorców (cutover 7 dni)
           ├─→ Worker (produkcyjny)
           └─→ n8n (shadow mode, tylko log)
           ▼
┌──────────────────────────────────────────────────────┐
│  Cloudflare Worker: waha-incident-router             │
│  Custom domain: waha.infra.zintegrowana.online       │
│                                                      │
│  1. Verify HMAC signature                            │
│  2. Parse payload + plate regex                      │
│  3. Plate lookup (KV cache, 30-min auto-resync)      │
│  4. State machine (KV: session:{phone})              │
│  5. Allocate ZG number (Supabase RPC, atomic)        │
│  6. LLM classify (claude-proxy)                      │
│  7. Stream media → Wasabi                            │
│  8. Insert do Supabase                               │
│  9. Reply in group (humanized)                       │
│                                                      │
│  Bindings:                                           │
│   • KV: SESSIONS (active 30-min)                     │
│   • KV: PLATES_KV (cars cache)                       │
│   • Queue: MEDIA_DLQ                                 │
│   • Secrets: WAHA_API_KEY, WAHA_HMAC_SECRET,         │
│              WASABI_ACCESS_KEY, WASABI_SECRET_KEY,   │
│              SUPABASE_SERVICE_KEY, CLAUDE_PROXY_KEY  │
└────────────────────┬─────────────────────────────────┘
                     │
        ┌────────────┼────────────────┐
        ▼            ▼                ▼
   ┌────────┐  ┌─────────┐      ┌──────────┐
   │ Wasabi │  │Supabase │      │   WAHA   │
   │ p24-   │  │ zgloszen│      │  reply   │
   │ infra/ │  │  +msgs  │      │   API    │
   │ waha/  │  │         │      │  (group) │
   └────────┘  └─────────┘      └──────────┘

4. Wasabi storage layout

Bucket: p24-infra (region eu-central-2, endpoint s3.eu-central-2.wasabisys.com)

Path schema: waha/{chat_id}/{plate_normalized}/{YYYY}/{MM}/{filename}

Przykład:

p24-infra/
└── waha/
    ├── 4915253015583-1548075046@g.us/
    │   ├── HVLET256/
    │   │   └── 2026/05/
    │   │       ├── ZG-2026-05-001-img-AC9C0BA5E30B.jpg
    │   │       ├── ZG-2026-05-001-aud-AC9C0BA5E30C.ogg
    │   │       └── ZG-2026-05-001-meta.json
    │   └── _unmatched/2026/05/attempt-XYZ999-img-...jpg
    └── 120363401944773725@g.us/...

Filename: {ZG_number}-{type}-{wa_msg_id_short}.{ext} (type = img|aud|vid|doc)

Retention: forever. Struktura {plate}/ pozwala na bulk delete jednym aws s3 rm --recursive po retirement auta.

Privacy: bucket prywatny. Signed URLs z TTL 7 dni (active) / 30 dni (resolved). Regen lazy on-demand.


5. Database schema

5.1 Nowa tabela p24_whatsapp_threads

CREATE TABLE public.p24_whatsapp_threads (
  id              uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  number          text UNIQUE NOT NULL,             -- "ZG/2026/05/001"
  year            int NOT NULL,
  month           int NOT NULL,
  seq             int NOT NULL,
 
  plate_input     text NOT NULL,
  plate_normalized text NOT NULL,
  car_id          uuid REFERENCES p24_l_cars(id),
 
  reporter_phone  text NOT NULL,
  reporter_name   text,
  chat_id         text NOT NULL,
 
  status          text NOT NULL DEFAULT 'open'
                  CHECK (status IN ('open','in_progress','resolved','invalid')),
  category        text
                  CHECK (category IN ('usterka','awaria','material_request','info')),
  category_confidence numeric(3,2),
  category_history jsonb DEFAULT '[]'::jsonb,       -- log re-classify per wiadomość
  summary         text,
 
  message_count   int NOT NULL DEFAULT 0,
  media_count     int NOT NULL DEFAULT 0,
 
  opened_at       timestamptz NOT NULL DEFAULT now(),
  last_activity_at timestamptz NOT NULL DEFAULT now(),
  closed_at       timestamptz,
 
  UNIQUE (year, month, seq)
);
 
CREATE INDEX idx_zg_active_phone ON p24_whatsapp_threads(reporter_phone, status)
  WHERE status = 'open';
CREATE INDEX idx_zg_car ON p24_whatsapp_threads(car_id);
CREATE INDEX idx_zg_chat ON p24_whatsapp_threads(chat_id, status);
CREATE INDEX idx_zg_reopen_window ON p24_whatsapp_threads(closed_at)
  WHERE closed_at IS NOT NULL;

5.2 Counter (atomic ZG allocation)

CREATE TABLE public.thread_counter (
  yearmonth text PRIMARY KEY,                       -- "2026-05"
  next_seq  int NOT NULL DEFAULT 1
);
 
CREATE OR REPLACE FUNCTION allocate_thread_number(p_year int, p_month int)
RETURNS int LANGUAGE plpgsql AS $$
DECLARE
  v_ym text := lpad(p_year::text,4,'0') || '-' || lpad(p_month::text,2,'0');
  v_seq int;
BEGIN
  INSERT INTO thread_counter (yearmonth, next_seq)
  VALUES (v_ym, 2)
  ON CONFLICT (yearmonth)
  DO UPDATE SET next_seq = thread_counter.next_seq + 1
  RETURNING next_seq - 1 INTO v_seq;
  RETURN v_seq;
END $$;

5.3 Rozszerzenie p24_whatsapp_messages

ALTER TABLE public.p24_whatsapp_messages
  ADD COLUMN thread_id        uuid REFERENCES p24_whatsapp_threads(id),
  ADD COLUMN is_thread_starter boolean DEFAULT false,
  ADD COLUMN plate_detected       text,
  ADD COLUMN plate_normalized     text,
  ADD COLUMN media_wasabi_key     text,
  ADD COLUMN media_wasabi_url     text,
  ADD COLUMN media_url_expires_at timestamptz,
  ADD COLUMN media_size_bytes     bigint,
  ADD COLUMN media_sha256         text,
  ADD COLUMN llm_category         text,             -- per-message classification (re-classify)
  ADD COLUMN llm_category_confidence numeric(3,2);
 
CREATE INDEX idx_msg_zgloszenie ON p24_whatsapp_messages(thread_id);

6. Conversational flow

6.1 Standard happy path

Driver: HVLET256 nie odpala, padl akumulator [+ zdjęcie]

Bot (in group):
        ✅ ZG/2026/05/001 założone
        🚛 HVLET256 — MAN TGM (ecotrans Charter GmbH)
        👤 Darek
        🚨 Kategoria: awaria

        📋 "nie odpala, padl akumulator"
        📎 1 zdjęcie zapisane

        Dodaj więcej zdjęć/audio/video przez 30 min.
        Wpisz STOP aby zamknąć wcześniej.

[Driver: kolejne zdjęcie w 15 min]
Bot:    📷 ZG/2026/05/001 — dodano (zdjęć: 2)

[30 min bezczynności]
Bot:    🔒 Zamknięto ZG/2026/05/001
        Razem: 3 wiadomości, 2 zdjęcia, 1 nagranie.
        Re-open przez 7 dni: wpisz ZG/2026/05/001

6.2 Plate nieznaleziona

Driver: HVLET999 hamulec piszczy

Bot:    ❌ Nie znalazłem auta HVLET999 w bazie.
        Sprawdź numer:
        • Bez spacji (HVLET256, nie HVL ET 256)
        • Wielkie litery
        • Polski lub niemiecki format
        Szukałem: HVLET999

6.3 Brak numeru auta

Driver: cześć, mam pytanie

Bot:    👋 Aby założyć zgłoszenie, zacznij od numeru rejestracyjnego.
        Przykład: HVLET256 lub WPI12345
        (bez spacji, wielkimi literami)

6.4 Multi-vehicle — strict

Driver: HVLET256 zepsuty, zastępcze HVLET252

Bot:    ✅ 2 pojazdy — 2 zgłoszenia:
        ZG/2026/05/001 — HVLET256 (MAN TGM)
        ZG/2026/05/002 — HVLET252 (MAN TGX)
        🚨 Kategoria: awaria
        📋 "zepsuty, zastępcze"

        ⚠ Sesja kontynuacji NIE jest ustawiona.
        Kolejne wiadomości wymagają numeru ZG lub tablicy:
          ZG/2026/05/001: zdjęcie usterki
        lub
          HVLET256 dodam info

Active session w KV nie jest ustawiana po multi-plate.

6.5 Re-open zamkniętego (do 7 dni)

Driver: ZG/2026/05/001 jeszcze nie naprawione

Bot:    🔓 Otwarto ponownie ZG/2026/05/001 (HVLET256)
        Status: resolved → in_progress
        Sesja aktywna 30 min.

Po 7 dniach:

Bot:    🔒 ZG/2026/05/001 zamknięte 8 dni temu — nie można otworzyć.
        Załóż nowe zgłoszenie z numerem auta.

7. Plate detection

7.1 Zasady

  • Exact match w plates_normalized — żadnego fuzzy.
  • ~500 aut → cache w Workers KV (§7.4).
  • Miss → user musi poprawić.

7.2 Regex (pierwsze 80 znaków)

/\b[A-Z]{1,3}[\s\-]?[A-Z]{0,3}[\s\-]?[0-9]{1,4}[A-Z]?\b/g

Pokrywa: DE (HVLET256, HVL ET 256), PL (WPI12345), custom (NAG1776, BET743).

7.3 Normalizacja

const normalize = (raw: string) =>
  raw.toUpperCase().replace(/[\s\-\.]/g, '').trim();

7.4 Lookup z KV cache + 30-min auto-resync

async function lookupPlate(env: Env, normalized: string) {
  let cache = await env.PLATES_KV.get('plates:all', 'json');
 
  if (!cache) {
    await refreshKvFromSupabase(env);
    cache = await env.PLATES_KV.get('plates:all', 'json');
    if (!cache) return null;
  }
 
  const hit = cache.plates[normalized];
  if (hit) return hit;
 
  const ageMs = Date.now() - new Date(cache.synced_at).getTime();
  if (ageMs < 30 * 60 * 1000) return null;
 
  // stary cache + miss → resync + retry once
  await refreshKvFromSupabase(env);
  const fresh = await env.PLATES_KV.get('plates:all', 'json');
  return fresh?.plates[normalized] ?? null;
}

Throttle resync: lock plates:resync_lock (TTL 60s). Manual trigger: POST /admin/refresh-plates z X-Admin-Key.

7.5 Cron worker

// src/cron-sync.ts
export default {
  async scheduled(_, env) { await refreshKvFromSupabase(env); }
};
 
async function refreshKvFromSupabase(env: Env) {
  const { data } = await env.SUPABASE
    .from('p24_l_cars')
    .select('id, plates, plates_normalized, brand, model, car_owner, status_op')
    .neq('is_test', true)
    .not('plates_normalized', 'is', null);
 
  const plates = Object.fromEntries(
    data.map(c => [c.plates_normalized, {
      id: c.id, plates_raw: c.plates, brand: c.brand,
      model: c.model, owner: c.car_owner, status_op: c.status_op
    }])
  );
 
  await env.PLATES_KV.put('plates:all', JSON.stringify({
    synced_at: new Date().toISOString(),
    count: data.length,
    plates
  }));
}

Cron: [triggers] crons = ["0 4 * * *"] (04:00 UTC).


8. LLM klasyfikacja kategorii

Kategorie:

  • awaria — pojazd nie nadaje się do jazdy / blokuje pracę / wymaga natychmiastowej interwencji
  • usterka — drobna wada, pojazd działa
  • material_request — prośba o części/materiały/narzędzia
  • info — informacja, ogłoszenie, pytanie organizacyjne

Provider: Claude Haiku przez claude-proxy (vps-h1).

Worker code:

async function classify(text: string, plate: string) {
  const res = await fetch(env.CLAUDE_PROXY_URL, {
    method: 'POST',
    headers: { 'Authorization': `Bearer ${env.CLAUDE_PROXY_KEY}` },
    body: JSON.stringify({
      model: 'claude-haiku-4-5',
      max_tokens: 200,
      system: `Klasyfikujesz zgłoszenia serwisowe floty pojazdów.
Zwróć JSON: {"category":"<usterka|awaria|material_request|info>","confidence":<0-1>,"summary":"<≤80 znaków po polsku>"}.
 
Definicje:
- awaria: pojazd nie jeździ / blokuje pracę / wymaga natychmiastowej interwencji
- usterka: drobna wada, pojazd działa
- material_request: prośba o części/materiały/narzędzia
- info: ogłoszenie/pytanie/info organizacyjne`,
      messages: [{ role: 'user', content: `Pojazd: ${plate}\nTreść: "${text}"` }]
    })
  });
  return JSON.parse((await res.json()).content[0].text);
}

8.1 Re-classify per wiadomość

Per każda nowa wiadomość w aktywnej sesji z istotną treścią tekstową (>30 znaków, nie czyste media):

  1. Wywołaj LLM z pełnym kontekstem zgłoszenia (poprzednie wiadomości + nowa).
  2. Otrzymaj nową category + confidence.
  3. Update:
    • p24_whatsapp_messages.llm_category / llm_category_confidence (per-message snapshot)
    • p24_whatsapp_threads.category / category_confidence (najnowsza)
    • Append do p24_whatsapp_threads.category_history:
      [
        {"at":"2026-05-12T14:30Z","category":"usterka","confidence":0.7,"msg_id":"..."},
        {"at":"2026-05-12T14:35Z","category":"awaria","confidence":0.95,"msg_id":"..."}
      ]
  4. Notify w grupie tylko gdy kategoria się zmieniła:
    Bot: 🔄 ZG/2026/05/001 — kategoria zmieniona: usterka → awaria
    

Throttle: re-classify max raz na 60s per ZG (anti-spam). Skip: media-only update, wiadomości <30 znaków, wiadomości typu “ok”, “tak”, “stop”, linki bez treści.

Fallback: jeśli LLM call fail → zachowaj poprzednią category, log warning.

Reply z kategorią (emoji): 🚨 awaria | ⚠ usterka | 📦 material_request | ℹ info


9. State management — sesje (Workers KV)

Klucz: session:{reporter_phone} Wartość:

{
  "thread_id": "uuid",
  "thread_number": "ZG/2026/05/001",
  "car_id": "uuid",
  "plate_normalized": "HVLET256",
  "chat_id": "4915253015583-1548075046@g.us",
  "opened_at": "2026-05-12T14:30:00Z",
  "last_message_at": "2026-05-12T14:35:12Z",
  "message_count": 3,
  "media_count": 2
}

TTL: 1800s (30 min), odświeżany przy każdej wiadomości w sesji.

Decyzja flow:

  1. Plate match (single) → set session, route do ZG.
  2. Plate match (multi, strict) → utwórz N ZG, brak active session.
  3. Brak plate + session → continuation aktywnego ZG.
  4. Brak plate + brak session → reply z prośbą o tablicę.
  5. ZG-number prefix (ZG/2026/05/001) → re-open lub continuation tego ZG.

10. WAHA webhook — cutover z shadow mode

Podczas migracji WAHA wysyła webhook na dwa endpointy równolegle przez 7 dni:

# vps-h1 (root@72.60.32.61)
curl -X PATCH http://localhost:13000/api/sessions/default \
  -H "X-Api-Key: $WAHA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "config": {
      "webhooks": [
        {
          "url": "https://waha.infra.zintegrowana.online/webhook",
          "events": ["message", "message.any"],
          "hmac": { "key": "<HMAC_SECRET>" },
          "retries": { "policy": "exponential", "maxRetries": 5, "delaySeconds": 2 }
        },
        {
          "url": "https://n8n.vps-h1.infra.zintegrowana.online/webhook/waha-shadow",
          "events": ["message"],
          "retries": { "policy": "exponential", "maxRetries": 2, "delaySeconds": 5 }
        }
      ]
    }
  }'

n8n shadow workflow (waha-shadow):

  • Loguje payload do tabeli p24_whatsapp_messages_shadow (lub na Supabase z flagą shadow=true).
  • Nie wysyła odpowiedzi (żeby nie było dwóch botów w grupie).
  • Daily diff: porównanie liczby/treści wiadomości w Worker vs Shadow → dashboard w Grafanie.

Rollback procedure: usuń pierwszy webhook (Worker URL), zostaw tylko n8n + włącz n8n reply flow z powrotem. <1 min.

Po 7 dniach validacji: drop shadow webhook i tabela _shadow.


11. Anti-blocking compliance

Humanization pattern z waha.md §7:

async function replyWithHumanization(chatId: string, text: string) {
  await wahaApi.sendSeen(chatId);
  await wahaApi.startTyping(chatId);
  await sleep(Math.min(text.length * 100, 4000));
  await wahaApi.stopTyping(chatId);
  await wahaApi.sendText(chatId, text);
}

Throttle:

  • Max 1 reply per ZG per 30s.
  • Brak reply na from_me: true.
  • Brak reply na forwarded.

12. Worker implementation skeleton

Stack: TypeScript + Cloudflare Workers + @aws-sdk/client-s3 + @supabase/supabase-js.

Repo:

infra-src/
└── waha-router/
    ├── package.json
    ├── wrangler.toml
    ├── src/
    │   ├── index.ts              ← webhook entry
    │   ├── cron-sync.ts          ← daily plates sync
    │   ├── hmac.ts               ← signature verify
    │   ├── parser.ts             ← plate regex + extraction
    │   ├── classifier.ts         ← LLM categorization + re-classify
    │   ├── supabase.ts           ← client + queries
    │   ├── wasabi.ts             ← S3 upload + signed URL
    │   ├── waha-api.ts           ← reply send
    │   ├── state.ts              ← sessions KV
    │   ├── plates.ts             ← plates KV cache + resync
    │   └── types.ts              ← WAHA payload types
    └── test/
        ├── parser.test.ts
        ├── classifier.test.ts
        └── plates.test.ts

wrangler.toml:

name = "waha-incident-router"
main = "src/index.ts"
compatibility_date = "2026-05-01"
 
[[kv_namespaces]]
binding = "SESSIONS"
id = "<wrangler kv:namespace create SESSIONS>"
 
[[kv_namespaces]]
binding = "PLATES_KV"
id = "<wrangler kv:namespace create PLATES_KV>"
 
[[queues.producers]]
binding = "MEDIA_DLQ"
queue = "waha-media-failed"
 
[triggers]
crons = ["0 4 * * *"]
 
[routes]
pattern = "waha.infra.zintegrowana.online/*"
zone_name = "zintegrowana.online"
 
[vars]
WASABI_BUCKET = "p24-infra"
WASABI_REGION = "eu-central-2"
WASABI_ENDPOINT = "https://s3.eu-central-2.wasabisys.com"
WAHA_BASE_URL = "https://waha2.vps-h1.infra.zintegrowana.online"
SUPABASE_URL = "https://mwkqmgadqnkkihjdeqsi.supabase.co"
CLAUDE_PROXY_URL = "https://claude-proxy.vps-h1.infra.zintegrowana.online"
 
# secrets (via `wrangler secret put`):
#   WAHA_API_KEY
#   WAHA_HMAC_SECRET
#   WASABI_ACCESS_KEY
#   WASABI_SECRET_KEY
#   SUPABASE_SERVICE_KEY
#   CLAUDE_PROXY_KEY
#   ADMIN_KEY

13. Failure modes & retry

FailureStrategy
Worker CPU timeout (30s)Media stream do Wasabi via Queue, reply natychmiast
Wasabi upload failPush do MEDIA_DLQ, retry 3x z backoff, Sentry alert
Supabase failRetry 2x, jeśli wciąż fail → DLQ + alert
WAHA reply failLog, ale nie blokujemy zapisu do Supabase
HMAC verify fail401 + log, brak retry — potencjalny atak
LLM classify failZachowaj poprzednią category, warning log
KV plates:all coldSynchroniczny refresh przed pierwszym lookup
Plate miss + cache staleAuto-resync gdy ≥30 min od synced_at
n8n shadow failLog only, nie blokuje produkcyjnego flow

Idempotency: klucz p24_whatsapp_messages.id (= WAHA message ID) zapobiega duplikatom przy WAHA retry.


14. Roadmap

EtapCzasCo
0. Design freezedoneTen dokument zatwierdzony
1. DNS + Wasabi0.5 dniaCloudflare record waha.infra.zintegrowana.online, bucket p24-infra setup
2. Supabase migracje0.5 dniap24_whatsapp_threads, thread_counter, allocate_thread_number, alter p24_whatsapp_messages
3. Worker skeleton1 dzieńwrangler init, KV namespaces, HMAC verify, idempotency
4. Plates KV cache1 dzieńcron-sync, lookup + 30-min auto-resync, admin refresh endpoint
5. Plate detection + state2 dniparser, sessions KV, multi-vehicle strict, re-open window
6. Wasabi media1.5 dniastream upload, signed URL, schema fields, DLQ
7. LLM classify + re-classify1.5 dniaclaude-proxy integration, category_history, change notifications
8. WAHA reply + humanization1 dzieńsendSeen → typing → reply pattern
9. Shadow cutover0.5 dniadual webhook config, n8n shadow workflow
10. Observability1.5 dniaSentry, Grafana dashboard (open ZG, response time, kategorie, top reporters, shadow diff)
11. Validation (shadow mode)7 dniWorker + n8n parallel, daily diff review
12. Shadow drop0.5 dniausuń shadow webhook + tabela _shadow

Total active dev: ~11 dni + 7 dni shadow validation = 2-3 tygodnie do steady state.


15. Open questions

Wszystkie rozwiązane 2026-05-12. Sekcja zamknięta.