/** * Standalone signaling server for the Nextcloud Spreed app. * Copyright (C) 2017 struktur AG * * @author Joachim Bauch * * @license GNU AGPL version 3 or any later version * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ package api import ( "encoding/json" "errors" "fmt" "log" "net" "net/url" "slices" "strings" "time" "github.com/golang-jwt/jwt/v5" "github.com/pion/ice/v4" "github.com/pion/sdp/v3" "github.com/strukturag/nextcloud-spreed-signaling/v2/container" "github.com/strukturag/nextcloud-spreed-signaling/v2/geoip" "github.com/strukturag/nextcloud-spreed-signaling/v2/internal" ) const ( // Version 1.0 validates auth params against the Nextcloud instance. HelloVersionV1 = "1.0" // Version 2.0 validates auth params encoded as JWT. HelloVersionV2 = "2.0" ActorTypeUsers = "users" ActorTypeFederatedUsers = "federated_users" ) var ( // InvalidHelloVersion is returned if the version in the "hello" message is not supported. InvalidHelloVersion = NewError("invalid_hello_version", "The hello version is not supported.") ErrNoSdp = NewError("no_sdp", "Payload does not contain a SDP.") // +checklocksignore: Global readonly variable. ErrInvalidSdp = NewError("invalid_sdp", "Payload does not contain a valid SDP.") ErrNoCandidate = NewError("no_candidate", "Payload does not contain a candidate.") ErrInvalidCandidate = NewError("invalid_candidate", "Payload does not contain a valid candidate.") ErrCandidateFiltered = errors.New("candidate was filtered") ) type PrivateSessionId string type PublicSessionId string type RoomSessionId string const ( FederatedRoomSessionIdPrefix = "federated|" ) func (s RoomSessionId) IsFederated() bool { return strings.HasPrefix(string(s), FederatedRoomSessionIdPrefix) } func (s RoomSessionId) WithoutFederation() RoomSessionId { return RoomSessionId(strings.TrimPrefix(string(s), FederatedRoomSessionIdPrefix)) } type Permission string var ( PERMISSION_MAY_PUBLISH_MEDIA Permission = "publish-media" PERMISSION_MAY_PUBLISH_AUDIO Permission = "publish-audio" PERMISSION_MAY_PUBLISH_VIDEO Permission = "publish-video" PERMISSION_MAY_PUBLISH_SCREEN Permission = "publish-screen" PERMISSION_MAY_CONTROL Permission = "control" PERMISSION_TRANSIENT_DATA Permission = "transient-data" PERMISSION_HIDE_DISPLAYNAMES Permission = "hide-displaynames" // DefaultPermissionOverrides contains permission overrides for users where // no permissions have been set by the server. If a permission is not set in // this map, it's assumed the user has that permission. DefaultPermissionOverrides = map[Permission]bool{ // +checklocksignore: Global readonly variable. PERMISSION_HIDE_DISPLAYNAMES: false, } ) // ClientMessage is a message that is sent from a client to the server. type ClientMessage struct { json.Marshaler json.Unmarshaler // The unique request id (optional). Id string `json:"id,omitempty"` // The type of the request. Type string `json:"type"` // Filled for type "hello" Hello *HelloClientMessage `json:"hello,omitempty"` Bye *ByeClientMessage `json:"bye,omitempty"` Room *RoomClientMessage `json:"room,omitempty"` Message *MessageClientMessage `json:"message,omitempty"` Control *ControlClientMessage `json:"control,omitempty"` Internal *InternalClientMessage `json:"internal,omitempty"` TransientData *TransientDataClientMessage `json:"transient,omitempty"` } func (m *ClientMessage) CheckValid() error { switch m.Type { case "": return errors.New("type missing") case "hello": if m.Hello == nil { return errors.New("hello missing") } else if err := m.Hello.CheckValid(); err != nil { return err } case "bye": // No additional check required. case "room": if m.Room == nil { return errors.New("room missing") } else if err := m.Room.CheckValid(); err != nil { return err } case "message": if m.Message == nil { return errors.New("message missing") } else if err := m.Message.CheckValid(); err != nil { return err } case "control": if m.Control == nil { return errors.New("control missing") } else if err := m.Control.CheckValid(); err != nil { return err } case "internal": if m.Internal == nil { return errors.New("internal missing") } else if err := m.Internal.CheckValid(); err != nil { return err } case "transient": if m.TransientData == nil { return errors.New("transient missing") } else if err := m.TransientData.CheckValid(); err != nil { return err } } return nil } func (m ClientMessage) String() string { data, err := json.Marshal(m) if err != nil { return fmt.Sprintf("Could not serialize %#v: %s", m, err) } return string(data) } func (m *ClientMessage) NewErrorServerMessage(e *Error) *ServerMessage { return &ServerMessage{ Id: m.Id, Type: "error", Error: e, } } func (m *ClientMessage) NewWrappedErrorServerMessage(e error) *ServerMessage { if e, ok := e.(*Error); ok { return m.NewErrorServerMessage(e) } return m.NewErrorServerMessage(NewError("internal_error", e.Error())) } // ServerMessage is a message that is sent from the server to a client. type ServerMessage struct { json.Marshaler json.Unmarshaler Id string `json:"id,omitempty"` Type string `json:"type"` Error *Error `json:"error,omitempty"` Welcome *WelcomeServerMessage `json:"welcome,omitempty"` Hello *HelloServerMessage `json:"hello,omitempty"` Bye *ByeServerMessage `json:"bye,omitempty"` Room *RoomServerMessage `json:"room,omitempty"` Message *MessageServerMessage `json:"message,omitempty"` Control *ControlServerMessage `json:"control,omitempty"` Event *EventServerMessage `json:"event,omitempty"` TransientData *TransientDataServerMessage `json:"transient,omitempty"` Internal *InternalServerMessage `json:"internal,omitempty"` Dialout *DialoutInternalClientMessage `json:"dialout,omitempty"` } type RoomAware interface { IsInRoom(id string) bool } func (r *ServerMessage) CloseAfterSend(session RoomAware) bool { if r.Type == "bye" { return true } if r.Type == "event" { if evt := r.Event; evt != nil && evt.Target == "roomlist" && evt.Type == "disinvite" { // Only close session / connection if the disinvite was for the room // the session is currently in. if session != nil && evt.Disinvite != nil && session.IsInRoom(evt.Disinvite.RoomId) { return true } } } return false } func (r *ServerMessage) IsChatRefresh() bool { if r.Type != "event" || r.Event == nil || r.Event.Type != "message" || r.Event.Message == nil || len(r.Event.Message.Data) == 0 { return false } data, err := r.Event.Message.GetData() if data == nil || err != nil { return false } if data.Type != "chat" || data.Chat == nil { return false } return data.Chat.Refresh } func (r *ServerMessage) IsParticipantsUpdate() bool { if r.Type != "event" || r.Event == nil { return false } if event := r.Event; event.Target != "participants" || event.Type != "update" { return false } return true } func (r *ServerMessage) String() string { data, err := json.Marshal(r) if err != nil { return fmt.Sprintf("Could not serialize %#v: %s", r, err) } return string(data) } type Error struct { Code string `json:"code"` Message string `json:"message"` Details json.RawMessage `json:"details,omitempty"` } func NewError(code string, message string) *Error { return NewErrorDetail(code, message, nil) } func NewErrorDetail(code string, message string, details any) *Error { var rawDetails json.RawMessage if details != nil { var err error if rawDetails, err = json.Marshal(details); err != nil { log.Printf("Could not marshal details %+v for error %s with %s: %s", details, code, message, err) return NewError("internal_error", "Could not marshal error details") } } return &Error{ Code: code, Message: message, Details: rawDetails, } } func (e *Error) Error() string { return e.Message } type WelcomeServerMessage struct { Version string `json:"version"` Features []string `json:"features,omitempty"` Country geoip.Country `json:"country,omitempty"` } func NewWelcomeServerMessage(version string, feature ...string) *WelcomeServerMessage { message := &WelcomeServerMessage{ Version: version, Features: feature, } if len(feature) > 0 { slices.Sort(message.Features) } return message } func (m *WelcomeServerMessage) AddFeature(feature ...string) { newFeatures := slices.Clone(m.Features) for _, feat := range feature { if !slices.Contains(newFeatures, feat) { newFeatures = append(newFeatures, feat) } } slices.Sort(newFeatures) m.Features = newFeatures } func (m *WelcomeServerMessage) RemoveFeature(feature ...string) { newFeatures := make([]string, len(m.Features)) copy(newFeatures, m.Features) for _, feat := range feature { idx, found := slices.BinarySearch(newFeatures, feat) if found { newFeatures = slices.Delete(newFeatures, idx, idx+1) } } m.Features = newFeatures } func (m *WelcomeServerMessage) HasFeature(feature string) bool { for _, f := range m.Features { f = strings.TrimSpace(f) if f == feature { return true } } return false } type ClientType string const ( HelloClientTypeClient = ClientType("client") HelloClientTypeInternal = ClientType("internal") HelloClientTypeFederation = ClientType("federation") HelloClientTypeVirtual = ClientType("virtual") ) type ClientTypeInternalAuthParams struct { Random string `json:"random"` Token string `json:"token"` Backend string `json:"backend"` ParsedBackend *url.URL `json:"-"` } func (p *ClientTypeInternalAuthParams) CheckValid() error { if p.Backend == "" { return errors.New("backend missing") } if p.Backend[len(p.Backend)-1] != '/' { p.Backend += "/" } if u, err := url.Parse(p.Backend); err != nil { return err } else { var changed bool if u, changed = internal.CanonicalizeUrl(u); changed { p.Backend = u.String() } p.ParsedBackend = u } return nil } type HelloV2AuthParams struct { Token string `json:"token"` } func (p *HelloV2AuthParams) CheckValid() error { if p.Token == "" { return errors.New("token missing") } return nil } type AuthTokenClaims interface { jwt.Claims GetUserData() json.RawMessage } type HelloV2TokenClaims struct { jwt.RegisteredClaims UserData json.RawMessage `json:"userdata,omitempty"` } func (c *HelloV2TokenClaims) GetUserData() json.RawMessage { return c.UserData } type FederationAuthParams struct { Token string `json:"token"` } func (p *FederationAuthParams) CheckValid() error { if p.Token == "" { return errors.New("token missing") } return nil } type FederationTokenClaims struct { jwt.RegisteredClaims UserData json.RawMessage `json:"userdata,omitempty"` } func (c *FederationTokenClaims) GetUserData() json.RawMessage { return c.UserData } type HelloClientMessageAuth struct { // The client type that is connecting. Leave empty to use the default // "HelloClientTypeClient" Type ClientType `json:"type,omitempty"` Params json.RawMessage `json:"params"` Url string `json:"url"` ParsedUrl *url.URL `json:"-"` InternalParams ClientTypeInternalAuthParams `json:"-"` HelloV2Params HelloV2AuthParams `json:"-"` FederationParams FederationAuthParams `json:"-"` } // Type "hello" type HelloClientMessage struct { Version string `json:"version"` ResumeId PrivateSessionId `json:"resumeid"` Features []string `json:"features,omitempty"` // The authentication credentials. Auth *HelloClientMessageAuth `json:"auth,omitempty"` } func (m *HelloClientMessage) CheckValid() error { if m.Version != HelloVersionV1 && m.Version != HelloVersionV2 { return InvalidHelloVersion } if m.ResumeId == "" { if m.Auth == nil || len(m.Auth.Params) == 0 { return errors.New("params missing") } if m.Auth.Type == "" { m.Auth.Type = HelloClientTypeClient } switch m.Auth.Type { case HelloClientTypeClient: fallthrough case HelloClientTypeFederation: if m.Auth.Url == "" { return errors.New("url missing") } if m.Auth.Url[len(m.Auth.Url)-1] != '/' { m.Auth.Url += "/" } if pos := strings.Index(m.Auth.Url, "ocs/v2.php/apps/spreed/"); pos != -1 { m.Auth.Url = m.Auth.Url[:pos] } if u, err := url.ParseRequestURI(m.Auth.Url); err != nil { return err } else { var changed bool if u, changed = internal.CanonicalizeUrl(u); changed { m.Auth.Url = u.String() } m.Auth.ParsedUrl = u } switch m.Version { case HelloVersionV1: // No additional validation necessary. case HelloVersionV2: switch m.Auth.Type { case HelloClientTypeClient: if err := json.Unmarshal(m.Auth.Params, &m.Auth.HelloV2Params); err != nil { return err } else if err := m.Auth.HelloV2Params.CheckValid(); err != nil { return err } case HelloClientTypeFederation: if err := json.Unmarshal(m.Auth.Params, &m.Auth.FederationParams); err != nil { return err } else if err := m.Auth.FederationParams.CheckValid(); err != nil { return err } } } case HelloClientTypeInternal: if err := json.Unmarshal(m.Auth.Params, &m.Auth.InternalParams); err != nil { return err } else if err := m.Auth.InternalParams.CheckValid(); err != nil { return err } default: return errors.New("unsupported auth type") } } return nil } const ( // Features to send to all clients. ServerFeatureMcu = "mcu" ServerFeatureSimulcast = "simulcast" ServerFeatureUpdateSdp = "update-sdp" ServerFeatureAudioVideoPermissions = "audio-video-permissions" ServerFeatureTransientData = "transient-data" ServerFeatureInCallAll = "incall-all" ServerFeatureWelcome = "welcome" ServerFeatureHelloV2 = "hello-v2" ServerFeatureSwitchTo = "switchto" ServerFeatureDialout = "dialout" ServerFeatureFederation = "federation" ServerFeatureRecipientCall = "recipient-call" ServerFeatureJoinFeatures = "join-features" ServerFeatureOfferCodecs = "offer-codecs" ServerFeatureServerInfo = "serverinfo" ServerFeatureChatRelay = "chat-relay" ServerFeatureTransientSessionData = "transient-sessiondata" // Features to send to internal clients only. ServerFeatureInternalVirtualSessions = "virtual-sessions" // Possible client features from the "hello" request. ClientFeatureChatRelay = "chat-relay" ClientFeatureInternalInCall = "internal-incall" ClientFeatureStartDialout = "start-dialout" ) var ( DefaultFeatures = []string{ ServerFeatureAudioVideoPermissions, ServerFeatureTransientData, ServerFeatureInCallAll, ServerFeatureWelcome, ServerFeatureHelloV2, ServerFeatureSwitchTo, ServerFeatureDialout, ServerFeatureFederation, ServerFeatureRecipientCall, ServerFeatureJoinFeatures, ServerFeatureOfferCodecs, ServerFeatureServerInfo, ServerFeatureChatRelay, ServerFeatureTransientSessionData, } DefaultFeaturesInternal = []string{ ServerFeatureInternalVirtualSessions, ServerFeatureTransientData, ServerFeatureInCallAll, ServerFeatureWelcome, ServerFeatureHelloV2, ServerFeatureSwitchTo, ServerFeatureDialout, ServerFeatureFederation, ServerFeatureRecipientCall, ServerFeatureJoinFeatures, ServerFeatureOfferCodecs, ServerFeatureServerInfo, ServerFeatureChatRelay, ServerFeatureTransientSessionData, } DefaultWelcomeFeatures = []string{ ServerFeatureAudioVideoPermissions, ServerFeatureInternalVirtualSessions, ServerFeatureTransientData, ServerFeatureInCallAll, ServerFeatureWelcome, ServerFeatureHelloV2, ServerFeatureSwitchTo, ServerFeatureDialout, ServerFeatureFederation, ServerFeatureRecipientCall, ServerFeatureJoinFeatures, ServerFeatureOfferCodecs, ServerFeatureServerInfo, ServerFeatureChatRelay, ServerFeatureTransientSessionData, } ) type HelloServerMessage struct { Version string `json:"version"` SessionId PublicSessionId `json:"sessionid"` ResumeId PrivateSessionId `json:"resumeid"` UserId string `json:"userid"` // TODO: Remove once all clients have switched to the "welcome" message. Server *WelcomeServerMessage `json:"server,omitempty"` } // Type "bye" type ByeClientMessage struct { } func (m *ByeClientMessage) CheckValid() error { // No additional validation required. return nil } type ByeServerMessage struct { Reason string `json:"reason"` } // Type "room" type RoomClientMessage struct { RoomId string `json:"roomid"` SessionId RoomSessionId `json:"sessionid,omitempty"` Federation *RoomFederationMessage `json:"federation,omitempty"` } func (m *RoomClientMessage) CheckValid() error { // No additional validation required. if m.Federation != nil { if err := m.Federation.CheckValid(); err != nil { return err } } return nil } type RoomFederationMessage struct { SignalingUrl string `json:"signaling"` ParsedSignalingUrl *url.URL `json:"-"` NextcloudUrl string `json:"url"` ParsedNextcloudUrl *url.URL `json:"-"` RoomId string `json:"roomid,omitempty"` Token string `json:"token"` } func (m *RoomFederationMessage) CheckValid() error { if m.SignalingUrl == "" { return errors.New("signaling url missing") } if m.SignalingUrl[len(m.SignalingUrl)-1] != '/' { m.SignalingUrl += "/" } if u, err := url.Parse(m.SignalingUrl); err != nil { return fmt.Errorf("invalid signaling url: %w", err) } else { m.ParsedSignalingUrl = u } if m.NextcloudUrl == "" { return errors.New("nextcloud url missing") } else if u, err := url.Parse(m.NextcloudUrl); err != nil { return fmt.Errorf("invalid nextcloud url: %w", err) } else { m.ParsedNextcloudUrl = u } if m.Token == "" { return errors.New("token missing") } return nil } type RoomServerMessage struct { RoomId string `json:"roomid"` Properties json.RawMessage `json:"properties,omitempty"` Bandwidth *RoomBandwidth `json:"bandwidth,omitempty"` } type RoomBandwidth struct { MaxStreamBitrate Bandwidth `json:"maxstreambitrate"` MaxScreenBitrate Bandwidth `json:"maxscreenbitrate"` } type RoomErrorDetails struct { Room *RoomServerMessage `json:"room"` } // Type "message" const ( RecipientTypeSession = "session" RecipientTypeUser = "user" RecipientTypeRoom = "room" RecipientTypeCall = "call" ) type MessageClientMessageRecipient struct { Type string `json:"type"` SessionId PublicSessionId `json:"sessionid,omitempty"` UserId string `json:"userid,omitempty"` } type MessageClientMessage struct { Recipient MessageClientMessageRecipient `json:"recipient"` Data json.RawMessage `json:"data"` } type MessageClientMessageData struct { json.Marshaler json.Unmarshaler Type string `json:"type"` Sid string `json:"sid"` RoomType string `json:"roomType"` Payload StringMap `json:"payload"` // Only supported if Type == "offer" Bitrate Bandwidth `json:"bitrate,omitempty"` AudioCodec string `json:"audiocodec,omitempty"` VideoCodec string `json:"videocodec,omitempty"` VP9Profile string `json:"vp9profile,omitempty"` H264Profile string `json:"h264profile,omitempty"` OfferSdp *sdp.SessionDescription `json:"-"` // Only set if Type == "offer" AnswerSdp *sdp.SessionDescription `json:"-"` // Only set if Type == "answer" Candidate ice.Candidate `json:"-"` // Only set if Type == "candidate" } func (m *MessageClientMessageData) String() string { data, err := json.Marshal(m) if err != nil { return fmt.Sprintf("Could not serialize %#v: %s", m, err) } return string(data) } func ParseSDP(s string) (*sdp.SessionDescription, error) { var sdp sdp.SessionDescription if err := sdp.UnmarshalString(s); err != nil { return nil, NewErrorDetail("invalid_sdp", "Error parsing SDP from payload.", StringMap{ "error": err.Error(), }) } for _, m := range sdp.MediaDescriptions { for idx, a := range m.Attributes { if !a.IsICECandidate() { continue } if _, err := ice.UnmarshalCandidate(a.Value); err != nil { return nil, NewErrorDetail("invalid_sdp", "Error parsing candidate from media description.", StringMap{ "media": m.MediaName.Media, "idx": idx, "error": err.Error(), }) } } } return &sdp, nil } var ( emptyCandidate = &ice.CandidateHost{} ) // TODO: Use shared method from "mcu_common.go". func isValidStreamType(s string) bool { switch s { case "audio": fallthrough case "video": fallthrough case "screen": return true default: return false } } func (m *MessageClientMessageData) CheckValid() error { if m.RoomType != "" && !isValidStreamType(m.RoomType) { return fmt.Errorf("invalid room type: %s", m.RoomType) } switch m.Type { case "": return errors.New("type missing") case "offer", "answer": sdpText, ok := GetStringMapEntry[string](m.Payload, "sdp") if !ok { return ErrInvalidSdp } sdp, err := ParseSDP(sdpText) if err != nil { return err } switch m.Type { case "offer": m.OfferSdp = sdp case "answer": m.AnswerSdp = sdp } case "candidate": candValue, found := m.Payload["candidate"] if !found { return ErrNoCandidate } candItem, ok := ConvertStringMap(candValue) if !ok { return ErrInvalidCandidate } candText, ok := GetStringMapEntry[string](candItem, "candidate") if !ok { return ErrInvalidCandidate } if candText == "" { m.Candidate = emptyCandidate } else { cand, err := ice.UnmarshalCandidate(candText) if err != nil { return NewErrorDetail("invalid_candidate", "Error parsing candidate from payload.", StringMap{ "error": err.Error(), }) } m.Candidate = cand } } return nil } func FilterCandidate(c ice.Candidate, allowed *container.IPList, blocked *container.IPList) bool { switch c { case nil: return true case emptyCandidate: return false } ip := net.ParseIP(c.Address()) if len(ip) == 0 || ip.IsUnspecified() { return true } // Whitelist has preference. if allowed != nil && allowed.Contains(ip) { return false } // Check if address is blocked manually. if blocked != nil && blocked.Contains(ip) { return true } return false } func FilterSDPCandidates(s *sdp.SessionDescription, allowed *container.IPList, blocked *container.IPList) bool { modified := false for _, m := range s.MediaDescriptions { m.Attributes = slices.DeleteFunc(m.Attributes, func(a sdp.Attribute) bool { if !a.IsICECandidate() { return false } if a.Value == "" { return false } c, err := ice.UnmarshalCandidate(a.Value) if err != nil || FilterCandidate(c, allowed, blocked) { modified = true return true } return false }) } return modified } func (m *MessageClientMessage) CheckValid() error { if len(m.Data) == 0 { return errors.New("message empty") } switch m.Recipient.Type { case RecipientTypeRoom: fallthrough case RecipientTypeCall: // No additional checks required. case RecipientTypeSession: if m.Recipient.SessionId == "" { return errors.New("session id missing") } case RecipientTypeUser: if m.Recipient.UserId == "" { return errors.New("user id missing") } default: return fmt.Errorf("unsupported recipient type %v", m.Recipient.Type) } return nil } type MessageServerMessageSender struct { Type string `json:"type"` SessionId PublicSessionId `json:"sessionid,omitempty"` UserId string `json:"userid,omitempty"` } type MessageServerMessageData struct { Type string `json:"type"` } type MessageServerMessage struct { Sender *MessageServerMessageSender `json:"sender"` Recipient *MessageClientMessageRecipient `json:"recipient,omitempty"` Data json.RawMessage `json:"data"` } // Type "control" type ControlClientMessage struct { MessageClientMessage } func (m *ControlClientMessage) CheckValid() error { return m.MessageClientMessage.CheckValid() } type ControlServerMessage struct { Sender *MessageServerMessageSender `json:"sender"` Recipient *MessageClientMessageRecipient `json:"recipient,omitempty"` Data json.RawMessage `json:"data"` } // Type "internal" type CommonSessionInternalClientMessage struct { SessionId PublicSessionId `json:"sessionid"` RoomId string `json:"roomid"` } func (m *CommonSessionInternalClientMessage) CheckValid() error { if m.SessionId == "" { return errors.New("sessionid missing") } if m.RoomId == "" { return errors.New("roomid missing") } return nil } type AddSessionOptions struct { ActorId string `json:"actorId,omitempty"` ActorType string `json:"actorType,omitempty"` } type AddSessionInternalClientMessage struct { CommonSessionInternalClientMessage UserId string `json:"userid,omitempty"` User json.RawMessage `json:"user,omitempty"` Flags uint32 `json:"flags,omitempty"` InCall *int `json:"incall,omitempty"` Options *AddSessionOptions `json:"options,omitempty"` } func (m *AddSessionInternalClientMessage) CheckValid() error { return m.CommonSessionInternalClientMessage.CheckValid() } type UpdateSessionInternalClientMessage struct { CommonSessionInternalClientMessage Flags *uint32 `json:"flags,omitempty"` InCall *int `json:"incall,omitempty"` } func (m *UpdateSessionInternalClientMessage) CheckValid() error { return m.CommonSessionInternalClientMessage.CheckValid() } type RemoveSessionInternalClientMessage struct { CommonSessionInternalClientMessage UserId string `json:"userid,omitempty"` } func (m *RemoveSessionInternalClientMessage) CheckValid() error { return m.CommonSessionInternalClientMessage.CheckValid() } type InCallInternalClientMessage struct { InCall int `json:"incall"` } func (m *InCallInternalClientMessage) CheckValid() error { return nil } type DialoutStatus string var ( DialoutStatusAccepted DialoutStatus = "accepted" DialoutStatusRinging DialoutStatus = "ringing" DialoutStatusConnected DialoutStatus = "connected" DialoutStatusRejected DialoutStatus = "rejected" DialoutStatusCleared DialoutStatus = "cleared" ) type DialoutStatusInternalClientMessage struct { CallId string `json:"callid"` Status DialoutStatus `json:"status"` // Cause is set if Status is "cleared" or "rejected". Cause string `json:"cause,omitempty"` Code int `json:"code,omitempty"` Message string `json:"message,omitempty"` } type DialoutInternalClientMessage struct { Type string `json:"type"` RoomId string `json:"roomid,omitempty"` Error *Error `json:"error,omitempty"` Status *DialoutStatusInternalClientMessage `json:"status,omitempty"` } func (m *DialoutInternalClientMessage) CheckValid() error { switch m.Type { case "": return errors.New("type missing") case "error": if m.Error == nil { return errors.New("error missing") } case "status": if m.Status == nil { return errors.New("status missing") } } return nil } type InternalClientMessage struct { Type string `json:"type"` AddSession *AddSessionInternalClientMessage `json:"addsession,omitempty"` UpdateSession *UpdateSessionInternalClientMessage `json:"updatesession,omitempty"` RemoveSession *RemoveSessionInternalClientMessage `json:"removesession,omitempty"` InCall *InCallInternalClientMessage `json:"incall,omitempty"` Dialout *DialoutInternalClientMessage `json:"dialout,omitempty"` } func (m *InternalClientMessage) CheckValid() error { switch m.Type { case "": return errors.New("type missing") case "addsession": if m.AddSession == nil { return errors.New("addsession missing") } else if err := m.AddSession.CheckValid(); err != nil { return err } case "updatesession": if m.UpdateSession == nil { return errors.New("updatesession missing") } else if err := m.UpdateSession.CheckValid(); err != nil { return err } case "removesession": if m.RemoveSession == nil { return errors.New("removesession missing") } else if err := m.RemoveSession.CheckValid(); err != nil { return err } case "incall": if m.InCall == nil { return errors.New("incall missing") } else if err := m.InCall.CheckValid(); err != nil { return err } case "dialout": if m.Dialout == nil { return errors.New("dialout missing") } else if err := m.Dialout.CheckValid(); err != nil { return err } } return nil } type InternalServerDialoutRequestContents struct { // E.164 number to dial (e.g. "+1234567890") Number string `json:"number"` Options json.RawMessage `json:"options,omitempty"` } type InternalServerDialoutRequest struct { RoomId string `json:"roomid"` Backend string `json:"backend"` Request *InternalServerDialoutRequestContents `json:"request"` } type InternalServerMessage struct { Type string `json:"type"` Dialout *InternalServerDialoutRequest `json:"dialout,omitempty"` } // Type "event" type RoomEventServerMessage struct { RoomId string `json:"roomid"` Properties json.RawMessage `json:"properties,omitempty"` // TODO(jojo): Change "InCall" to "int" when #914 has landed in NC Talk. InCall json.RawMessage `json:"incall,omitempty"` Changed []StringMap `json:"changed,omitempty"` Users []StringMap `json:"users,omitempty"` All bool `json:"all,omitempty"` } func (m *RoomEventServerMessage) String() string { data, err := json.Marshal(m) if err != nil { return fmt.Sprintf("Could not serialize %#v: %s", m, err) } return string(data) } const ( DisinviteReasonDisinvited = "disinvited" DisinviteReasonDeleted = "deleted" ) type RoomDisinviteEventServerMessage struct { RoomEventServerMessage Reason string `json:"reason"` } type ChatComment StringMap type RoomEventMessageDataChat struct { // Refresh will be included if the client does not support the "chat-relay" feature. Refresh bool `json:"refresh,omitempty"` // Comment will be included if the client supports the "chat-relay" feature. Comment json.RawMessage `json:"comment,omitempty"` // Comments will be included if the client supports the "chat-relay" feature. Comments []json.RawMessage `json:"comments,omitempty"` } func (m *RoomEventMessageDataChat) HasComment() bool { return len(m.Comment) > 0 || slices.ContainsFunc(m.Comments, func(comment json.RawMessage) bool { return len(comment) > 0 }) } type RoomEventMessageData struct { Type string `json:"type"` Chat *RoomEventMessageDataChat `json:"chat,omitempty"` } type RoomEventMessage struct { RoomId string `json:"roomid"` Data json.RawMessage `json:"data,omitempty"` } func (m *RoomEventMessage) GetData() (*RoomEventMessageData, error) { if len(m.Data) == 0 { return nil, nil } // TODO: Cache parsed result. var data RoomEventMessageData if err := json.Unmarshal(m.Data, &data); err != nil { return nil, err } return &data, nil } type RoomFlagsServerMessage struct { RoomId string `json:"roomid"` SessionId PublicSessionId `json:"sessionid"` Flags uint32 `json:"flags"` } type EventServerMessage struct { Target string `json:"target"` Type string `json:"type"` // Used for target "room" Join []EventServerMessageSessionEntry `json:"join,omitempty"` Leave []PublicSessionId `json:"leave,omitempty"` Change []EventServerMessageSessionEntry `json:"change,omitempty"` SwitchTo *EventServerMessageSwitchTo `json:"switchto,omitempty"` Resumed *bool `json:"resumed,omitempty"` // Used for target "roomlist" / "participants" Invite *RoomEventServerMessage `json:"invite,omitempty"` Disinvite *RoomDisinviteEventServerMessage `json:"disinvite,omitempty"` Update *RoomEventServerMessage `json:"update,omitempty"` Flags *RoomFlagsServerMessage `json:"flags,omitempty"` // Used for target "message" Message *RoomEventMessage `json:"message,omitempty"` } func (m *EventServerMessage) String() string { data, err := json.Marshal(m) if err != nil { return fmt.Sprintf("Could not serialize %#v: %s", m, err) } return string(data) } type EventServerMessageSessionEntry struct { SessionId PublicSessionId `json:"sessionid"` UserId string `json:"userid"` Features []string `json:"features,omitempty"` User json.RawMessage `json:"user,omitempty"` RoomSessionId RoomSessionId `json:"roomsessionid,omitempty"` Federated bool `json:"federated,omitempty"` } func (e EventServerMessageSessionEntry) Clone() EventServerMessageSessionEntry { return EventServerMessageSessionEntry{ SessionId: e.SessionId, UserId: e.UserId, Features: e.Features, User: e.User, RoomSessionId: e.RoomSessionId, Federated: e.Federated, } } type EventServerMessageSwitchTo struct { RoomId string `json:"roomid"` Details json.RawMessage `json:"details,omitempty"` } // MCU-related types type AnswerOfferMessage struct { To PublicSessionId `json:"to"` From PublicSessionId `json:"from"` Type string `json:"type"` RoomType string `json:"roomType"` Payload StringMap `json:"payload"` Sid string `json:"sid,omitempty"` } // Type "transient" type TransientDataClientMessage struct { Type string `json:"type"` Key string `json:"key,omitempty"` Value json.RawMessage `json:"value,omitempty"` TTL time.Duration `json:"ttl,omitempty"` } func (m *TransientDataClientMessage) CheckValid() error { switch m.Type { case "": return errors.New("type missing") case "set": if m.Key == "" { return errors.New("key missing") } // A "nil" value is allowed and will remove the key. case "remove": if m.Key == "" { return errors.New("key missing") } } return nil } type TransientDataServerMessage struct { Type string `json:"type"` Key string `json:"key,omitempty"` OldValue any `json:"oldvalue,omitempty"` Value any `json:"value,omitempty"` Data StringMap `json:"data,omitempty"` }