Přeskočit obsah

Chat systém — Specifikace

Přehled

Integrovaný chat v AVAX platformě. Zprávy uloženy jako JSON soubory na dedikovaném S3 bucketu (MinIO), doručení přes adaptive polling s pending_deliveries frontou. Žádný WebSocket server, žádný Redis Pub/Sub — nízká HW náročnost pro 100 000+ uživatelů.


Architektura — S3 jako primární store

Tradiční chat:   klient → WS server → Redis Pub/Sub → DB → klient
AVAX chat:       klient → backend API → S3 (zápis) → pending_deliveries
                 klient ← polling ←────────────────── pending_deliveries

Výhody pro self-hosted MinIO: - Žádné per-request poplatky (vlastní hardware) - GC pro zprávy = stejný mechanismus jako pro zálohy - Horizontálně škálovatelné bez sdíleného stavu - Presigned URLs pro soubory bez proxy přes backend


Dedikovaný S3 bucket

Bucket:     avax-chat
Přístup:    privátní (žádné veřejné ACL)
IAM user:   chat-service (read/write/delete na celý bucket)
Šifrování:  SSE-S3 (serverové AES-256) — výchozí pro nešifrovaný chat
            SSE-C (client-side key) — volitelný šifrovaný chat

Oddělení od zálohovacího bucketu (claudeai) zajišťuje: - nezávislá IAM politika - vlastní GC pravidla (zprávy vs. zálohy mají jinou retenci) - čistší billing a monitoring


Pojmenování adresářů

Login v adresáři = e-mail bez @, ostatní znaky zachovány:

jan.novotny@mail.cz  →  jan.novotnymail.cz
petr.novak@firma.cz  →  petr.novakfirma.cz

Přímé zprávy (1:1)

dm_{login_a}_{login_b}/

Pravidlo: loginy seřazeny abecedně (zajišťuje unikátnost bez ohledu na směr)
Příklad:  dm_jan.novotnymail.cz_petr.novakfirma.cz/

Skupinová konverzace

gr_{login_zakladatele}_{NNN}/

NNN = trojmístné pořadové číslo skupin tohoto uživatele (001, 002, ...)
Příklad:  gr_jan.novotnymail.cz_001/
          gr_jan.novotnymail.cz_002/

DB mapování — directory_name → conversation_id:

conversations (
  id             UUID PRIMARY KEY,
  type           VARCHAR(10),   -- 'dm' | 'group' | 'support'
  s3_prefix      VARCHAR(255),  -- dm_jan.novotnymail.cz_petr.novakfirma.cz
  created_by     UUID REFERENCES users(id),
  name           VARCHAR(255),  -- jen pro skupiny
  encrypted      BOOLEAN DEFAULT false,
  created_at     TIMESTAMPTZ
)

Pokud se uživatel přejmenuje (nick), S3 prefix zůstane nezměněn — mapování zajistí DB.


Struktura S3 objektů

avax-chat/
└── {s3_prefix}/
    ├── messages/
    │   ├── 2026-05-03/               ← dnešní den: individual soubory
    │   │   ├── {ts}_{msg_id}.json    ← okamžitě zapsáno při odeslání
    │   │   └── ...
    │   ├── 2026-05-02.json           ← včera+: denní bundle (GC job)
    │   ├── 2026-05-01.json
    │   └── ...
    └── attachments/
        └── {msg_id}/
            ├── original.{ext}        ← původní soubor
            └── thumb.jpg             ← jen pro obrázky (server-side resize)

Denní bundle — logika

Zprávy se píší jako individual soubory během dne (S3 neumí append). Každý den ve 02:00 Celery job agreguje předchozí den do jednoho JSON souboru:

# Celery task: bundle_daily_messages(date, s3_prefix)
# 1. LIST objects: messages/{date}/
# 2. GET + parse každý soubor → seřadit dle timestamp
# 3. PUT messages/{date}.json  (pole zpráv)
# 4. DELETE objects messages/{date}/

Formát souboru zprávy

Individual soubor: messages/2026-05-03/{timestamp_ms}_{msg_id}.json

Pole type — povolené hodnoty:

