WAHA Incident Router — Design Document
Status: Approved (2026-05-12), ready for implementation Branch:
feat/waha-incident-routerOwner: 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:
- Kierowca/dyspozytor wysyła wiadomość do grupy WhatsApp zaczynającą się od numeru rejestracyjnego.
- 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.
- Rozpoznaje numer auta (exact match w
- 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)
| Pytanie | Decyzja |
|---|---|
| Reply | W grupie (kontekst widoczny dla wszystkich uczestników) |
| Re-open window | 7 dni od closed_at |
| Kategorie | usterka, awaria, material_request, info |
| Klasyfikacja | LLM (Claude Haiku przez claude-proxy vps-h1) |
| Multi-vehicle | Strict — każda tablica = osobne ZG, brak active session po multi |
| Retention mediów | Forever — bulk delete per-vehicle przy retirement floty |
| Plates cache | Workers KV singleton, daily cron sync + 30-min auto-resync przy miss |
| n8n parallel fallback | TAK — shadow mode 7 dni po cutover dla porównania i rollback |
| Custom domain | waha.infra.zintegrowana.online (Cloudflare DNS, zone zintegrowana.online) |
| LLM re-classify | TAK 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/gPokrywa: 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 interwencjiusterka— drobna wada, pojazd działamaterial_request— prośba o części/materiały/narzędziainfo— 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):
- Wywołaj LLM z pełnym kontekstem zgłoszenia (poprzednie wiadomości + nowa).
- Otrzymaj nową
category+confidence. - 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":"..."} ]
- 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:
- Plate match (single) → set session, route do ZG.
- Plate match (multi, strict) → utwórz N ZG, brak active session.
- Brak plate + session → continuation aktywnego ZG.
- Brak plate + brak session → reply z prośbą o tablicę.
- 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_KEY13. Failure modes & retry
| Failure | Strategy |
|---|---|
| Worker CPU timeout (30s) | Media stream do Wasabi via Queue, reply natychmiast |
| Wasabi upload fail | Push do MEDIA_DLQ, retry 3x z backoff, Sentry alert |
| Supabase fail | Retry 2x, jeśli wciąż fail → DLQ + alert |
| WAHA reply fail | Log, ale nie blokujemy zapisu do Supabase |
| HMAC verify fail | 401 + log, brak retry — potencjalny atak |
| LLM classify fail | Zachowaj poprzednią category, warning log |
KV plates:all cold | Synchroniczny refresh przed pierwszym lookup |
| Plate miss + cache stale | Auto-resync gdy ≥30 min od synced_at |
| n8n shadow fail | Log only, nie blokuje produkcyjnego flow |
Idempotency: klucz p24_whatsapp_messages.id (= WAHA message ID) zapobiega duplikatom przy WAHA retry.
14. Roadmap
| Etap | Czas | Co |
|---|---|---|
| 0. Design freeze | done | Ten dokument zatwierdzony |
| 1. DNS + Wasabi | 0.5 dnia | Cloudflare record waha.infra.zintegrowana.online, bucket p24-infra setup |
| 2. Supabase migracje | 0.5 dnia | p24_whatsapp_threads, thread_counter, allocate_thread_number, alter p24_whatsapp_messages |
| 3. Worker skeleton | 1 dzień | wrangler init, KV namespaces, HMAC verify, idempotency |
| 4. Plates KV cache | 1 dzień | cron-sync, lookup + 30-min auto-resync, admin refresh endpoint |
| 5. Plate detection + state | 2 dni | parser, sessions KV, multi-vehicle strict, re-open window |
| 6. Wasabi media | 1.5 dnia | stream upload, signed URL, schema fields, DLQ |
| 7. LLM classify + re-classify | 1.5 dnia | claude-proxy integration, category_history, change notifications |
| 8. WAHA reply + humanization | 1 dzień | sendSeen → typing → reply pattern |
| 9. Shadow cutover | 0.5 dnia | dual webhook config, n8n shadow workflow |
| 10. Observability | 1.5 dnia | Sentry, Grafana dashboard (open ZG, response time, kategorie, top reporters, shadow diff) |
| 11. Validation (shadow mode) | 7 dni | Worker + n8n parallel, daily diff review |
| 12. Shadow drop | 0.5 dnia | usuń 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.
16. Related
- waha.md — WAHA podstawy, anti-blocking, n8n patterns
- infrastructure-overview.md — pełna mapa infry
- design-plan.md — poprzedni plan (deprecated po cutover)