Przejdź do treści

Czat — przebieg integracji

Ścieżki REST są względne wobec /partner/v1/chat. Uwierzytelnienie REST: x-client-id, x-api-key, x-user-id.

Zasada ogólna

Czat działa w dwóch warstwach:

  • REST — wszystkie operacje na danych: pobranie konwersacji, odczyt historii, wysyłanie, usuwanie, tłumaczenie. Wywoływane z backendu partnera, kluczem API.
  • Socket.IO — odbiór wiadomości na żywo oraz sygnały „przeczytano" i „pisze". Nawiązywane z przeglądarki, uwierzytelniane krótkożyciowym tokenem.

Podział wynika z ustalenia, że klucz API nigdy nie opuszcza backendu partnera. Połączenie socketowe z natury powstaje po stronie frontendu, więc dostaje osobne, wąskie poświadczenie.

Rozmowa toczy się między użytkownikiem a weterynarzem po naszej stronie. Odpowiedź weterynarza przychodzi asynchronicznie — przez socket, a przy jego braku zdarzeniem webhooka chat.message.received.

Pierwsza wiadomość wiąże użytkownika z placówką

Wysłanie pierwszej wiadomości w konwersacji dopina użytkownika oraz wszystkie jego zwierzaki do placówki obsługującej czat. Dzieje się to automatycznie i nie ma osobnego wywołania, którym można by to włączyć lub pominąć. Dzięki temu weterynarz widzi zwierzaka, o którym jest mowa, i ma wgląd w jego książeczkę zdrowia.

Trzy rzeczy wynikające z tego powiązania:

  • dopięte zostają wszystkie zwierzaki użytkownika, nie tylko ten, którego dotyczy rozmowa
  • od tej chwili weterynarz tej placówki może założyć wizytę w naszej sieci, która pojawi się użytkownikowi na osi czasu i w module wizyt
  • powiązanie jest trwałe — nie znika po zakończeniu rozmowy

Krok 1 — konwersacja użytkownika

GET /user-conversation

Każdy użytkownik ma najwyżej jedną konwersację. Endpoint zwraca istniejącą, a przy pierwszym wywołaniu ją zakłada. Kolejne wywołania zwracają zawsze tę samą.

{ "conversation": { "id": "…", "name": "Anna Kowalska", "active": true,
                    "readByUser": false, "readByVet": true, "createdAt": "…" } }

Identyfikator z tej odpowiedzi jest potrzebny we wszystkich pozostałych wywołaniach.

Krok 2 — historia wiadomości

GET /{conversationId}/messages?take=30
GET /{conversationId}/messages?take=30&cursor=<nextCursor>
{ "messages": [ { "id": "…", "conversationId": "…", "content": "Dzień dobry…",
                  "files": null, "isOwn": false, "createdAt": "…",
                  "Author": { "name": "lek. wet. Jan Nowak", "image_src": "…" } } ],
  "nextCursor": "…" }
  • wiadomości są zwracane od najnowszej
  • take jest opcjonalne (domyślnie 30, maksymalnie 100); stronicowanie przez cursor z pola nextCursor
  • nextCursor równe null oznacza koniec historii
  • isOwn mówi, czy autorem jest użytkownik z x-user-id
  • files przy odczycie jest null, gdy wiadomość nie ma załączników (a nie pustą tablicą, którą zwraca samo wysłanie)

Dwie rzeczy warte uwagi:

Treść bywa tłumaczeniem. Dla cudzych wiadomości content zawiera tłumaczenie na język użytkownika, jeśli takie powstało, a oryginał dopiero w jego braku. Własne wiadomości zawsze wracają w oryginale.

Brak dostępu nie jest błędem. Jeśli konwersacja nie istnieje albo użytkownik nie jest jej uczestnikiem, odpowiedź to pusta lista i nextCursor: null, a nie kod błędu.

Krok 3 — wysłanie wiadomości

POST /{conversationId}/messages
{ "content": "Mój pies od wczoraj nie je.", "files": [] }
  • content może być puste, jeśli wysyłane są same pliki
  • files to identyfikatory plików (id z potwierdzenia wgrania), a nie ścieżki — czat jako jedyny moduł referencuje pliki przez identyfikator; szczegóły w dokumentacji plików
  • identyfikator jest sprawdzany przed zapisem wiadomości: nieznany kończy się 404, a plik wgrany w innym celu (np. jako zdjęcie zwierzaka) — 400
  • załączniki czatu wgrywa się z purpose: "chat_attachment" i są prywatne, więc do ich wyświetlenia potrzebny jest podpisany adres

Wysłanie wiadomości samo rozgłasza ją socketem — nie trzeba nic dodatkowo robić.

