diff --git a/LarpixServer/Account/Requests.cs b/LarpixServer/Account/Requests.cs index b6028fe..dd08533 100644 --- a/LarpixServer/Account/Requests.cs +++ b/LarpixServer/Account/Requests.cs @@ -1098,6 +1098,12 @@ public class Requests await Room.Shared.VoiceLeave(id, serializedBody.string2) , password)); break; + case "room/voice/state": + await context.Response.WriteAsync( + Encryption.Encryption.EncryptForClient( + await Room.Shared.VoiceState(id, serializedBody.string2) + , password)); + break; case "room/signal": await context.Response.WriteAsync( Encryption.Encryption.EncryptForClient( diff --git a/LarpixServer/Account/Utils.cs b/LarpixServer/Account/Utils.cs index b88bb93..169e578 100644 --- a/LarpixServer/Account/Utils.cs +++ b/LarpixServer/Account/Utils.cs @@ -531,6 +531,23 @@ public class Utils } } + //Dopisuje "call":true do wpisu JSON listy, jeśli w pokoju trwa aktywna rozmowa + //głosowa (ActiveCalls). Pozwala klientowi od razu po starcie pokazać wskaźnik. + public static string AppendCallStateToEntry(string entryJson, string roomId) + { + bool active = false; + if (ActiveCalls.TryGetValue(roomId, out var callList)) + { + //Odczyt Count pod lockiem - ta sama lista jest mutowana pod lockiem + //w VoiceJoin/VoiceLeave/disconnect cleanup + lock (callList) { active = callList.Count > 0; } + } + if (!active) return entryJson; + string trimmed = entryJson.TrimEnd(); + if (!trimmed.EndsWith("}")) return entryJson; + return $"{trimmed.Substring(0, trimmed.Length - 1)},\"call\":true}}"; + } + public static async Task GetUserDms(string id) { string path = $"{ACCOUNTS_DATA_DIR}/{id}/dms"; @@ -546,14 +563,16 @@ public class Utils { string content = Encoding.UTF8.GetString(await Fs.ReadFile($"{path}/{dmFiles[i]}")); if (string.IsNullOrWhiteSpace(content)) continue; - + + content = AppendCallStateToEntry(content, dmFiles[i]); + if (!first) dmsBuilder.Append(','); dmsBuilder.Append($"\"{dmFiles[i]}\":"); dmsBuilder.Append(content); first = false; } dmsBuilder.Append("}"); - + return $"{{{dmsBuilder.ToString()}}}"; } diff --git a/LarpixServer/Room/Groups.cs b/LarpixServer/Room/Groups.cs index 28932d3..b11953e 100644 --- a/LarpixServer/Room/Groups.cs +++ b/LarpixServer/Room/Groups.cs @@ -170,14 +170,16 @@ public class Groups { string content = Encoding.UTF8.GetString(await Fs.ReadFile($"{path}/{groupFiles[i]}")); if (string.IsNullOrWhiteSpace(content)) continue; - + + content = Account.Utils.AppendCallStateToEntry(content, groupFiles[i]); + if (!first) groupsBuilder.Append(','); groupsBuilder.Append($"\"{groupFiles[i]}\":"); groupsBuilder.Append(content); first = false; } groupsBuilder.Append("}"); - + return $"{{{groupsBuilder.ToString()}}}"; } diff --git a/LarpixServer/Room/Shared.cs b/LarpixServer/Room/Shared.cs index 33f9467..d2db31f 100644 --- a/LarpixServer/Room/Shared.cs +++ b/LarpixServer/Room/Shared.cs @@ -655,6 +655,44 @@ public class Shared Timeout = TimeSpan.FromSeconds(2) }; + // ===== FEDERACJA GŁOSU - PLAN (celowo niezaimplementowany) ===== + // + // Zasady hostowania rozmów między serwerami: + // 1) GROUPS - rozmowa głosowa jest zawsze hostowana przez serwer DOMOWY + // grupy (domena z groupId, np. "123;example.com" -> example.com). + // Powód: członkowie, klucze pokoju i ActiveCalls żyją na serwerze grupy, + // tam najprościej utrzymać jeden spójny stan i rozsyłać call_update + // (także do użytkowników zdalnych, przez API federacyjne). + // + // 2) DMS - gdy uczestnicy DM są z dwóch różnych domen, rozmowę hostuje + // serwer domowy OSOBY, KTÓRA ROZPOCZĘŁA rozmowę (pierwszy uczestnik). + // Powód: initiator i tak wykonuje room/voice/join u siebie; druga strona + // dostaje call_update/sygnalizację i dołącza do pokoju na hoście. + // Determinacja "kto zaczął": pierwszy wpis w liście uczestników danej + // rozmowy; po stronie implementacji trzeba tę informację przekazać + // w protokole federacyjnym (np. w żądaniu forwardu join). + // + // Do zrobienia przy implementacji: + // - GetVoiceHostDomain() ma zwracać domenę hosta (groups: domena z roomId; + // dms: domena inicjatora gdy obie domeny różne), + // - VoiceJoin/VoiceState: gdy hostDomain != DOMAIN -> forward żądania do + // room/voice/join / room/voice/state na serwerze domowym hosta i zwrot + // jego odpowiedzi (proxy), zamiast lokalnej alokacji ticketu, + // - klucz ActiveCalls dla pokoi zdalnych kwalifikować domeną + // (np. "{roomId}@{hostDomain}"), żeby nie kolidował z lokalnymi, + // - call_update rozsyłać również do członków zdalnych (Sender API), + // a disconnect cleanup powiadamiał hosta o odejściu swojego usera, + // - sygnalizacja (room/signal): sprawdzić trasowanie signal: do zdalnych. + public static string GetVoiceHostDomain(string roomType, string roomId) + { + // TODO(federation): + // - groups -> domena z części roomId po ';', + // - dms -> rozparsować dmId ("idA;domA_idB;domB"); jeśli domA != domB, + // hostem jest domena inicjatora (wymaga informacji o inicjatorze + // przekazanej przez protokół federacyjny). + return DOMAIN; + } + public static async Task VoiceJoin(string id, string body) { Universal2String serializedBody = JsonSerializer.Deserialize( @@ -666,18 +704,43 @@ public class Shared if (!await IsMemberOfRoom(id, roomType, roomId)) return "error:forbidden"; + //Federacja (patrz plan wyżej): dla pokoju zdalnego to żądanie powinno + //zostać sforwardowane do serwera hostującego rozmowę. Na razie każda + //rozmowa jest hostowana lokalnie. + string voiceHostDomain = GetVoiceHostDomain(roomType, roomId); + + bool added = false; var callList = ActiveCalls.GetOrAdd(roomId, _ => new List()); lock (callList) { if (!callList.Contains(id)) { callList.Add(id); + added = true; } } - - string callState; - lock (callList) { callState = JsonSerializer.Serialize(callList, AppJsonSerializerContext.Default.ListString); } - + + //call_update rozsyłamy TYLKO gdy skład się zmienił - ponowne wejście + //(np. reconnect) nie generuje zbędnych broadcastów do wszystkich + //członków, co przy dużych grupach oszczędza WS-owe pakiety i CPU + if (added) + { + string callState; + lock (callList) { callState = JsonSerializer.Serialize(callList, AppJsonSerializerContext.Default.ListString); } + + string[] members = Fs.ReadDirectory($"{GetRoomPath(roomType, roomId)}/members"); + foreach(string memberWD in members) + { + if (Account.Utils.IsUserLocal(memberWD, out string _)) + { + string memberId = Account.Utils.GetValidIdOrZero(memberWD); + _ = Utils.Utils.SendWsMessage(memberId, $"call_update:{roomId}:{callState}"); + } + } + } + //Wybór serwera głosowego: najmniej obciążony z lokalnej listy VOICE_IPS. + //Federacja (plan): gdy voiceHostDomain != DOMAIN, tutaj należy użyć + //adresu serwera głosowego domeny hosta zamiast lokalnej listy. string bestIp = ""; long bestLoad = long.MaxValue; @@ -728,15 +791,6 @@ public class Shared return "error:voice.not.configured"; // 48 random bytes colliding - practically impossible } - string[] members = Fs.ReadDirectory($"{GetRoomPath(roomType, roomId)}/members"); - foreach(string memberWD in members) - { - if (Account.Utils.IsUserLocal(memberWD, out string _)) - { - string memberId = Account.Utils.GetValidIdOrZero(memberWD); - _ = Utils.Utils.SendWsMessage(memberId, $"call_update:{roomId}:{callState}"); - } - } return $"success:{bestIp}|{publicId}|{connKey}|{proof}"; } @@ -930,6 +984,33 @@ public class Shared return "success"; } + //Zwraca listę uczestników aktywnej rozmowy głosowej w pokoju (ID z domeną). + //Używane przez klienta do pokazania wskaźników "na rozmowie" po otwarciu pokoju. + public static async Task VoiceState(string id, string body) + { + Universal2String serializedBody = JsonSerializer.Deserialize( + body, + AppJsonSerializerContext.Default.Universal2String + ); + string roomType = serializedBody.string1; + string roomId = serializedBody.string2; + + if (!await IsMemberOfRoom(id, roomType, roomId)) return "error:forbidden"; + + List participants = new List(); + if (ActiveCalls.TryGetValue(roomId, out var callList)) + { + lock (callList) + { + foreach (string memberId in callList) + { + participants.Add($"{memberId};{DOMAIN}"); + } + } + } + return JsonSerializer.Serialize(participants, AppJsonSerializerContext.Default.ListString); + } + public static async Task RoomSignal(string id, string body) { Universal3String serializedBody = JsonSerializer.Deserialize(