text         → textová zpráva (+ volitelné přílohy)
mixed        → text + přílohy
image        → pouze obrázek
file         → pouze soubor
system       → systémová zpráva (přidal se člen, opustil chat...)
call_invite  → záznam hlasového hovoru — připraveno, zatím šedivé v UI

{
  "id": "abc123",
  "sender": "jan.novotnymail.cz",
  "sender_name": "Jan Novotný",
  "sender_nick": "Honza",
  "type": "text",
  "content": "Ahoj, jak se máš?",
  "sent_at": "2026-05-03T10:42:00.000Z",
  "edited_at": null,
  "deleted": false,
  "reply_to": null,
  "attachments": [],
  "call": null
}

Zpráva typu call_invite — záznam hovoru:

{
  "id": "call_abc123",
  "sender": "jan.novotnymail.cz",
  "sender_name": "Jan Novotný",
  "sender_nick": "Honza",
  "type": "call_invite",
  "content": "Jan Novotný zahájil hovor",
  "sent_at": "2026-05-03T10:42:00.000Z",
  "edited_at": null,
  "deleted": false,
  "reply_to": null,
  "attachments": [],
  "call": {
    "call_id": "call_abc123",
    "status": "ended",
    "started_at": "2026-05-03T10:42:05.000Z",
    "ended_at":   "2026-05-03T10:57:13.000Z",
    "duration_seconds": 908
  }
}

Stavy hovoru (call.status):

ringing   → vyzvání, čeká na odpověď
accepted  → hovor probíhá
declined  → příjemce odmítl
missed    → příjemce neodpověděl (timeout 30s)
ended     → hovor ukončen
failed    → technická chyba / P2P spojení se nepodařilo sestavit

Denní bundle: messages/2026-05-02.json

[
  { ...zpráva... },
  { ...zpráva... }
]


Zprávy s přílohami

{
  "id": "def456",
  "sender": "jan.novotnymail.cz",
  "type": "mixed",
  "content": "Viz příloha",
  "sent_at": "2026-05-03T10:43:00.000Z",
  "attachments": [
    {
      "id": "att789",
      "name": "faktury_Q1.pdf",
      "mime": "application/pdf",
      "size": 204800,
      "s3_key": "dm_.../attachments/def456/original.pdf"
    },
    {
      "id": "att790",
      "name": "foto.jpg",
      "mime": "image/jpeg",
      "size": 1048576,
      "s3_key":       "dm_.../attachments/def456/original.jpg",
      "thumb_s3_key": "dm_.../attachments/def456/thumb.jpg"
    }
  ]
}

Zobrazení souborů v chatu

Soubor:   [📄 faktury_Q1.pdf  200 KB]   ← karta, při hoveru/kliknutí:
            [⬇ Stáhnout na disk]
            [🔗 Sdílet odkaz]  → vygeneruje presigned GET URL (platnost 7 dní)

Obrázek:  [thumbnail inline v chatu]    ← presigned GET na thumb_s3_key
          klik → full-size overlay
            [⬇ Stáhnout na disk]
            [🔗 Sdílet odkaz]

Sdílení:
  POST /chat/attachments/{att_id}/share-link
  → server vygeneruje presigned GET URL (7 dní) a vrátí ho klientovi
  → uživatel ho zkopíruje / pošle mimo AVAX
  → odkaz je anonymní (bez autentizace), platný 7 dní

Doručení zpráv — Adaptive Polling

Fáze odeslání

Odesílatel klikne Odeslat:
  1. POST /chat/messages  → backend ověří, zapíše JSON na S3
  2. Backend vloží do pending_deliveries pro každého příjemce
  3. Vrátí { msg_id, s3_key, sent_at } → sender zobrazí zprávu okamžitě

Fáze doručení

Příjemce online (aktivní poll):
  → poll vrátí okamžitě novou zprávu z pending_deliveries
  → client stáhne JSON z S3 (presigned GET)
  → smaže z pending_deliveries (ACK)

Příjemce offline / tray:
  → pending_deliveries zůstane
  → při přihlášení / otevření launcheru:
      GET /chat/pending → stáhne vše co přišlo, zpracuje
