diff --git a/core/group_party.go b/core/group_party.go index ea0dadb..5be9853 100644 --- a/core/group_party.go +++ b/core/group_party.go @@ -894,3 +894,4 @@ func (gp *groupParty) DenyPartyInvitation(ctx wshandler.ApiCallContext) { Tag: []string{"MemberDocFull"}, }) } + diff --git a/core/group_voice.go b/core/group_voice.go new file mode 100644 index 0000000..903adb3 --- /dev/null +++ b/core/group_voice.go @@ -0,0 +1,227 @@ +package core + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "sync/atomic" + "time" + "unsafe" + + "github.com/gorilla/websocket" + "repositories.action2quare.com/ayo/gocommon" + "repositories.action2quare.com/ayo/gocommon/logger" + "repositories.action2quare.com/ayo/gocommon/wshandler" +) + +type eosauth struct { + AccessToken string `json:"access_token"` + ExpiresAt time.Time `json:"expires_at"` + ExpiresIn int64 `json:"expires_in"` + DeploymentId string `json:"deployment_id"` + ProductId string `json:"product_id"` + SandboxId string `json:"sandbox_id"` + TokenType string `json:"token_type"` +} + +type groupVoice struct { + rh *gocommon.RedisonHandler + eosptr unsafe.Pointer +} + +func (gv *groupVoice) eosTokenRefresh(ctx context.Context) { + defer func() { + r := recover() + if r != nil { + logger.Error(r) + } + }() + + endpoint := "https://api.epicgames.dev/auth/v1/oauth/token" + auth := base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", config.EosClientId, config.EosClientSecret))) + body := bytes.NewBufferString("grant_type=client_credentials&deployment_id=" + config.EosDeploymentId) + for { + req, _ := http.NewRequest("POST", endpoint, body) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", fmt.Sprintf("Basic %s", auth)) + resp, err := http.DefaultClient.Do(req) + if err != nil { + logger.Println("eosTokenRefresh failed. eos token reqeust err :", err) + time.Sleep(time.Minute) + continue + } + + var neweos eosauth + err = json.NewDecoder(resp.Body).Decode(&neweos) + resp.Body.Close() + + if err != nil { + logger.Println("eosTokenRefresh failed. decode err :", err) + return + } + + logger.Printf("eos access_token retreived : %s...", neweos.AccessToken[:20]) + atomic.StorePointer(&gv.eosptr, unsafe.Pointer(&neweos)) + select { + case <-ctx.Done(): + return + + case <-time.After(time.Duration(neweos.ExpiresIn-60) * time.Second): + + } + } +} + +func (gv *groupVoice) eosAuth() *eosauth { + ptr := atomic.LoadPointer(&gv.eosptr) + return (*eosauth)(ptr) +} + +func (gv *groupVoice) Initialize(tv *Tavern, ctx context.Context) error { + gv.rh = tv.redison + gv.eosptr = unsafe.Pointer(&eosauth{}) + + if len(config.EosClientId) == 0 { + logger.Println("eos voice chat is disabled. 'eos_client_id' is empty") + } + if len(config.EosClientSecret) == 0 { + logger.Println("eos voice chat is disabled. 'eos_client_secret' is empty") + } + if len(config.EosDeploymentId) == 0 { + logger.Println("eos voice chat is disabled. 'eos_deployment_id' is empty") + } + + if len(config.EosClientId) > 0 && len(config.EosClientSecret) > 0 && len(config.EosDeploymentId) > 0 { + go gv.eosTokenRefresh(ctx) + } + + return nil +} + +func (gv *groupVoice) ClientConnected(conn *websocket.Conn, callby *wshandler.Sender) { + +} + +func (gv *groupVoice) ClientDisconnected(msg string, callby *wshandler.Sender) { + // vals, err := gv.rh.JSONGetString(callby.Accid.Hex(), "$.voice") + // if err != nil { + // return + // } + + // if len(vals) == 0 { + // return + // } + + // switch vals[0] { + // case "eos": + // // TODO : Removing a Participant + // // https://dev.epicgames.com/docs/web-api-ref/voice-web-api#removing-a-participant + // } +} + +type eosRoomParticipantRequests struct { + Puid string `json:"puid"` + ClientIP string `json:"clientIP"` + HardMuted bool `json:"hardMuted"` +} + +type eosRoomParticipants struct { + Participants []eosRoomParticipantRequests `json:"participants"` +} + +func (gv *groupVoice) JoinVoiceChat(w http.ResponseWriter, r *http.Request) { + var data struct { + Gid string + Mid string + Service string + Alias string + } + if err := gocommon.MakeDecoder(r).Decode(&data); err != nil { + logger.Println("JoinVoiceChat failed. DecodeGob returns err :", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + switch data.Service { + case "eos": + // https://dev.epicgames.com/docs/web-api-ref/voice-web-api + accessToken := gv.eosAuth().AccessToken + if len(accessToken) == 0 { + logger.Println("eos voice chat is not ready. access_token is empty") + w.WriteHeader(http.StatusInternalServerError) + return + } + + voiceendpoint := fmt.Sprintf("https://api.epicgames.dev/rtc/v1/%s/room/%s", config.EosDeploymentId, data.Gid) + participants := eosRoomParticipants{ + Participants: []eosRoomParticipantRequests{ + {Puid: data.Mid}, + }, + } + + body, _ := json.Marshal(participants) + req, _ := http.NewRequest("POST", voiceendpoint, bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", accessToken)) + resp, err := http.DefaultClient.Do(req) + if err != nil { + logger.Println("join voice room failed. api.epicgames.dev return err :", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + var result map[string]any + json.NewDecoder(resp.Body).Decode(&result) + resp.Body.Close() + + result["client_id"] = config.EosClientId + result["client_secret"] = config.EosClientSecret + + par := result["participants"].([]any)[0] + participant := par.(map[string]any) + + channelCredentials := map[string]any{ + "override_userid": data.Mid, + "client_base_url": result["clientBaseUrl"], + "participant_token": participant["token"], + } + marshaled, _ := json.Marshal(channelCredentials) + result["channel_credentials"] = base64.StdEncoding.EncodeToString(marshaled) + + gocommon.MakeEncoder(w, r).Encode(result) + } +} + +func (gv *groupVoice) LeaveVoiceChat(w http.ResponseWriter, r *http.Request) { + var data struct { + Gid string + Mid string + Service string + } + if err := gocommon.MakeDecoder(r).Decode(&data); err != nil { + logger.Println("JoinVoiceChat failed. DecodeGob returns err :", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + switch data.Service { + case "eos": + voiceendpoint := fmt.Sprintf("https://api.epicgames.dev/rtc/v1/%s/room/%s/participants/%s", config.EosDeploymentId, data.Gid, data.Mid) + accessToken := gv.eosAuth().AccessToken + + req, _ := http.NewRequest("DELETE", voiceendpoint, nil) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", accessToken)) + resp, err := http.DefaultClient.Do(req) + if err != nil { + logger.Println("LeaveVoiceChat failed. err :", err) + } else if resp.StatusCode != http.StatusOK { + logger.Println("LeaveVoiceChat failed. status code :", resp.StatusCode) + } + } +} diff --git a/core/tavern.go b/core/tavern.go index bd5cc68..202442e 100644 --- a/core/tavern.go +++ b/core/tavern.go @@ -28,6 +28,9 @@ type TavernConfig struct { Group map[string]configDocument `json:"tavern_group_types"` MaingateApiToken string `json:"maingate_api_token"` RedisURL string `json:"tavern_redis_url"` + EosClientId string `json:"eos_client_id"` + EosClientSecret string `json:"eos_client_secret"` + EosDeploymentId string `json:"eos_deployment_id"` macAddr string } @@ -120,6 +123,13 @@ func (tv *Tavern) prepare(ctx context.Context) error { tv.httpApiBorker.AddHandler(gocommon.MakeHttpApiHandler(instant, "instant")) tv.wsh.AddHandler(wshandler.MakeWebsocketApiHandler(instant, "instant")) + voice := new(groupVoice) + if err := voice.Initialize(tv, ctx); err != nil { + return logger.ErrorWithCallStack(err) + } + tv.httpApiBorker.AddHandler(gocommon.MakeHttpApiHandler(voice, "voice")) + tv.wsh.AddHandler(wshandler.MakeWebsocketApiHandler(voice, "voice")) + ccu = metric.NewMetric(metric.MetricGuage, "concurrent_user", "concurrent user count", map[string]string{"game": "lobby"}) return nil }