package handlers import ( "context" "encoding/json" "errors" "io" "net/http" "sync" "time" "github.com/Silo-Server/silo-server/internal/access" apimw "github.com/Silo-Server/silo-server/internal/api/middleware" "github.com/Silo-Server/silo-server/internal/auth" "github.com/Silo-Server/silo-server/internal/watchtogether" "github.com/go-chi/chi/v5" "github.com/gorilla/websocket" ) type WatchTogetherScopeResolver interface { Resolve(ctx context.Context, input access.ResolveInput) (access.Scope, error) } type WatchTogetherHandler struct { Service *watchtogether.Service ScopeResolver WatchTogetherScopeResolver TokenService *watchtogether.RoomTokenService } type createWatchTogetherRoomRequest struct { SelectionMode string `json:"selection_mode,omitempty"` } type joinWatchTogetherRoomRequest struct { Code string `json:"code"` JoinToken string `json:"join_token"` } type updateWatchTogetherPolicyRequest struct { GuestControlPolicy watchtogether.GuestControlPolicy `json:"guest_control_policy"` } type selectWatchTogetherRoomItemRequest struct { ContentID string `json:"content_id"` FileID *int `json:"file_id"` LibraryID *int `json:"library_id"` } type watchTogetherRoomResponse struct { Room watchtogether.Snapshot `json:"room"` RoomAccessToken string `json:"room_access_token,omitempty"` } type createWatchTogetherSuggestionRequest struct { ContentID string `json:"content_id"` ContentType string `json:"content_type"` Title string `json:"title"` Subtitle string `json:"subtitle,omitempty"` PosterURL string `json:"poster_url,omitempty"` Note string `json:"note,omitempty"` } type promoteSuggestionRequest struct { SuggestionID string `json:"suggestion_id"` } type watchTogetherSuggestionsResponse struct { Suggestions []watchtogether.Suggestion `json:"suggestions"` } type watchTogetherClientMessage struct { Type string `json:"type"` } type watchTogetherAttachMessage struct { Type string `json:"type"` SessionID string `json:"session_id"` } type watchTogetherTransportRequestMessage struct { Type string `json:"type"` Action watchtogether.TransportAction `json:"action"` PositionSeconds *float64 `json:"position_seconds,omitempty"` IsPaused bool `json:"is_paused"` } type watchTogetherStateReportMessage struct { Type string `json:"type"` SessionID string `json:"session_id"` PositionSeconds float64 `json:"position_seconds"` IsPaused bool `json:"is_paused"` } type watchTogetherReadyMessage struct { Type string `json:"type"` SessionID string `json:"session_id"` PositionSeconds float64 `json:"position_seconds"` IsPaused bool `json:"is_paused"` } type watchTogetherBufferingMessage struct { Type string `json:"type"` SessionID string `json:"session_id"` PositionSeconds float64 `json:"position_seconds"` IsPaused bool `json:"is_paused"` } type watchTogetherPingMessage struct { Type string `json:"type"` ClientSentAt string `json:"client_sent_at"` } type watchTogetherRoomConn struct { conn *websocket.Conn writeMu sync.Mutex } func (c *watchTogetherRoomConn) WriteJSON(v any) error { c.writeMu.Lock() defer c.writeMu.Unlock() return writeWebSocketJSON(c.conn, v) } func (c *watchTogetherRoomConn) Close() error { c.writeMu.Lock() defer c.writeMu.Unlock() return c.conn.Close() } func (c *watchTogetherRoomConn) WritePing() error { c.writeMu.Lock() defer c.writeMu.Unlock() return writeWebSocketControl(c.conn, websocket.PingMessage, nil) } func NewWatchTogetherHandler( service *watchtogether.Service, scopeResolver WatchTogetherScopeResolver, tokenService *watchtogether.RoomTokenService, ) *WatchTogetherHandler { return &WatchTogetherHandler{ Service: service, ScopeResolver: scopeResolver, TokenService: tokenService, } } func (h *WatchTogetherHandler) HandleCreateRoom(w http.ResponseWriter, r *http.Request) { userID := apimw.GetUserID(r.Context()) profileID := apimw.GetProfileID(r.Context()) if userID == 0 || profileID == "" { writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required") return } var req createWatchTogetherRoomRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) { writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") return } room, err := h.Service.CreateRoom(r.Context(), watchtogether.CreateRoomInput{ HostUserID: userID, HostProfileID: profileID, SelectionMode: watchtogether.RoomSelectionMode(req.SelectionMode), }) if err != nil { writeError(w, http.StatusInternalServerError, "internal_error", "Failed to create room") return } snapshot, err := h.Service.Snapshot(r.Context(), room.ID, userID, profileID) if err != nil { writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load room") return } response, err := h.buildRoomResponse(r.Context(), snapshot, userID, profileID) if err != nil { writeError(w, http.StatusInternalServerError, "internal_error", "Failed to issue room token") return } writeJSON(w, http.StatusCreated, response) } func (h *WatchTogetherHandler) HandleJoinRoom(w http.ResponseWriter, r *http.Request) { userID := apimw.GetUserID(r.Context()) profileID := apimw.GetProfileID(r.Context()) if userID == 0 || profileID == "" { writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required") return } var req joinWatchTogetherRoomRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") return } room, err := h.Service.JoinRoom(r.Context(), watchtogether.JoinInput{ Code: req.Code, JoinToken: req.JoinToken, }) if err != nil { switch { case errors.Is(err, watchtogether.ErrInvalidJoinRequest): writeError(w, http.StatusBadRequest, "bad_request", "Room code or invite token is required") case errors.Is(err, watchtogether.ErrRoomNotFound): writeError(w, http.StatusNotFound, "not_found", "Room not found") case errors.Is(err, watchtogether.ErrRoomClosed): writeError(w, http.StatusGone, "gone", "Room is no longer active") default: writeError(w, http.StatusInternalServerError, "internal_error", "Failed to join room") } return } snapshot, err := h.Service.Snapshot(r.Context(), room.ID, userID, profileID) if err != nil { writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load room") return } response, err := h.buildRoomResponse(r.Context(), snapshot, userID, profileID) if err != nil { writeError(w, http.StatusInternalServerError, "internal_error", "Failed to issue room token") return } writeJSON(w, http.StatusOK, response) } func (h *WatchTogetherHandler) HandleGetRoom(w http.ResponseWriter, r *http.Request) { userID := apimw.GetUserID(r.Context()) profileID := apimw.GetProfileID(r.Context()) if userID == 0 || profileID == "" { writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required") return } roomID := chi.URLParam(r, "room_id") if err := h.validateRoomAccessToken(r, roomID, userID, profileID); err != nil { writeError(w, http.StatusForbidden, "forbidden", "Room access token required") return } snapshot, err := h.Service.Snapshot(r.Context(), roomID, userID, profileID) if err != nil { switch { case errors.Is(err, watchtogether.ErrRoomNotFound): writeError(w, http.StatusNotFound, "not_found", "Room not found") case errors.Is(err, watchtogether.ErrRoomClosed): writeError(w, http.StatusGone, "gone", "Room is no longer active") default: writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load room") } return } response, err := h.buildRoomResponse(r.Context(), snapshot, userID, profileID) if err != nil { writeError(w, http.StatusInternalServerError, "internal_error", "Failed to issue room token") return } writeJSON(w, http.StatusOK, response) } func (h *WatchTogetherHandler) HandleUpdateRoomPolicy(w http.ResponseWriter, r *http.Request) { userID := apimw.GetUserID(r.Context()) profileID := apimw.GetProfileID(r.Context()) roomID := chi.URLParam(r, "room_id") var req updateWatchTogetherPolicyRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") return } snapshot, err := h.Service.UpdatePolicy(r.Context(), roomID, userID, profileID, req.GuestControlPolicy) if err != nil { switch { case errors.Is(err, watchtogether.ErrRoomForbidden): writeError(w, http.StatusForbidden, "forbidden", "Only the host can update room policy") case errors.Is(err, watchtogether.ErrRoomNotFound): writeError(w, http.StatusNotFound, "not_found", "Room not found") default: writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update room policy") } return } response, err := h.buildRoomResponse(r.Context(), snapshot, userID, profileID) if err != nil { writeError(w, http.StatusInternalServerError, "internal_error", "Failed to issue room token") return } writeJSON(w, http.StatusOK, response) } func (h *WatchTogetherHandler) HandleCloseRoom(w http.ResponseWriter, r *http.Request) { userID := apimw.GetUserID(r.Context()) profileID := apimw.GetProfileID(r.Context()) roomID := chi.URLParam(r, "room_id") if err := h.Service.CloseRoom(r.Context(), roomID, userID, profileID); err != nil { switch { case errors.Is(err, watchtogether.ErrRoomForbidden): writeError(w, http.StatusForbidden, "forbidden", "Only the host can close the room") case errors.Is(err, watchtogether.ErrRoomNotFound): writeError(w, http.StatusNotFound, "not_found", "Room not found") default: writeError(w, http.StatusInternalServerError, "internal_error", "Failed to close room") } return } w.WriteHeader(http.StatusNoContent) } func (h *WatchTogetherHandler) HandleSelectRoomItem(w http.ResponseWriter, r *http.Request) { userID := apimw.GetUserID(r.Context()) profileID := apimw.GetProfileID(r.Context()) roomID := chi.URLParam(r, "room_id") var req selectWatchTogetherRoomItemRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") return } snapshot, err := h.Service.SelectItem(r.Context(), roomID, userID, profileID, watchtogether.SelectItemInput{ ContentID: req.ContentID, FileID: req.FileID, LibraryID: req.LibraryID, }) if err != nil { switch { case errors.Is(err, watchtogether.ErrRoomForbidden): writeError(w, http.StatusForbidden, "forbidden", "Only the host can start or switch room playback") case errors.Is(err, watchtogether.ErrRoomNotFound): writeError(w, http.StatusNotFound, "not_found", "Room not found") case errors.Is(err, watchtogether.ErrRoomClosed): writeError(w, http.StatusGone, "gone", "Room is no longer active") case errors.Is(err, watchtogether.ErrInvalidSelection): writeError(w, http.StatusBadRequest, "bad_request", "Content is not playable in this room") default: writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update room selection") } return } response, err := h.buildRoomResponse(r.Context(), snapshot, userID, profileID) if err != nil { writeError(w, http.StatusInternalServerError, "internal_error", "Failed to issue room token") return } writeJSON(w, http.StatusOK, response) } func (h *WatchTogetherHandler) HandleListSuggestions(w http.ResponseWriter, r *http.Request) { userID := apimw.GetUserID(r.Context()) profileID := apimw.GetProfileID(r.Context()) roomID := chi.URLParam(r, "room_id") if err := h.validateRoomAccessToken(r, roomID, userID, profileID); err != nil { writeError(w, http.StatusForbidden, "forbidden", "Room access token required") return } suggestions, err := h.Service.ListSuggestions(r.Context(), roomID, profileID) if err != nil { switch { case errors.Is(err, watchtogether.ErrRoomNotFound): writeError(w, http.StatusNotFound, "not_found", "Room not found") case errors.Is(err, watchtogether.ErrRoomClosed): writeError(w, http.StatusGone, "gone", "Room is no longer active") default: writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list suggestions") } return } writeJSON(w, http.StatusOK, watchTogetherSuggestionsResponse{Suggestions: suggestions}) } func (h *WatchTogetherHandler) HandleCreateSuggestion(w http.ResponseWriter, r *http.Request) { userID := apimw.GetUserID(r.Context()) profileID := apimw.GetProfileID(r.Context()) roomID := chi.URLParam(r, "room_id") if err := h.validateRoomAccessToken(r, roomID, userID, profileID); err != nil { writeError(w, http.StatusForbidden, "forbidden", "Room access token required") return } var req createWatchTogetherSuggestionRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") return } if req.ContentID == "" || req.Title == "" { writeError(w, http.StatusBadRequest, "bad_request", "content_id and title are required") return } suggestions, err := h.Service.CreateSuggestion(r.Context(), roomID, userID, profileID, watchtogether.CreateSuggestionInput{ ContentID: req.ContentID, ContentType: req.ContentType, Title: req.Title, Subtitle: req.Subtitle, PosterURL: req.PosterURL, Note: req.Note, }) if err != nil { switch { case errors.Is(err, watchtogether.ErrRoomNotFound): writeError(w, http.StatusNotFound, "not_found", "Room not found") case errors.Is(err, watchtogether.ErrRoomClosed): writeError(w, http.StatusGone, "gone", "Room is no longer active") case errors.Is(err, watchtogether.ErrInvalidSelection): writeError(w, http.StatusBadRequest, "bad_request", "Invalid content type") default: writeError(w, http.StatusInternalServerError, "internal_error", "Failed to create suggestion") } return } writeJSON(w, http.StatusCreated, watchTogetherSuggestionsResponse{Suggestions: suggestions}) } func (h *WatchTogetherHandler) HandleDeleteSuggestion(w http.ResponseWriter, r *http.Request) { userID := apimw.GetUserID(r.Context()) profileID := apimw.GetProfileID(r.Context()) roomID := chi.URLParam(r, "room_id") suggestionID := chi.URLParam(r, "suggestion_id") if err := h.validateRoomAccessToken(r, roomID, userID, profileID); err != nil { writeError(w, http.StatusForbidden, "forbidden", "Room access token required") return } suggestions, err := h.Service.DeleteSuggestion(r.Context(), roomID, suggestionID, userID, profileID) if err != nil { switch { case errors.Is(err, watchtogether.ErrSuggestionNotFound): writeError(w, http.StatusNotFound, "not_found", "Suggestion not found") case errors.Is(err, watchtogether.ErrRoomForbidden): writeError(w, http.StatusForbidden, "forbidden", "Only the host or suggester can delete a suggestion") case errors.Is(err, watchtogether.ErrRoomNotFound): writeError(w, http.StatusNotFound, "not_found", "Room not found") default: writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete suggestion") } return } writeJSON(w, http.StatusOK, watchTogetherSuggestionsResponse{Suggestions: suggestions}) } func (h *WatchTogetherHandler) HandleVote(w http.ResponseWriter, r *http.Request) { userID := apimw.GetUserID(r.Context()) profileID := apimw.GetProfileID(r.Context()) roomID := chi.URLParam(r, "room_id") suggestionID := chi.URLParam(r, "suggestion_id") if err := h.validateRoomAccessToken(r, roomID, userID, profileID); err != nil { writeError(w, http.StatusForbidden, "forbidden", "Room access token required") return } suggestions, err := h.Service.Vote(r.Context(), roomID, suggestionID, userID, profileID) if err != nil { switch { case errors.Is(err, watchtogether.ErrDuplicateVote): writeError(w, http.StatusConflict, "conflict", "Already voted") case errors.Is(err, watchtogether.ErrRoomNotFound): writeError(w, http.StatusNotFound, "not_found", "Room not found") default: writeError(w, http.StatusInternalServerError, "internal_error", "Failed to vote") } return } writeJSON(w, http.StatusOK, watchTogetherSuggestionsResponse{Suggestions: suggestions}) } func (h *WatchTogetherHandler) HandleUnvote(w http.ResponseWriter, r *http.Request) { userID := apimw.GetUserID(r.Context()) profileID := apimw.GetProfileID(r.Context()) roomID := chi.URLParam(r, "room_id") suggestionID := chi.URLParam(r, "suggestion_id") if err := h.validateRoomAccessToken(r, roomID, userID, profileID); err != nil { writeError(w, http.StatusForbidden, "forbidden", "Room access token required") return } suggestions, err := h.Service.Unvote(r.Context(), roomID, suggestionID, userID, profileID) if err != nil { switch { case errors.Is(err, watchtogether.ErrNotVoted): writeError(w, http.StatusConflict, "conflict", "Not voted") case errors.Is(err, watchtogether.ErrRoomNotFound): writeError(w, http.StatusNotFound, "not_found", "Room not found") default: writeError(w, http.StatusInternalServerError, "internal_error", "Failed to remove vote") } return } writeJSON(w, http.StatusOK, watchTogetherSuggestionsResponse{Suggestions: suggestions}) } func (h *WatchTogetherHandler) HandlePromoteSuggestion(w http.ResponseWriter, r *http.Request) { userID := apimw.GetUserID(r.Context()) profileID := apimw.GetProfileID(r.Context()) roomID := chi.URLParam(r, "room_id") if err := h.validateRoomAccessToken(r, roomID, userID, profileID); err != nil { writeError(w, http.StatusForbidden, "forbidden", "Room access token required") return } var req promoteSuggestionRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") return } if req.SuggestionID == "" { writeError(w, http.StatusBadRequest, "bad_request", "suggestion_id is required") return } snapshot, err := h.Service.PromoteSuggestion(r.Context(), roomID, req.SuggestionID, userID, profileID) if err != nil { switch { case errors.Is(err, watchtogether.ErrRoomForbidden): writeError(w, http.StatusForbidden, "forbidden", "Only the host can promote a suggestion") case errors.Is(err, watchtogether.ErrSuggestionNotFound): writeError(w, http.StatusNotFound, "not_found", "Suggestion not found") case errors.Is(err, watchtogether.ErrRoomNotFound): writeError(w, http.StatusNotFound, "not_found", "Room not found") case errors.Is(err, watchtogether.ErrInvalidSelection): writeError(w, http.StatusBadRequest, "bad_request", "Suggested content is not playable") default: writeError(w, http.StatusInternalServerError, "internal_error", "Failed to promote suggestion") } return } response, err := h.buildRoomResponse(r.Context(), snapshot, userID, profileID) if err != nil { writeError(w, http.StatusInternalServerError, "internal_error", "Failed to issue room token") return } writeJSON(w, http.StatusOK, response) } func (h *WatchTogetherHandler) buildRoomResponse( ctx context.Context, snapshot watchtogether.Snapshot, userID int, profileID string, ) (watchTogetherRoomResponse, error) { response := watchTogetherRoomResponse{Room: snapshot} if h == nil || h.TokenService == nil { return response, nil } token, _, err := h.TokenService.Mint(watchtogether.RoomTokenClaims{ RoomID: snapshot.RoomID, UserID: userID, ProfileID: profileID, }) if err != nil { return watchTogetherRoomResponse{}, err } response.RoomAccessToken = token return response, nil } func (h *WatchTogetherHandler) validateRoomAccessToken( r *http.Request, roomID string, userID int, profileID string, ) error { if h == nil || h.TokenService == nil { return nil } claims, err := h.TokenService.Validate(r.URL.Query().Get("room_token")) if err != nil { return err } if claims.RoomID != roomID || claims.UserID != userID || claims.ProfileID != profileID { return watchtogether.ErrRoomForbidden } return nil } func (h *WatchTogetherHandler) HandleRoomWebSocket(w http.ResponseWriter, r *http.Request) { if h == nil || h.Service == nil || h.ScopeResolver == nil { http.Error(w, "watch together unavailable", http.StatusServiceUnavailable) return } claims := apimw.GetClaims(r.Context()) if claims == nil { http.Error(w, "unauthorized", http.StatusUnauthorized) return } roomID := chi.URLParam(r, "room_id") if roomID == "" { http.Error(w, "room_id required", http.StatusBadRequest) return } profileID := r.URL.Query().Get("profile_id") if profileID == "" { http.Error(w, "profile_id required", http.StatusBadRequest) return } if err := h.validateRoomAccessToken(r, roomID, claims.UserID, profileID); err != nil { http.Error(w, "room access token required", http.StatusForbidden) return } _, err := h.ScopeResolver.Resolve(r.Context(), access.ResolveInput{ UserID: claims.UserID, SessionID: claims.SessionID, ProfileID: profileID, ProfileToken: r.URL.Query().Get("profile_token"), SkipPINVerification: claims.TokenType == auth.TokenTypeAPIKey, }) if err != nil { status := http.StatusForbidden if errors.Is(err, access.ErrProfileNotFound) { status = http.StatusNotFound } http.Error(w, "profile verification failed", status) return } conn, err := wsUpgrader.Upgrade(w, r, nil) if err != nil { return } defer conn.Close() realtimeConn := &watchTogetherRoomConn{conn: conn} ctx, cancel := context.WithCancel(r.Context()) defer cancel() reg, snapshot, err := h.Service.Connect(ctx, roomID, claims.UserID, profileID, realtimeConn) if err != nil { if errors.Is(err, watchtogether.ErrRoomNotFound) { writeWebSocketError(conn, "not_found", "Room not found") } else if errors.Is(err, watchtogether.ErrRoomClosed) { writeWebSocketError(conn, "gone", "Room is no longer active") } else { writeWebSocketError(conn, "internal_error", "Failed to connect room socket") } return } defer h.Service.Disconnect(reg, false) configureWebSocket(conn) startWebSocketPingLoop(ctx, realtimeConn.WritePing) if err := realtimeConn.WriteJSON(map[string]any{ "type": "snapshot", "room": snapshot, }); err != nil { return } for { _, data, err := conn.ReadMessage() if err != nil { return } if err := h.handleRoomClientMessage(ctx, conn, reg, claims.UserID, profileID, data); err != nil { writeWebSocketError(conn, "bad_request", err.Error()) } } } func (h *WatchTogetherHandler) handleRoomClientMessage( ctx context.Context, conn *websocket.Conn, reg *watchtogether.Registration, userID int, profileID string, data []byte, ) error { var base watchTogetherClientMessage if err := json.Unmarshal(data, &base); err != nil { return err } switch base.Type { case "attach_session": var msg watchTogetherAttachMessage if err := json.Unmarshal(data, &msg); err != nil { return err } if msg.SessionID == "" { return errors.New("session_id is required") } _, err := h.Service.AttachSessionForConnection(ctx, reg, userID, profileID, msg.SessionID) return err case "transport_request": var msg watchTogetherTransportRequestMessage if err := json.Unmarshal(data, &msg); err != nil { return err } _, err := h.Service.HandleTransportRequestForConnection(ctx, reg, userID, profileID, watchtogether.TransportRequest{ Action: msg.Action, PositionSeconds: msg.PositionSeconds, IsPaused: msg.IsPaused, }) return err case "state_report": var msg watchTogetherStateReportMessage if err := json.Unmarshal(data, &msg); err != nil { return err } if msg.SessionID == "" { return errors.New("session_id is required") } _, err := h.Service.HandleStateReportForConnection(ctx, reg, userID, profileID, watchtogether.StateReport{ SessionID: msg.SessionID, PositionSeconds: msg.PositionSeconds, IsPaused: msg.IsPaused, }) return err case "ready": var msg watchTogetherReadyMessage if err := json.Unmarshal(data, &msg); err != nil { return err } if msg.SessionID == "" { return errors.New("session_id is required") } _, err := h.Service.HandleReadyForConnection(ctx, reg, userID, profileID, watchtogether.StateReport{ SessionID: msg.SessionID, PositionSeconds: msg.PositionSeconds, IsPaused: msg.IsPaused, }) return err case "buffering": var msg watchTogetherBufferingMessage if err := json.Unmarshal(data, &msg); err != nil { return err } if msg.SessionID == "" { return errors.New("session_id is required") } _, err := h.Service.HandleBufferingForConnection(ctx, reg, userID, profileID, watchtogether.StateReport{ SessionID: msg.SessionID, PositionSeconds: msg.PositionSeconds, IsPaused: msg.IsPaused, }) return err case "ping": var msg watchTogetherPingMessage if err := json.Unmarshal(data, &msg); err != nil { return err } clientSentAt, err := time.Parse(time.RFC3339Nano, msg.ClientSentAt) if err != nil { return errors.New("client_sent_at must be RFC3339Nano") } now := time.Now().UTC() pingMS := now.Sub(clientSentAt.UTC()).Milliseconds() if pingMS > 0 { _ = h.Service.HandlePingForConnection(ctx, reg, userID, profileID, pingMS) } return writeWebSocketJSON(conn, map[string]string{ "type": "pong", "client_sent_at": msg.ClientSentAt, "server_received_at": now.Format(time.RFC3339Nano), "server_sent_at": time.Now().UTC().Format(time.RFC3339Nano), }) default: return errors.New("unsupported room websocket message") } }