pending_deliveries (
  id              UUID PRIMARY KEY,
  conversation_id UUID REFERENCES conversations(id),
  recipient_id    UUID REFERENCES users(id),
  s3_key          TEXT,          -- cesta k JSON souboru zprávy
  msg_id          VARCHAR(64),
  created_at      TIMESTAMPTZ,
  delivered_at    TIMESTAMPTZ    -- NULL = dosud nedoručeno
)
-- Index pro rychlé načtení: (recipient_id, delivered_at) WHERE delivered_at IS NULL

Polling interval — adaptive

Stav klienta                         Interval
────────────────────────────────────────────────
Chat záložka otevřena + psaní        2 s
Chat záložka otevřena, idle          5 s
Launcher aktivní, chat zavřen        30 s
Launcher minimalizován (tray)        60 s
Launcher zavřen                      ---  (bez pollingu)

Endpoint: GET /chat/pending?since={timestamp}
  → vrátí seznam nových zpráv (s3_key + msg_id)
  → klient stáhne každou zprávu presigned URL
  → POST /chat/ack  Body: { msg_ids: [...] }  → smaže z pending_deliveries

Presence / Online status

Pro 100 000 uživatelů — minimální zátěž:

Heartbeat: POST /presence/heartbeat  každých 60 s
  → Redis key: presence:{user_id}   TTL 90s
  → hodnota: "online" | "away"

Výpočet:
  ONLINE  → klíč existuje, hodnota "online"
  PRYČ    → klíč existuje, hodnota "away"  (launcher idle > 5 min)
  OFFLINE → klíč neexistuje (TTL expiroval)

Zátěž při 5 000 aktivních uživatelích:
  5 000 heartbeatů / 60s = ~83 req/s → triviální pro Redis

Multi-device:
  Redis key: presence:{user_id}:{device_id}   TTL 90s
  Uživatel je ONLINE pokud alespoň jeden klíč s "online" existuje
-- Uložení last_seen pro offline info
ALTER TABLE users ADD COLUMN last_seen_at TIMESTAMPTZ;

Uživatelská identita

ALTER TABLE users ADD COLUMN chat_nick VARCHAR(50);

Zobrazení v chatu:

[Initials]  Honza               ← nick (pokud nastaven)
            Jan Novotný         ← celé jméno (vždy)
            jan.novotnymail.cz  ← login/email (v detailu profilu)

Nick je globální — stejný ve všech konverzacích. Uživatel ho může kdykoli změnit.


Šifrování (volitelné)

Uživatel může v nastavení zapnout Šifrovaný chat.

Princip: End-to-end šifrování zpráv před zápisem na S3
  → obsah zprávy je šifrovaný = backend ho nevidí = full-text search nefunguje

Aktivace:
  Uživatel A zapne šifrovaný chat v nastavení
  → při zahájení konverzace s uživatelem B:
      systém zkontroluje: má B také šifrovaný chat?
      ANO → konverzace probíhá šifrovaně, v záhlaví chatu: 🔒 Šifrovaný chat
      NE  → zobrazí hláška: "Druhá strana nepoužívá šifrovaný chat.
             Pro šifrování musí šifrování aktivovat i oni."

UI:
  🔒  Šifrovaný chat                    ← badge v záhlaví konverzace
  🔍  [████████████████]  (šedé)        ← pole pro vyhledávání zatmaveno + tooltip:
      "Vyhledávání není dostupné v šifrovaném chatu"

Technicky:
  Algoritmus: AES-256-GCM, klíč derivován z hesla uživatele (PBKDF2)
  Výměna klíčů: ECDH (X25519) při prvním kontaktu
  Uložení: šifrovaný soubor zprávy na S3 (pole content je base64-encrypted)

  Zpráva v S3 (šifrovaná):
    { ..., "content": "BASE64ENCRYPTED==", "encrypted": true }

  Dešifrování probíhá výhradně na klientovi — server obsah nevidí.

Skupinový šifrovaný chat:
  Každý člen dostane kopii symetrického klíče skupiny,
  šifrovanou jeho veřejným klíčem.
  Při přidání nového člena admin re-šifruje klíč skupiny pro nového člena.
  Při odebrání člena se vygeneruje nový klíč skupiny (forward secrecy).
