/* * Copyright (c) Contributors, http://opensimulator.org/ * See CONTRIBUTORS.TXT for a full list of copyright holders. * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: * * Redistributions of source code must retain the above copyright * notice, this list of conditions and the following disclaimer. * * Redistributions in binary form must reproduce the above copyright * notice, this list of conditions and the following disclaimer in the * documentation and/or other materials provided with the distribution. * * Neither the name of the OpenSimulator Project nor the * names of its contributors may be used to endorse or promote products * derived from this software without specific prior written permission. * * THIS SOFTWARE IS PROVIDED BY THE DEVELOPERS ``AS IS'' AND ANY * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE * DISCLAIMED. IN NO EVENT SHALL THE CONTRIBUTORS BE LIABLE FOR ANY * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Net.Http; using System.Net.Mime; using System.Reflection; using System.Threading.Tasks; using OpenMetaverse; using OpenMetaverse.StructuredData; using log4net; using System.Threading; using OpenSim.Framework; namespace osWebRtcVoice { // Encapsulization of a Session to the Janus server public class JanusSession : IDisposable { private static readonly ILog m_log = LogManager.GetLogger(MethodBase.GetCurrentMethod().DeclaringType); private static readonly string LogHeader = "[JANUS SESSION]"; // Set to true to enable Janus session debug logging. private bool _DebugEnabled = false; // Set to 'true' to get the messages send and received from Janus private bool _MessageDetails = false; private string _JanusServerURI = string.Empty; private string _JanusAPIToken = string.Empty; private string _JanusAdminURI = string.Empty; private string _JanusAdminToken = string.Empty; public string JanusServerURI => _JanusServerURI; public string JanusAdminURI => _JanusAdminURI; public string SessionId { get; private set; } public string SessionUri { get ; private set ; } public string PluginId { get; set; } // private CancellationTokenSource _CancelTokenSource = new(); public bool IsConnected { get; set; } // Wrapper around the session connection to Janus-gateway public JanusSession(string pServerURI, string pAPIToken, string pAdminURI, string pAdminToken, bool pDebugEnabled = false, bool pDebugMessages = false) { // m_log.DebugFormat("{0} JanusSession constructor", LogHeader); _DebugEnabled = pDebugEnabled; DebugLog("{0} JanusSession constructor", LogHeader); _JanusServerURI = pServerURI; _JanusAPIToken = pAPIToken; _JanusAdminURI = pAdminURI; _JanusAdminToken = pAdminToken; _MessageDetails = pDebugMessages; } public void Dispose() { ClearEventSubscriptions(); if (IsConnected) { _ = DestroySession(); } } /// /// Make the create session request to the Janus server, get the /// sessionID and return TRUE if successful. /// /// TRUE if session was created successfully public async Task CreateSession() { bool ret = false; try { JanusMessageResp resp = await SendToJanus(new CreateSessionReq()).ConfigureAwait(false); if (resp is not null && resp.isSuccess) { CreateSessionResp sessionResp = new(resp); SessionId = sessionResp.returnedId; IsConnected = true; SessionUri = _JanusServerURI + "/" + SessionId; m_log.DebugFormat("{0} CreateSession. Created. ID={1}, URL={2}", LogHeader, SessionId, SessionUri); // DebugLog("{0} CreateSession. Created. ID={1}, URL={2}", LogHeader, SessionId, SessionUri); ret = true; StartLongPoll(); } else { m_log.ErrorFormat("{0} CreateSession: failed", LogHeader); } } catch (Exception e) { m_log.ErrorFormat("{0} CreateSession: exception {1}", LogHeader, e); } return ret; } public async Task DestroySession() { bool ret = false; try { JanusMessageResp resp = await SendToSession(new DestroySessionReq()).ConfigureAwait(false); if (resp is not null && resp.isSuccess) { // Note that setting IsConnected to false will cause the long poll to exit m_log.Debug($"{LogHeader} DestroySession. Destroyed"); // Debug("{0} DestroySession. Destroyed", LogHeader); } else { if (resp.isError) { ErrorResp eResp = new(resp); switch (eResp.errorCode) { case 458: // This is the error code for a session that is already destroyed m_log.Debug($"{LogHeader} DestroySession: session already destroyed"); // DebugLog("{0} DestroySession: session already destroyed", LogHeader); break; case 459: // This is the error code for handle already destroyed if (_MessageDetails) m_log.Debug($"{LogHeader} DestroySession: Handle not found"); // if (_MessageDetails) DebugLog("{0} DestroySession: Handle not found", LogHeader); break; default: m_log.Error($"{LogHeader} DestroySession: failed {eResp.errorReason}"); break; } } else { m_log.Error($"{LogHeader} DestroySession: failed response"); if (m_log.IsDebugEnabled) m_log.DebugFormat("{0} DestroySession: response detail {1}", LogHeader, resp.ToString()); } } } catch (Exception e) { m_log.Error($"{LogHeader} DestroySession: exception ", e); } IsConnected = false; // _CancelTokenSource.Cancel(); return ret; } // ==================================================================== public async Task TrickleCandidates(JanusViewerSession pVSession, OSDArray pCandidates) { JanusMessageResp ret = null; // if the audiobridge is active, the trickle message is sent to it if (pVSession.AudioBridge is null) { ret = await SendToJanusNoWait(new TrickleReq(pVSession, pCandidates)).ConfigureAwait(false); } else { ret = await SendToJanusNoWait(new TrickleReq(pVSession, pCandidates), pVSession.AudioBridge.PluginUri).ConfigureAwait(false); } return ret; } // ==================================================================== public async Task TrickleCompleted(JanusViewerSession pVSession) { JanusMessageResp ret = null; // if the audiobridge is active, the trickle message is sent to it if (pVSession.AudioBridge is null) { ret = await SendToJanusNoWait(new TrickleReq(pVSession)).ConfigureAwait(false); } else { ret = await SendToJanusNoWait(new TrickleReq(pVSession), pVSession.AudioBridge.PluginUri).ConfigureAwait(false); } return ret; } // ==================================================================== public Dictionary _Plugins = new Dictionary(); public void AddPlugin(JanusPlugin pPlugin) { _Plugins.Add(pPlugin.PluginName, pPlugin); } private void DebugLog(string pFormat, params object[] pArgs) { if (_DebugEnabled) { m_log.DebugFormat(pFormat, pArgs); } } private void DebugLog(string message) { if (_DebugEnabled) { m_log.Debug(message); } } // ==================================================================== // Post to the session public async Task SendToSession(JanusMessageReq pReq) { return await SendToJanus(pReq, SessionUri); } private class OutstandingRequest { public string TransactionId; public DateTime RequestTime; public TaskCompletionSource TaskCompletionSource; } private readonly ConcurrentDictionary _OutstandingRequests = new(); // Send a request directly to the Janus server. // NOTE: this is probably NOT what you want to do. This is a direct call that is outside the session. private async Task SendToJanus(JanusMessageReq pReq) { return await SendToJanus(pReq, _JanusServerURI); } /// /// Send a request to the Janus server. This is the basic call that sends a request to the server. /// The transaction ID is used to match the response to the request. /// If the request returns an 'ack' response, the code waits for the matching event /// before returning the response. /// /// /// /// public async Task SendToJanus(JanusMessageReq pReq, string pURI, bool admin = false) { AddJanusHeaders(pReq, admin); if (_MessageDetails) m_log.DebugFormat($"{LogHeader} SendToJanus. URI={pURI}, req={pReq.ToJson}"); JanusMessageResp ret = null; try { OutstandingRequest outReq = new OutstandingRequest { TransactionId = pReq.TransactionId, RequestTime = DateTime.Now, TaskCompletionSource = new TaskCompletionSource() }; _OutstandingRequests[pReq.TransactionId] = outReq; string reqStr = pReq.ToJson(); HttpClient httpClient = WebUtil.GetNewGlobalHttpClient(30000); HttpRequestMessage reqMsg = new(HttpMethod.Post, pURI); reqMsg.Content = new StringContent(reqStr, System.Text.Encoding.UTF8, MediaTypeNames.Application.Json); reqMsg.Headers.TryAddWithoutValidation("Accept", "application/json"); HttpResponseMessage response = await httpClient.SendAsync(reqMsg).ConfigureAwait(false); if (response.IsSuccessStatusCode) { string respStr = await response.Content.ReadAsStringAsync().ConfigureAwait(false); ret = JanusMessageResp.FromJson(respStr); if (ret.CheckReturnCode("ack")) { // Some messages are asynchronous and completed with an event if (_MessageDetails) m_log.Debug($"{LogHeader} SendToJanus: ack response {respStr}"); // if (_MessageDetails) DebugLog("{0} SendToJanus: ack response {1}", LogHeader, respStr); /* if (_OutstandingRequests.TryGetValue(pReq.TransactionId, out OutstandingRequest outstandingRequest)) { ret = await outstandingRequest.TaskCompletionSource.Task; _OutstandingRequests.Remove(pReq.TransactionId); } // If there is no OutstandingRequest, the request was not waiting for an event or already processed */ // Wait on the local TaskCompletionSource instead of re-reading the dictionary. // This avoids a race where the long-poll thread already removed the request. ret = await outReq.TaskCompletionSource.Task; } else { // If the response is not an ack, that means a synchronous request/response so return the response _= _OutstandingRequests.TryRemove(pReq.TransactionId, out _); if (_MessageDetails) m_log.Debug($"{LogHeader} SendToJanus: response {respStr}"); // if (_MessageDetails) DebugLog("{0} SendToJanus: response {1}", LogHeader, respStr); } } else { m_log.Error($"{LogHeader} SendToJanus: response not successful {response}"); _= _OutstandingRequests.TryRemove(pReq.TransactionId, out _); } } catch (Exception e) { m_log.ErrorFormat("{0} SendToJanus: exception {1}", LogHeader, e.Message); _= _OutstandingRequests.TryRemove(pReq.TransactionId, out _); } finally { _= _OutstandingRequests.TryRemove(pReq.TransactionId, out _); } return ret; } /// /// Send a request to the Janus server but we just return the response and don't wait for any /// event or anything. /// There are some requests that are just fire-and-forget. /// /// /// private async Task SendToJanusNoWait(JanusMessageReq pReq, string pURI) { JanusMessageResp ret = new(); AddJanusHeaders(pReq); try { HttpClient httpClient = WebUtil.GetNewGlobalHttpClient(30000); HttpRequestMessage reqMsg = new(HttpMethod.Post, pURI); string reqStr = pReq.ToJson(); reqMsg.Content = new StringContent(reqStr, System.Text.Encoding.UTF8, MediaTypeNames.Application.Json); reqMsg.Headers.TryAddWithoutValidation("Accept", "application/json"); HttpResponseMessage response = await httpClient.SendAsync(reqMsg).ConfigureAwait(false); string respStr = await response.Content.ReadAsStringAsync().ConfigureAwait(false); ret = JanusMessageResp.FromJson(respStr); } catch (Exception e) { m_log.Error($"{LogHeader} SendToJanusNoWait: exception {e.Message}"); } return ret; } private async Task SendToJanusNoWait(JanusMessageReq pReq) { return await SendToJanusNoWait(pReq, SessionUri); } // There are various headers that are in most Janus requests. Add them here. private void AddJanusHeaders(JanusMessageReq pReq, bool admin = false) { // Authentication token if(admin) { if (!string.IsNullOrEmpty(_JanusAdminToken)) { pReq.AddAdminToken(_JanusAdminToken); } } else { if (!string.IsNullOrEmpty(_JanusAPIToken)) { pReq.AddAPIToken(_JanusAPIToken); } } // Transaction ID that matches responses to requests if (string.IsNullOrEmpty(pReq.TransactionId)) { pReq.TransactionId = UUID.Random().ToString(); } // The following two are required for the WebSocket interface. They are optional for the // HTTP interface since the session and plugin handle are in the URL. // SessionId is added to the message if not already there if (!admin && !pReq.hasSessionId && !string.IsNullOrEmpty(SessionId)) { pReq.AddSessionId(SessionId); } // HandleId connects to the plugin if (!admin && !pReq.hasHandleId && !string.IsNullOrEmpty(PluginId)) { pReq.AddHandleId(PluginId); } } bool TryGetOutstandingRequest(string pTransactionId, out OutstandingRequest pOutstandingRequest) { if (string.IsNullOrEmpty(pTransactionId)) { pOutstandingRequest = null; return false; } if (_OutstandingRequests.TryGetValue(pTransactionId, out pOutstandingRequest)) return true; pOutstandingRequest = null; return false; } public Task SendToJanusAdmin(JanusMessageReq pReq) { return SendToJanus(pReq, _JanusAdminURI, true); } public Task GetFromJanus() { return GetFromJanus(_JanusServerURI); } /// /// Do a GET to the Janus server and return the response. /// If the response is an HTTP error, we return fake JanusMessageResp with the error. /// /// /// public async Task GetFromJanus(string pURI, int timeout = 30000) { if (!string.IsNullOrEmpty(_JanusAPIToken)) { pURI += "?apisecret=" + _JanusAPIToken; } JanusMessageResp ret = null; try { // m_log.DebugFormat("{0} GetFromJanus: URI = \"{1}\"", LogHeader, pURI); //HttpClient httpClient = WebUtil.GetNewGlobalHttpClient(timeout); HttpClient httpClient = WebUtil.GetGlobalNoRedirHttpClient(timeout, 1024 * 1024); HttpRequestMessage reqMsg = new HttpRequestMessage(HttpMethod.Get, pURI); reqMsg.Headers.TryAddWithoutValidation("Accept", "application/json"); HttpResponseMessage response = null; try { response = await httpClient.SendAsync(reqMsg).ConfigureAwait(false); if (response is not null && response.IsSuccessStatusCode) { string respStr = await response.Content.ReadAsStringAsync().ConfigureAwait(false); ret = JanusMessageResp.FromJson(respStr); // m_log.DebugFormat("{0} GetFromJanus: response {1}", LogHeader, respStr); } else { ErrorResp eResp = new("GETERROR"); // Add the sessionId so the proper session can be shut down eResp.AddSessionId(SessionId); if (response is not null) { eResp.SetError((int)response.StatusCode, response.ReasonPhrase); m_log.Error($"{LogHeader} GetFromJanus: response not successful {response}"); } else { eResp.SetError(0, "Connection refused"); m_log.Error($"{LogHeader} GetFromJanus: response not successful"); } ret = eResp; } } catch (TaskCanceledException e) { if (_MessageDetails) m_log.Debug($"{LogHeader} GetFromJanus: task canceled: {e.Message}"); ErrorResp eResp = new("GETERROR"); eResp.SetError(499, "Task canceled"); ret = eResp; } catch (Exception e) { m_log.Error($"{LogHeader} GetFromJanus: exception {e.Message}"); ErrorResp eResp = new("GETERROR"); eResp.SetError(400, "Exception: " + e.Message); ret = eResp; } } catch (Exception e) { m_log.Error($"{LogHeader} GetFromJanus: exception ", e); ErrorResp eResp = new("GETERROR"); eResp.SetError(400, "Exception: " + e.Message); ret = eResp; } return ret; } // ==================================================================== public delegate void JanusEventHandler(EventResp pResp); // Not all the events are used. CS0067 is to suppress the warning that the event is not used. #pragma warning disable CS0067,CS0414 public event JanusEventHandler OnKeepAlive; public event JanusEventHandler OnServerInfo; public event JanusEventHandler OnTrickle; public event JanusEventHandler OnHangup; public event JanusEventHandler OnDetached; public event JanusEventHandler OnError; public event JanusEventHandler OnEvent; public event JanusEventHandler OnMessage; public event JanusEventHandler OnJoined; public event JanusEventHandler OnLeaving; public event JanusEventHandler OnDisconnect; #pragma warning restore CS0067,CS0414 public void ClearEventSubscriptions() { OnKeepAlive = null; OnServerInfo = null; OnTrickle = null; OnHangup = null; OnDetached = null; OnError = null; OnEvent = null; OnMessage = null; OnJoined = null; OnLeaving = null; OnDisconnect = null; } // ==================================================================== /// /// In the REST API, events are returned by a long poll. This /// starts the poll and calls the registed event handler when /// an event is received. /// private void StartLongPoll() { bool running = true; m_log.Debug($"{LogHeader} EventLongPoll"); // DebugLog($"{LogHeader} EventLongPoll"); Task.Run(async () => { while (running && IsConnected) { try { JanusMessageResp resp = await GetFromJanus(SessionUri, 60000).ConfigureAwait(false); if (resp is not null) { //_ = Task.Run(() => { EventResp eventResp = new EventResp(resp); switch (resp.ReturnCode) { case "keepalive": // These should happen every 30 seconds // m_log.DebugFormat("{0} EventLongPoll: keepalive {1}", LogHeader, resp.ToString()); break; case "server_info": // Just info on the Janus instance m_log.Debug($"{LogHeader} EventLongPoll: server_info {resp}"); break; case "ack": // 'ack' says the request was received and an event will follow if (_MessageDetails) m_log.Debug($"{LogHeader} EventLongPoll: ack {resp}"); break; case "success": // success is a sync response that says the request was completed if (_MessageDetails) m_log.Debug($"{LogHeader} EventLongPoll: success {resp}"); break; case "trickle": // got a trickle ICE candidate from Janus // this is for reverse communication from Janus to the client and we don't do that if (_MessageDetails) m_log.Debug($"{LogHeader} EventLongPoll: trickle {resp}"); OnTrickle?.Invoke(eventResp); break; case "webrtcup": // ICE and DTLS succeeded, and so Janus correctly established a PeerConnection with the user/application; // m_log.DebugFormat("{0} EventLongPoll: webrtcup {1}", LogHeader, resp.ToString()); string webrtcupSessionId = ExtractLogId(resp, "session_id"); string webrtcupSender = ExtractLogId(resp, "sender"); m_log.DebugFormat("{0} EventLongPoll: webrtcup session_id={1}, sender={2}", LogHeader, string.IsNullOrEmpty(webrtcupSessionId) ? "" : webrtcupSessionId, string.IsNullOrEmpty(webrtcupSender) ? "" : webrtcupSender); break; case "hangup": // The PeerConnection was closed, either by the user/application or by Janus itself; // If one is in the room, when a "hangup" event happens, it means that the user left the room. // m_log.DebugFormat("{0} EventLongPoll: hangup {1}", LogHeader, resp.ToString()); m_log.DebugFormat("{0} EventLongPoll: hangup session_id={1}, sender={2}, reason={3}, tx={4}", LogHeader, ExtractLogId(resp, "session_id"), ExtractLogId(resp, "sender"), resp.RawBody.TryGetString("reason", out string hangupReason) ? hangupReason : string.Empty, FormatTransactionId(resp.TransactionId)); OnHangup?.Invoke(eventResp); break; case "detached": // a plugin asked the core to detach one of our handles // m_log.DebugFormat("{0} EventLongPoll: event {1}", LogHeader, resp.ToString()); m_log.DebugFormat("{0} EventLongPoll: detached session_id={1}, sender={2}, tx={3}", LogHeader, ExtractLogId(resp, "session_id"), ExtractLogId(resp, "sender"), FormatTransactionId(resp.TransactionId)); OnDetached?.Invoke(eventResp); break; case "media": // Janus is receiving (receiving: true/false) audio/video (type: "audio/video") on this PeerConnection; // m_log.DebugFormat("{0} EventLongPoll: media {1}", LogHeader, resp.ToString()); m_log.DebugFormat("{0} EventLongPoll: media session_id={1}, sender={2}, tx={3}", LogHeader, ExtractLogId(resp, "session_id"), ExtractLogId(resp, "sender"), FormatTransactionId(resp.TransactionId)); break; case "slowlink": // Janus detected a slowlink (uplink: true/false) on this PeerConnection; // m_log.DebugFormat("{0} EventLongPoll: slowlink {1}", LogHeader, resp.ToString()); m_log.DebugFormat("{0} EventLongPoll: slowlink session_id={1}, sender={2}, tx={3}", LogHeader, ExtractLogId(resp, "session_id"), ExtractLogId(resp, "sender"), FormatTransactionId(resp.TransactionId)); break; case "error": m_log.DebugFormat("{0} EventLongPoll: error {1}", LogHeader, resp.ToString()); if (TryGetOutstandingRequest(resp.TransactionId, out OutstandingRequest outstandingRequest)) { outstandingRequest.TaskCompletionSource.SetResult(resp); } else { OnError?.Invoke(eventResp); m_log.Error($"{LogHeader} EventLongPoll: error with no transaction. {resp}"); } break; case "event": if (_MessageDetails) m_log.Debug($"{LogHeader} EventLongPoll: event {resp}"); if (TryGetOutstandingRequest(resp.TransactionId, out OutstandingRequest outstandingRequest2)) { // Someone is waiting for this event outstandingRequest2.TaskCompletionSource.SetResult(resp); } else { m_log.ErrorFormat("{0} EventLongPoll: event no outstanding request {1}", LogHeader, resp.ToString()); // Janus often pushes plugin events without a transaction id (normal async flow). // Keep unknown transaction ids visible, but do not treat missing transaction ids as errors. if (string.IsNullOrEmpty(resp.TransactionId)) { m_log.DebugFormat("{0} EventLongPoll: async event with no transaction {1}", LogHeader, resp.ToString()); // if (_MessageDetails) DebugLog("{0} EventLongPoll: async event with no transaction {1}", LogHeader, resp.ToString()); } else { m_log.WarnFormat("{0} EventLongPoll: event with unknown transaction", LogHeader); if (m_log.IsDebugEnabled) m_log.DebugFormat("{0} EventLongPoll: unknown transaction detail {1}", LogHeader, resp.ToString()); } OnEvent?.Invoke(eventResp); } break; case "message": m_log.DebugFormat("{0} EventLongPoll: message {1}", LogHeader, resp.ToString()); OnMessage?.Invoke(eventResp); break; case "timeout": // Events for the audio bridge m_log.DebugFormat("{0} EventLongPoll: timeout {1}", LogHeader, resp.ToString()); break; case "joined": // Events for the audio bridge OnJoined?.Invoke(eventResp); m_log.DebugFormat("{0} EventLongPoll: joined {1}", LogHeader, resp.ToString()); break; case "leaving": // Events for the audio bridge OnLeaving?.Invoke(eventResp); m_log.DebugFormat("{0} EventLongPoll: leaving {1}", LogHeader, resp.ToString()); break; case "GETERROR": // Special error response from the GET ErrorResp errorResp = new(resp); switch (errorResp.errorCode) { case 404: // "Not found" means there is a Janus server but the session is gone m_log.ErrorFormat("{0} EventLongPoll: GETERROR Not Found. URI={1}: {2}", LogHeader, SessionUri, resp.ToString()); break; case 400: // "Bad request" means the session is gone m_log.ErrorFormat("{0} EventLongPoll: Bad Request. URI={1}: {2}", LogHeader, SessionUri, resp.ToString()); break; case 499: // "Task canceled" means the long poll was canceled if (_MessageDetails) m_log.DebugFormat("{0} EventLongPoll: Task canceled. URI={1}", LogHeader, SessionUri); break; default: m_log.DebugFormat("{0} EventLongPoll: unknown response. URI={1}: {2}", LogHeader, SessionUri, resp.ToString()); break; } // This will cause the long poll to exit running = false; OnDisconnect?.Invoke(eventResp); break; default: m_log.DebugFormat("{0} EventLongPoll: unknown response {1}", LogHeader, resp.ToString()); break; } } // ); } else { m_log.ErrorFormat("{0} EventLongPoll: failed. Response is null", LogHeader); } } catch (Exception e) { // This will cause the long poll to exit running = false; m_log.ErrorFormat("{0} EventLongPoll: exception {1}", LogHeader, e); } } if (_MessageDetails) m_log.InfoFormat("{0} EventLongPoll: Exiting long poll loop", LogHeader); }); } private static string ExtractLogId(JanusMessageResp resp, string key) { if (resp?.RawBody is null || !resp.RawBody.TryGetValue(key, out OSD value) || value is null) return string.Empty; try { return value.Type switch { OSDType.Integer or OSDType.Binary or OSDType.Array => value.AsLong().ToString(), _ => value.AsString(), }; } catch { return value.AsString(); } } private static string FormatTransactionId(string transactionId) { return string.IsNullOrEmpty(transactionId) ? "" : transactionId; } } }