package main import ( "encoding/json" "errors" "math" "net/http" "strconv" "strings" "time" rtctokenbuilder "github.com/AgoraIO/Tools/DynamicKey/AgoraDynamicKey/go/src/rtctokenbuilder2" "github.com/tinode/chat/server/store" "github.com/tinode/chat/server/store/types" ) const ( imReasonAgoraDisabled = "AGORA_DISABLED" imReasonAgoraConfigMissing = "AGORA_CONFIG_MISSING" imReasonCallNotFound = "CALL_NOT_FOUND" imReasonCallNotAllowed = "CALL_NOT_ALLOWED" imReasonCallStateInvalid = "CALL_STATE_INVALID" imClientFeatureConfigGroup = "im_client_feature" ) type imCallRTCJoinRequest struct { Topic string `json:"topic"` CallID string `json:"call_id"` Seq int64 `json:"seq"` CallType string `json:"call_type"` Role string `json:"role"` } type imAgoraConfig struct { Enabled bool AuthMode string AppID string AppCertificate string TokenTTLSeconds int ChannelPrefix string UIDMode string } func serveIMCallRTCJoin(wrt http.ResponseWriter, req *http.Request) { if req.Method != http.MethodPost { writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) return } principal, ok := authenticateIMHTTPBearer(wrt, req) if !ok { return } var body imCallRTCJoinRequest if err := json.NewDecoder(http.MaxBytesReader(wrt, req.Body, 1<<20)).Decode(&body); err != nil { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil) return } body.Topic = strings.TrimSpace(body.Topic) body.CallID = strings.TrimSpace(body.CallID) body.CallType = strings.ToLower(strings.TrimSpace(body.CallType)) body.Role = strings.ToLower(strings.TrimSpace(body.Role)) if body.Topic == "" || body.CallID == "" || body.Seq <= 0 || (body.CallType != constCallTypeAudio && body.CallType != constCallTypeVideo) || (body.Role != "caller" && body.Role != "callee") { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil) return } normalizedTopic, _ := normalizeP2PConversationTopic(body.Topic, principal.Uid) if normalizedTopic == "" { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"}) return } body.Topic = normalizedTopic config, err := loadIMAgoraConfig() if err != nil { if errors.Is(err, types.ErrNotFound) || errors.Is(err, types.ErrMalformed) { writeIMError(wrt, req, http.StatusServiceUnavailable, 30060, imReasonAgoraConfigMissing, "agora config missing", nil) return } writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } if !config.Enabled { writeIMError(wrt, req, http.StatusServiceUnavailable, 30061, imReasonAgoraDisabled, "agora disabled", nil) return } sub, err := store.Subs.Get(principal.TenantID, body.Topic, principal.Uid, false) if err != nil { writeIMRelationError(wrt, req, err) return } if sub == nil || !(sub.ModeGiven & sub.ModeWant).IsReader() { writeIMError(wrt, req, http.StatusForbidden, 30062, imReasonCallNotAllowed, "call not allowed", nil) return } record, err := findCallRecordForRTCJoin(principal.TenantID, principal.Uid, body) if err != nil { switch { case errors.Is(err, types.ErrNotFound): writeIMError(wrt, req, http.StatusNotFound, 30063, imReasonCallNotFound, "call not found", nil) case errors.Is(err, types.ErrPermissionDenied): writeIMError(wrt, req, http.StatusForbidden, 30062, imReasonCallNotAllowed, "call not allowed", nil) default: writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) } return } if !callStateAllowsRTCJoin(record.Status) { writeIMError(wrt, req, http.StatusConflict, 30064, imReasonCallStateInvalid, "call state invalid", map[string]any{"status": record.Status}) return } channelName := config.ChannelPrefix + record.CallID uid, err := agoraUIDForUser(config, principal.Uid) if err != nil { writeIMError(wrt, req, http.StatusServiceUnavailable, 30060, imReasonAgoraConfigMissing, "agora config missing", nil) return } token := "" var expiresAt any if config.AuthMode == "token" { token, expiresAt, err = buildAgoraRTCToken(config, channelName, principal.Uid) if err != nil { writeIMError(wrt, req, http.StatusServiceUnavailable, 30060, imReasonAgoraConfigMissing, "agora rtc token generation failed", nil) return } } writeIMSuccess(wrt, req, map[string]any{ "provider": "agora", "auth_mode": config.AuthMode, "app_id": config.AppID, "channel_name": channelName, "uid": uid, "token": token, "token_expires_at": expiresAt, "call_id": record.CallID, "topic": record.Topic, "seq": record.SeqID, "call_type": record.CallType, "role": body.Role, }) } func loadIMAgoraConfig() (imAgoraConfig, error) { values, err := store.ThirdPartyConfig.Get("im_shengwang", "agora_enabled", "agora_auth_mode", "agora_app_id", "agora_app_certificate", "agora_token_ttl_seconds", "agora_channel_prefix", "agora_uid_mode", ) if err != nil { return imAgoraConfig{}, err } config := imAgoraConfig{ Enabled: imTruthy(values["agora_enabled"]), AuthMode: strings.ToLower(strings.TrimSpace(values["agora_auth_mode"])), AppID: strings.TrimSpace(values["agora_app_id"]), AppCertificate: strings.TrimSpace(values["agora_app_certificate"]), TokenTTLSeconds: 3600, ChannelPrefix: strings.TrimSpace(values["agora_channel_prefix"]), UIDMode: strings.ToLower(strings.TrimSpace(values["agora_uid_mode"])), } if config.AuthMode == "" { config.AuthMode = "app_id" } if config.ChannelPrefix == "" { config.ChannelPrefix = "call_" } if config.UIDMode == "" { config.UIDMode = "string" } if ttl := strings.TrimSpace(values["agora_token_ttl_seconds"]); ttl != "" { parsed, err := strconv.Atoi(ttl) if err != nil || parsed <= 0 { return imAgoraConfig{}, types.ErrMalformed } config.TokenTTLSeconds = parsed } if !config.Enabled { return config, nil } if config.AppID == "" || (config.AuthMode != "app_id" && config.AuthMode != "token") { return imAgoraConfig{}, types.ErrMalformed } if config.AuthMode == "token" && config.AppCertificate == "" { return imAgoraConfig{}, types.ErrMalformed } return config, nil } func imCallFeatureFlags() (bool, bool) { runtimeEnabled := imCallRuntimeEnabled() if !runtimeEnabled || store.ThirdPartyConfig == nil { return false, false } values, err := store.ThirdPartyConfig.Get(imClientFeatureConfigGroup, "audio_call_enabled", "video_call_enabled") if err != nil { return false, false } return imTruthy(values["audio_call_enabled"]), imTruthy(values["video_call_enabled"]) } func imCallRuntimeEnabled() bool { webrtcEnabled := len(globals.iceServers) > 0 agoraEnabled := false if config, err := loadIMAgoraConfig(); err == nil { agoraEnabled = config.Enabled } return webrtcEnabled || agoraEnabled } func imCallFeatureEnabled() bool { audioCall, videoCall := imCallFeatureFlags() return audioCall || videoCall } func imCallTimeoutSeconds() int { if globals.callEstablishmentTimeout > 0 { return globals.callEstablishmentTimeout } if imCallFeatureEnabled() { return defaultCallEstablishmentTimeout } return 0 } func imCanCall(allowed bool) bool { return allowed && imCallFeatureEnabled() } func findCallRecordForRTCJoin(tenantID types.TenantID, uid types.Uid, body imCallRTCJoinRequest) (*types.CallRecord, error) { if store.Calls == nil { return nil, types.ErrUnsupported } records, _, err := store.Calls.List(tenantID, uid, body.Topic, "", 100) if err != nil { return nil, err } for i := range records { record := &records[i] if record.CallID != body.CallID || record.Topic != body.Topic || record.SeqID != body.Seq || record.CallType != body.CallType { continue } if record.CallerID != uid && record.CalleeID != uid { return nil, types.ErrPermissionDenied } if body.Role == "caller" && record.CallerID != uid { return nil, types.ErrPermissionDenied } if body.Role == "callee" && record.CalleeID != uid { return nil, types.ErrPermissionDenied } return record, nil } return nil, types.ErrNotFound } func callStateAllowsRTCJoin(status string) bool { switch status { case types.CallStatusStarted, types.CallStatusAccepted: return true default: return false } } func agoraUIDForUser(config imAgoraConfig, uid types.Uid) (any, error) { switch config.UIDMode { case "int": decoded := store.DecodeUid(uid) if decoded <= 0 || decoded > math.MaxUint32 { return nil, types.ErrMalformed } return decoded, nil case "string": return uid.UserId(), nil default: return nil, types.ErrMalformed } } func buildAgoraRTCToken(config imAgoraConfig, channelName string, uid types.Uid) (string, string, error) { ttl := uint32(config.TokenTTLSeconds) if config.UIDMode == "int" { decoded := store.DecodeUid(uid) if decoded <= 0 || decoded > math.MaxUint32 { return "", "", types.ErrMalformed } token, err := rtctokenbuilder.BuildTokenWithUid(config.AppID, config.AppCertificate, channelName, uint32(decoded), rtctokenbuilder.RolePublisher, ttl, ttl) if err != nil { return "", "", err } return token, agoraTokenExpiresAt(config.TokenTTLSeconds), nil } account := uid.UserId() if account == "" { return "", "", types.ErrMalformed } token, err := rtctokenbuilder.BuildTokenWithUserAccount(config.AppID, config.AppCertificate, channelName, account, rtctokenbuilder.RolePublisher, ttl, ttl) if err != nil { return "", "", err } return token, agoraTokenExpiresAt(config.TokenTTLSeconds), nil } func imTruthy(value string) bool { switch strings.ToLower(strings.TrimSpace(value)) { case "1", "true", "yes", "on": return true default: return false } } func agoraTokenExpiresAt(ttlSeconds int) string { return time.Now().UTC().Add(time.Duration(ttlSeconds) * time.Second).Format(time.RFC3339Nano) }