-- Klíče pro E2E šifrování
user_chat_keys (
  user_id     UUID REFERENCES users(id) PRIMARY KEY,
  public_key  TEXT,                 -- X25519 veřejný klíč (base64)
  key_version INT DEFAULT 1,
  created_at  TIMESTAMPTZ
)

conversation_keys (
  conversation_id UUID REFERENCES conversations(id),
  user_id         UUID REFERENCES users(id),
  encrypted_key   TEXT,             -- symetrický klíč skupiny, šifrovaný veřejným klíčem člena
  key_version     INT DEFAULT 1,
  PRIMARY KEY (conversation_id, user_id)
)

Smazání zpráv a konverzací

1:1 — single-sided

Smazání zprávy: client zapíše do lokálního indexu hidden_msg_ids[]
  → zpráva se nezobrazí jen tomuto uživateli
  → S3 soubor se NEmaže (druhý uživatel zprávu vidí dál)
  → uloženo v DB: message_visibility(conv_id, user_id, msg_id, hidden_at)

Smazání konverzace: conversation_deletions INSERT (conv_id, user_id)
  → konverzace zmizí jen z pohledu tohoto uživatele
  → S3 soubory zůstanou dokud to nesmaže i druhá strana → pak GC

Úprava zprávy: message_visibility.custom_content
  → druhý uživatel vidí originál

Skupinový chat — globální

Admin maže zprávu:
  1. GET message JSON z S3
  2. Přepíše: { "deleted": true, "content": "[zpráva smazána adminem]", "deleted_by": "..." }
  3. PUT zpět na S3 (přepíše původní soubor)
  → všichni uživatelé vidí "[zpráva smazána adminem]" při příštím načtení