Dwie rzeczy warte uwagi:

  • Zdarzenie new_message dociera także do nadawcy, z profileId równym jego własnemu. Klient, który dopisuje wiadomość lokalnie po POST i jednocześnie nasłuchuje new_message, pokaże ją dwa razy. Albo pomijamy zdarzenia własne (po profileId), albo nie dopisujemy lokalnie i czekamy na socket.
  • Odpowiedź na POST zawiera wysłaną treść, więc nadaje się do optymistycznego dopisania w interfejsie — z zastrzeżeniem z punktu wyżej, żeby nie policzyć tej samej wiadomości dwa razy.

Usuwanie i tłumaczenie

DELETE /{conversationId}/messages/{messageId}
POST   /translate/{messageId}

Usunąć można wyłącznie własną wiadomość. Treść zostaje wyczyszczona, a wiadomość oznaczona jako usunięta — wpis pozostaje w historii.

Tłumaczenie zwraca gołego stringa z przetłumaczoną treścią (albo null), a nie obiekt. Językiem docelowym jest ten z nagłówka x-language.

Warstwa czasu rzeczywistego

Token

POST /socket-token
{ "token": "eyJhbGciOi…", "expiresIn": 300 }

Token jest ważny 5 minut — tyle na nawiązanie połączenia, nie na czas jego trwania. Dotyczy jednego użytkownika i nie daje dostępu do niczego poza socketem. Wydaje się go z backendu partnera i przekazuje do przeglądarki.

Połączenie

Wymagana jest biblioteka socket.io-client — natywny WebSocket nie jest zgodny z protokołem Socket.IO i połączenie się nie nawiąże.

const socket = io(SOCKET_URL, { auth: { token } });

Tożsamość użytkownika bierze się wyłącznie z tokena. Nie trzeba przekazywać identyfikatora profilu w parametrach połączenia — zostanie zignorowany.

Dołączenie do konwersacji jest konieczne

socket.emit('join_conversation', { conversationId });

Zdarzenia new_message i message_deleted trafiają tylko do uczestników pokoju danej konwersacji. Bez join_conversation połączenie będzie zestawione, ale nowe wiadomości nie przyjdą. Warto wywołać je zaraz po nawiązaniu połączenia i po każdym ponownym połączeniu.

Zdarzenia

Wysyłane przez klienta:

Zdarzenie Ładunek Znaczenie
join_conversation { conversationId } zapisanie się na wiadomości z konwersacji
leave_conversation { conversationId } wypisanie się
message_read { conversationId } oznaczenie konwersacji jako przeczytanej
typing_start / typing_end { conversationId } wskaźnik pisania

Odbierane przez klienta:

Zdarzenie Znaczenie
new_message nowa wiadomość w konwersacji, z treścią i autorem
message_deleted { messageId, profileId }
typing_start / typing_end druga strona pisze
conversations_updated { conversationId, profileId } — sygnał, że w konwersacji coś się zmieniło, bez treści

message_read nie ma odpowiednika w REST. Oznaczenie konwersacji jako przeczytanej jest możliwe wyłącznie przez socket — bez niego weterynarz będzie widział rozmowę jako nieodczytaną.

Najczęstsze przyczyny błędów

Objaw Przyczyna
Pusta lista wiadomości Zły conversationId albo użytkownik nie jest uczestnikiem — nie zwracamy w tym przypadku błędu
Połączenie socketowe odrzucane Token wygasł (ważny 5 minut) albo użyto natywnego WebSocket zamiast socket.io-client
Połączenie działa, ale wiadomości nie przychodzą Nie wywołano join_conversation
Konwersacja stale nieprzeczytana u weterynarza Nie wysyłane jest zdarzenie message_read
Nie da się usunąć wiadomości Usuwać można wyłącznie własne wiadomości
Załącznik nie wyświetla się W files wysłano path zamiast id, albo nie pobrano podpisanego adresu
404 przy wysyłaniu wiadomości Nieznany identyfikator w files
400 „was not uploaded as a chat attachment" Plik wgrano z innym purpose niż chat_attachment
Wiadomość pojawia się dwa razy Nadawca też dostaje new_message — patrz wyżej

Przykładowa pełna sekwencja

backend:
  GET  /partner/v1/chat/user-conversation          → { conversation: { id } }
  GET  /partner/v1/chat/{conversationId}/messages?take=30      → historia
  POST /partner/v1/chat/socket-token               → { token, expiresIn: 300 }

przeglądarka:
  io(SOCKET_URL, { auth: { token } })
  socket.emit('join_conversation', { conversationId: id })
  socket.on('new_message', …)
  socket.emit('message_read', { conversationId: id })

wysłanie wiadomości (ponownie przez backend):
  POST /partner/v1/chat/{conversationId}/messages  { "content": "…", "files": [] }

Adres serwera Socket.IO podamy razem z poświadczeniami dla środowiska testowego.