Zrušení skupiny:
  Celery task: smaže celý S3 prefix (DELETE objects s3_prefix/*) + DB záznamy

Garbage Collector — zprávy

Integrace s existujícím GC systémem (stejná logika jako zálohy):

Textové zprávy:   žádná automatická expirace (dokud uživatel nesmaže)
Přílohy:          zachovány dokud konverzace existuje (nemaže se po 24h jako v původní spec)
                  → attachment_expires_at = NULL pro trvalé soubory v aktivní konverzaci
                  → GC smaže přílohy jen při zrušení celé konverzace

Celery tasks:
  bundle_daily_messages  → každý den 02:00, agreguje individual soubory předchozího dne
  gc_deleted_conversations → smaže S3 prefix + DB záznamy smazaných konverzací
  cleanup_share_links    → smaže expirované presigned share linky z DB

API endpointy

# Konverzace
GET  /chat/conversations                         seznam s unread count
POST /chat/conversations                         nová konverzace
     Body: { type, members, name, encrypted }
GET  /chat/conversations/{id}                    detail + členové
DELETE /chat/conversations/{id}                  smazat (1:1: single-sided; skupina: admin→vše)

# Zprávy
GET  /chat/conversations/{id}/messages           ?date=2026-05-03  (denní bundle nebo individual)
POST /chat/conversations/{id}/messages           odeslat zprávu
     Body: { content, type, attachments, reply_to, encrypted_content }
PATCH /chat/conversations/{id}/messages/{msg_id} upravit zprávu (vlastní; skupina: global; 1:1: single-sided)
DELETE /chat/conversations/{id}/messages/{msg_id}

# Doručení
GET  /chat/pending                               ?since={timestamp}  nové zprávy ke stažení
POST /chat/ack                                   Body: { msg_ids: [...] }  potvrzení přijetí

# Přílohy
POST /chat/conversations/{id}/upload-url         Body: { filename, mime, size }
     Vrátí: { presigned_put_url, attachment_id, s3_key }
POST /chat/attachments/{att_id}/presigned        Vrátí: { url }  (privátní, TTL 1h)
POST /chat/attachments/{att_id}/share-link       Vrátí: { url, expires_at }  (veřejný, TTL 7d)

# Presence
POST /presence/heartbeat                         Body: { status: "online"|"away" }
GET  /presence?user_ids=[...]                    online status více uživatelů

# Nick
PUT  /users/me/chat-nick                         Body: { nick }

# Šifrování — výměna klíčů
GET  /chat/keys/{user_id}                        veřejný klíč uživatele pro E2E
PUT  /chat/keys/me                               Body: { public_key }  uložení/aktualizace klíče
POST /chat/conversations/{id}/keys               Body: { encrypted_key }  přidání klíče pro nového člena

# Skupinový chat — moderace
POST /chat/conversations/{id}/members            Body: { email }  přidat člena
DELETE /chat/conversations/{id}/members/{uid}    odebrat člena
PUT  /chat/conversations/{id}/members/{uid}/ban  Body: { banned: bool, reason }
POST /chat/conversations/{id}/leave

# Kontaktní tokeny (cross-company pozvánky)
POST /chat/contact-tokens                        vygeneruje token (8 znaků, 7d)
POST /chat/contact-connect                       Body: { token }
PUT  /chat/contact-requests/{id}                 Body: { action: accept|reject }

DB tabulky (minimální)

-- Konverzace a členství
conversations (
  id             UUID PRIMARY KEY,
  type           VARCHAR(10),   -- 'dm' | 'group' | 'support'
  s3_prefix      VARCHAR(255),  -- dm_jan.novotnymail.cz_petr.novakfirma.cz
  name           VARCHAR(255),
  created_by     UUID REFERENCES users(id),
  encrypted      BOOLEAN DEFAULT false,
  is_active      BOOLEAN DEFAULT true,
  deleted_at     TIMESTAMPTZ,
  created_at     TIMESTAMPTZ
)

conversation_members (
  conversation_id UUID REFERENCES conversations(id),
  user_id         UUID REFERENCES users(id),
  role            VARCHAR(10),   -- 'admin' | 'member'
  is_banned       BOOLEAN DEFAULT false,
  banned_at       TIMESTAMPTZ,
  banned_by       UUID REFERENCES users(id),
  ban_reason      TEXT,
  joined_at       TIMESTAMPTZ,
  last_read_date  DATE,          -- poslední přečtený den (pro unread badge)
  last_read_msg   VARCHAR(64),   -- poslední přečtený msg_id
  notification_pref VARCHAR(10) DEFAULT 'all',
  PRIMARY KEY (conversation_id, user_id)
)

-- Doručení zpráv
pending_deliveries (
  id              UUID PRIMARY KEY,
  conversation_id UUID REFERENCES conversations(id),
  recipient_id    UUID REFERENCES users(id),
  msg_id          VARCHAR(64),
  s3_key          TEXT,
  created_at      TIMESTAMPTZ,
  delivered_at    TIMESTAMPTZ
)
CREATE INDEX ON pending_deliveries (recipient_id) WHERE delivered_at IS NULL;

-- Single-sided operace (1:1)
message_visibility (
  conversation_id UUID REFERENCES conversations(id),
  user_id         UUID REFERENCES users(id),
  msg_id          VARCHAR(64),
  hidden_at       TIMESTAMPTZ,
  custom_content  TEXT,
  PRIMARY KEY (conversation_id, user_id, msg_id)
)

conversation_deletions (
  conversation_id UUID REFERENCES conversations(id),
  user_id         UUID REFERENCES users(id),
  deleted_at      TIMESTAMPTZ,
  PRIMARY KEY (conversation_id, user_id)
)

-- E2E šifrování
user_chat_keys (
  user_id     UUID REFERENCES users(id) PRIMARY KEY,
  public_key  TEXT,
  key_version INT DEFAULT 1,
  created_at  TIMESTAMPTZ
)

conversation_keys (
  conversation_id UUID REFERENCES conversations(id),
  user_id         UUID REFERENCES users(id),
  encrypted_key   TEXT,
  key_version     INT DEFAULT 1,
  PRIMARY KEY (conversation_id, user_id)
)

-- Skupinové chat numbering
user_group_counter (
  user_id   UUID REFERENCES users(id) PRIMARY KEY,
  counter   INT DEFAULT 0
)

HW náročnost — odhad pro 100 000 uživatelů

Předpoklady:
  Aktivní simultánně:  5 000 (5 %)
  Zpráv za den:        500 000
  Průměrná zpráva:     1 KB JSON

RAM (bez WS/Redis Pub/Sub):
  Polling HTTP:        ~0 MB persistent (spojení se uzavře po odpovědi)
  Redis presence:      100 000 klíčů × 50 B = ~5 MB
  pending_deliveries:  ~10 000 záznamů průměrně × 200 B = ~2 MB
  → Redis celkem:      ~10 MB

CPU (polling):
  5 000 aktivních × poll každých 5s = 1 000 req/s → 1 vCPU zvládne

S3 (MinIO):
  500 000 zpráv × 1 KB = 500 MB/den nových dat
  Denní bundle: 500 000 PUT + 500 000 DELETE + 1 bundle GET/PUT = jednorázové
  Přílohy: dle použití

Srovnání s WebSocket:
  WebSocket 5 000 conn × ~100 KB RAM = 500 MB RAM extra
  Redis Pub/Sub: dalších 50–100 MB
  → S3 polling ušetří ~600 MB RAM a eliminuje Redis Pub/Sub

Voice Chat — příprava (UI zatím šedivé)

Tlačítko hovoru je v UI viditelné, ale deaktivované (šedé + tooltip "Brzy dostupné"). Infrastruktura a formát zpráv jsou připraveny — aktivace bez změny schématu.

Technologie — WebRTC

1:1 hovor:      WebRTC P2P — zvuk jde přímo mezi uživateli
                server přenáší jen signalizaci (SDP, ICE) → minimální bandwidth

Skupinový hovor: Livekit SFU (Selective Forwarding Unit)
                každý uživatel posílá 1 stream na SFU
                SFU přeposílá ostatním — žádné N×(N-1) P2P spojení

Infrastruktura

coturn (STUN + TURN server):
  Účel:    STUN = sdělí uživateli jeho veřejnou IP (potřebné pro P2P)
           TURN = relay zvuku při selhání P2P (symetrický NAT, ~15–20 % hovorů)
  HW:      ~10 MB RAM, 0 CPU v idle; bandwidth jen při TURN fallback
  Deploy:  Docker na existující vm-gateway nebo samostatné VM
  Adresa:  stun.avaxis.cz:3478 / turn.avaxis.cz:3478

Livekit SFU (skupinové hovory):
  Účel:    přeposílač audio/video streamů pro skupiny 3+ uživatelů
  HW:      ~50 MB RAM, ~1 vCPU pro desítky simultánních hovorů
  Deploy:  Docker na nové vm-voice (Proxmox)
  Licence: open-source (Apache 2.0)

Průběh 1:1 hovoru (WebRTC P2P)

Volající (A):
  1. POST /chat/calls  Body: { conversation_id, type: "audio" }
     → server vytvoří call_id, vloží pending_delivery pro B (call_invite)
     → vrátí { call_id, ice_servers: [stun/turn URLs] }
  2. WebRTC: vytvoří SDP offer
  3. POST /chat/calls/{call_id}/signal  Body: { type: "offer", sdp: "..." }

Příjemce (B):
  4. Poll vrátí pending call_invite → UI zobrazí příchozí hovor 📞
  5. Uživatel přijme → WebRTC: vytvoří SDP answer
  6. POST /chat/calls/{call_id}/signal  Body: { type: "answer", sdp: "..." }
  7. Oba si vyměňují ICE candidates:
     POST /chat/calls/{call_id}/signal  Body: { type: "ice", candidate: "..." }
  8. P2P spojení sestaveno → zvuk teče přímo A ↔ B

Ukončení:
  9. POST /chat/calls/{call_id}/end
     → server zapíše call_invite zprávu na S3 se status: "ended" + duration

Signalizační API (připraveno, neaktivní)

POST /chat/calls                             zahájit hovor
     Body: { conversation_id, type: "audio"|"video" }
     Vrátí: { call_id, ice_servers }

GET  /chat/calls/{call_id}/signals           pending signály pro tohoto uživatele (polling)
     Vrátí: [{ type: "offer"|"answer"|"ice", data, from_user }]

POST /chat/calls/{call_id}/signal            odeslat signál (SDP / ICE candidate)
     Body: { type: "offer"|"answer"|"ice", data }

POST /chat/calls/{call_id}/accept            přijmout hovor
POST /chat/calls/{call_id}/decline           odmítnout hovor
POST /chat/calls/{call_id}/end               ukončit hovor

DB tabulky pro hovory

calls (
  id              VARCHAR(64) PRIMARY KEY,   -- call_id
  conversation_id UUID REFERENCES conversations(id),
  initiated_by    UUID REFERENCES users(id),
  type            VARCHAR(10),   -- 'audio' | 'video'
  status          VARCHAR(10),   -- 'ringing' | 'accepted' | 'declined' | 'missed' | 'ended' | 'failed'
  started_at      TIMESTAMPTZ,
  ended_at        TIMESTAMPTZ,
  duration_seconds INT,
  created_at      TIMESTAMPTZ
)

-- Signalizace (krátkodobá, TTL ~5 min)
call_signals (
  id         UUID PRIMARY KEY,
  call_id    VARCHAR(64) REFERENCES calls(id),
  from_user  UUID REFERENCES users(id),
  to_user    UUID REFERENCES users(id),
  type       VARCHAR(10),   -- 'offer' | 'answer' | 'ice' | 'end'
  data       TEXT,          -- SDP nebo ICE candidate JSON
  created_at TIMESTAMPTZ,
  consumed   BOOLEAN DEFAULT false
)
CREATE INDEX ON call_signals (to_user, consumed) WHERE NOT consumed;
-- GC: smaže consumed záznamy starší 10 minut

UI — deaktivované tlačítko

Záhlaví konverzace:
  [📞 Zavolat]  ← šedivé, kurzor not-allowed
                   tooltip: "Hlasové hovory — připravujeme"

Záznam hovoru v chatu (call_invite zpráva):
  ┌────────────────────────────────────┐
  │ 📞 Zmeškaný hovor                  │  ← status: missed, červeně
  │ 📞 Hovor  ·  15:08                 │  ← status: ended, duration
  │ 📞 Hovor odmítnut                  │  ← status: declined, šedě
  └────────────────────────────────────┘

Rozhodnuté otázky

Otázka Rozhodnutí
Transport Adaptive HTTP polling (ne WebSocket)
Message store S3 JSON soubory (individual → daily bundle přes GC)
S3 bucket Dedikovaný avax-chat, oddělený od záloh
Directory naming dm_login1_login2 / gr_login_NNN (email bez @)
Soubory přístup Privátní, presigned URL (TTL 1h pro čtení, 7d pro sdílení)
Sdílení souboru POST generuje anonymní presigned GET URL (7 dní)
Bundlování zpráv Individual soubory během dne → GC bundle do {datum}.json každou noc
Polling interval 2s (aktivní chat) / 5s (launcher aktivní) / 60s (tray)
Doručení offline pending_deliveries DB tabulka, doručení při přihlášení
Presence Redis per-device TTL 90s, heartbeat každých 60s
Šifrování Volitelné E2E (AES-256-GCM + X25519), obě strany musí mít zapnuto
Šifrování — UI 🔒 badge, vyhledávání zatmaveno s tooltipem
Skupinový šifrovaný chat Symetrický klíč skupiny, šifrovaný individuálně pro každého člena
Smazání 1:1 zprávy Single-sided (message_visibility), druhý vidí originál
Smazání skupinové zprávy Přepis JSON na S3 (deleted: true), vidí všichni
Nick Globální, volitelný, zobrazuje se vedle jména
Web klient Zatím ne
GC Přílohy zachovány po dobu aktivní konverzace, smaží se jen při zrušení
Voice chat Připraveno (WebRTC P2P pro 1:1, Livekit SFU pro skupiny), UI šedivé
Voice infrastruktura coturn (STUN+TURN) + Livekit SFU na Proxmox VM
Voice signalizace HTTP polling stejný mechanismus jako zprávy (call_signals tabulka)
call_invite v JSON Typ zprávy připraven, zaznamenává status + duration hovoru

Poslední aktualizace: 2026-05-03