Files
opensim/OpenSim/Addons/os-webrtc-janus/Janus/WebRtcJanusService.cs
2026-02-22 20:15:34 +00:00

466 lines
21 KiB
C#

/*
* 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.Reflection;
using System.Threading.Tasks;
using OpenSim.Framework;
using OpenSim.Services.Base;
using OpenMetaverse.StructuredData;
using OpenMetaverse;
using Nini.Config;
using log4net;
namespace osWebRtcVoice
{
public class WebRtcJanusService : ServiceBase, IWebRtcVoiceService
{
private static readonly ILog _log = LogManager.GetLogger(MethodBase.GetCurrentMethod().DeclaringType);
private static readonly string LogHeader = "[JANUS WEBRTC SERVICE]";
private readonly IConfigSource _Config;
private bool _Enabled = false;
private string _JanusServerURI = String.Empty;
private string _JanusAPIToken = String.Empty;
private string _JanusAdminURI = String.Empty;
private string _JanusAdminToken = String.Empty;
private bool _MessageDetails = false;
// An extra "viewer session" that is created initially. Used to verify the service
// is working and for a handle for the console commands.
private JanusViewerSession _ViewerSession;
public WebRtcJanusService(IConfigSource pConfig) : base(pConfig)
{
Assembly assembly = Assembly.GetExecutingAssembly();
string version = assembly.GetName().Version?.ToString() ?? "unknown";
_log.DebugFormat("{0} WebRtcJanusService version {1}", LogHeader, version);
_Config = pConfig;
IConfig webRtcVoiceConfig = _Config.Configs["WebRtcVoice"];
if (webRtcVoiceConfig is not null)
{
_Enabled = webRtcVoiceConfig.GetBoolean("Enabled", false);
IConfig janusConfig = _Config.Configs["JanusWebRtcVoice"];
if (_Enabled && janusConfig is not null)
{
_JanusServerURI = janusConfig.GetString("JanusGatewayURI", String.Empty);
_JanusAPIToken = janusConfig.GetString("APIToken", String.Empty);
_JanusAdminURI = janusConfig.GetString("JanusGatewayAdminURI", String.Empty);
_JanusAdminToken = janusConfig.GetString("AdminAPIToken", String.Empty);
// Debugging options
_MessageDetails = janusConfig.GetBoolean("MessageDetails", false);
if (String.IsNullOrEmpty(_JanusServerURI) || String.IsNullOrEmpty(_JanusAPIToken) ||
String.IsNullOrEmpty(_JanusAdminURI) || String.IsNullOrEmpty(_JanusAdminToken))
{
_log.ErrorFormat("{0} JanusWebRtcVoice configuration section missing required fields", LogHeader);
_Enabled = false;
}
if (_Enabled)
{
_log.DebugFormat("{0} Enabled", LogHeader);
StartConnectionToJanus();
RegisterConsoleCommands();
}
}
else
{
_log.ErrorFormat("{0} No JanusWebRtcVoice configuration section", LogHeader);
_Enabled = false;
}
}
else
{
_log.ErrorFormat("{0} No WebRtcVoice configuration section", LogHeader);
_Enabled = false;
}
}
// Start a thread to do the connection to the Janus server.
// Here an initial session is created and then a handle to the audio bridge plugin
// is created for the console commands. Since webrtc PeerConnections that are created
// my Janus are per-session, the other sessions will be created by the viewer requests.
private void StartConnectionToJanus()
{
_log.DebugFormat("{0} StartConnectionToJanus", LogHeader);
Task.Run(async () =>
{
_ViewerSession = new JanusViewerSession(this);
await ConnectToSessionAndAudioBridge(_ViewerSession);
});
}
private async Task ConnectToSessionAndAudioBridge(JanusViewerSession pViewerSession)
{
JanusSession janusSession = new JanusSession(_JanusServerURI, _JanusAPIToken, _JanusAdminURI, _JanusAdminToken, _MessageDetails);
if (await janusSession.CreateSession())
{
_log.DebugFormat("{0} JanusSession created", LogHeader);
janusSession.OnDisconnect += Handle_Hangup;
// Once the session is created, create a handle to the plugin for rooms
JanusAudioBridge audioBridge = new JanusAudioBridge(janusSession);
janusSession.AddPlugin(audioBridge);
pViewerSession.VoiceServiceSessionId = janusSession.SessionId;
pViewerSession.Session = janusSession;
pViewerSession.AudioBridge = audioBridge;
janusSession.OnHangup += Handle_Hangup;
if (await audioBridge.Activate(_Config))
{
_log.DebugFormat("{0} AudioBridgePluginHandle created", LogHeader);
// Requests through the capabilities will create rooms
}
else
{
_log.ErrorFormat("{0} JanusPluginHandle not created", LogHeader);
}
}
else
{
_log.ErrorFormat("{0} JanusSession not created", LogHeader);
}
}
private void Handle_Hangup(EventResp pResp)
{
if (pResp is not null)
{
var sessionId = pResp.sessionId;
_log.DebugFormat("{0} Handle_Hangup: {1}, sessionId={2}", LogHeader, pResp.RawBody.ToString(), sessionId);
if (VoiceViewerSession.TryGetViewerSessionByVSSessionId(sessionId, out IVoiceViewerSession viewerSession))
{
// There is a viewer session associated with this session
DisconnectViewerSession(viewerSession as JanusViewerSession);
}
else
{
_log.DebugFormat("{0} Handle_Hangup: no session found. SessionId={1}", LogHeader, sessionId);
}
}
}
// Disconnect the viewer session. This is called when the viewer logs out or hangs up.
private void DisconnectViewerSession(JanusViewerSession pViewerSession)
{
if (pViewerSession is not null)
{
Task.Run(() =>
{
VoiceViewerSession.RemoveViewerSession(pViewerSession.ViewerSessionID);
// No need to wait for the session to be shutdown
_ = pViewerSession.Shutdown();
});
}
}
// The pRequest parameter is a straight conversion of the JSON request from the client.
// This is the logic that takes the client's request and converts it into
// operations on rooms in the audio bridge.
// IWebRtcVoiceService.ProvisionVoiceAccountRequest
public async Task<OSDMap> ProvisionVoiceAccountRequest(IVoiceViewerSession pSession, OSDMap pRequest, UUID pUserID, UUID pSceneID)
{
OSDMap ret = null;
string errorMsg = null;
JanusViewerSession viewerSession = pSession as JanusViewerSession;
if (viewerSession is not null)
{
if (viewerSession.Session is null)
{
// This is a new session so we must create a new session and handle to the audio bridge
await ConnectToSessionAndAudioBridge(viewerSession);
}
// TODO: need to keep count of users in a room to know when to close a room
bool isLogout = pRequest.ContainsKey("logout") && pRequest["logout"].AsBoolean();
if (isLogout)
{
// The client is logging out. Exit the room.
if (viewerSession.Room is not null)
{
await viewerSession.Room.LeaveRoom(viewerSession);
viewerSession.Room = null;
}
return new OSDMap
{
{ "response", "closed" }
};
}
// Get the parameters that select the room
// To get here, voice_server_type has already been checked to be 'webrtc' and channel_type='local'
int parcel_local_id = pRequest.ContainsKey("parcel_local_id") ? pRequest["parcel_local_id"].AsInteger() : JanusAudioBridge.REGION_ROOM_ID;
string channel_id = pRequest.ContainsKey("channel_id") ? pRequest["channel_id"].AsString() : String.Empty;
string channel_credentials = pRequest.ContainsKey("credentials") ? pRequest["credentials"].AsString() : String.Empty;
string channel_type = pRequest["channel_type"].AsString();
bool isSpatial = channel_type == "local";
string voice_server_type = pRequest["voice_server_type"].AsString();
_log.DebugFormat("{0} ProvisionVoiceAccountRequest: parcel_id={1} channel_id={2} channel_type={3} voice_server_type={4}", LogHeader, parcel_local_id, channel_id, channel_type, voice_server_type);
if (pRequest.ContainsKey("jsep") && pRequest["jsep"] is OSDMap jsep)
{
// The jsep is the SDP from the client. This is the client's request to connect to the audio bridge.
string jsepType = jsep["type"].AsString();
string jsepSdp = jsep["sdp"].AsString();
if (jsepType == "offer")
{
// The client is sending an offer. Find the right room and join it.
// _log.DebugFormat("{0} ProvisionVoiceAccountRequest: jsep type={1} sdp={2}", LogHeader, jsepType, jsepSdp);
viewerSession.Room = await viewerSession.AudioBridge.SelectRoom(pSceneID.ToString(),
channel_type, isSpatial, parcel_local_id, channel_id);
if (viewerSession.Room is null)
{
errorMsg = "room selection failed";
_log.ErrorFormat("{0} ProvisionVoiceAccountRequest: room selection failed", LogHeader);
}
else {
viewerSession.Offer = jsepSdp;
viewerSession.OfferOrig = jsepSdp;
viewerSession.AgentId = pUserID;
if (await viewerSession.Room.JoinRoom(viewerSession))
{
ret = new OSDMap
{
{ "jsep", viewerSession.Answer },
{ "viewer_session", viewerSession.ViewerSessionID }
};
}
else
{
errorMsg = "JoinRoom failed";
_log.ErrorFormat("{0} ProvisionVoiceAccountRequest: JoinRoom failed", LogHeader);
}
}
}
else
{
errorMsg = "jsep type not offer";
_log.ErrorFormat("{0} ProvisionVoiceAccountRequest: jsep type={1} not offer", LogHeader, jsepType);
}
}
else
{
errorMsg = "no jsep";
_log.DebugFormat("{0} ProvisionVoiceAccountRequest: no jsep. req={1}", LogHeader, pRequest.ToString());
}
}
else
{
errorMsg = "viewersession not JanusViewerSession";
_log.ErrorFormat("{0} ProvisionVoiceAccountRequest: viewersession not JanusViewerSession", LogHeader);
}
if (!String.IsNullOrEmpty(errorMsg) && ret is null)
{
// The provision failed so build an error messgage to return
ret = new OSDMap
{
{ "response", "failed" },
{ "error", errorMsg }
};
}
return ret;
}
// IWebRtcVoiceService.VoiceAccountBalanceRequest
public async Task<OSDMap> VoiceSignalingRequest(IVoiceViewerSession pSession, OSDMap pRequest, UUID pUserID, UUID pSceneID)
{
OSDMap ret = null;
JanusViewerSession viewerSession = pSession as JanusViewerSession;
JanusMessageResp resp = null;
if (viewerSession is not null)
{
// The request should be an array of candidates
if (pRequest.ContainsKey("candidate") && pRequest["candidate"] is OSDMap candidate)
{
if (candidate.ContainsKey("completed") && candidate["completed"].AsBoolean())
{
// The client has finished sending candidates
resp = await viewerSession.Session.TrickleCompleted(viewerSession);
_log.DebugFormat("{0} VoiceSignalingRequest: candidate completed", LogHeader);
}
else
{
}
}
else if (pRequest.ContainsKey("candidates") && pRequest["candidates"] is OSDArray candidates)
{
OSDArray candidatesArray = new OSDArray();
foreach (OSDMap cand in candidates)
{
candidatesArray.Add(new OSDMap() {
{ "candidate", cand["candidate"].AsString() },
{ "sdpMid", cand["sdpMid"].AsString() },
{ "sdpMLineIndex", cand["sdpMLineIndex"].AsLong() }
});
}
resp = await viewerSession.Session.TrickleCandidates(viewerSession, candidatesArray);
_log.DebugFormat("{0} VoiceSignalingRequest: {1} candidates", LogHeader, candidatesArray.Count);
}
else
{
_log.ErrorFormat("{0} VoiceSignalingRequest: no 'candidate' or 'candidates'", LogHeader);
}
}
if (resp is null)
{
_log.ErrorFormat("{0} VoiceSignalingRequest: no response so returning error", LogHeader);
ret = new OSDMap
{
{ "response", "error" }
};
}
else
{
ret = resp.RawBody;
}
return ret;
}
// This module should not be invoked with this signature
// IWebRtcVoiceService.ProvisionVoiceAccountRequest
public Task<OSDMap> ProvisionVoiceAccountRequest(OSDMap pRequest, UUID pUserID, UUID pSceneID)
{
throw new NotImplementedException();
}
// This module should not be invoked with this signature
// IWebRtcVoiceService.VoiceSignalingRequest
public Task<OSDMap> VoiceSignalingRequest(OSDMap pRequest, UUID pUserID, UUID pSceneID)
{
throw new NotImplementedException();
}
// The viewer session object holds all the connection information to Janus.
// IWebRtcVoiceService.CreateViewerSession
public IVoiceViewerSession CreateViewerSession(OSDMap pRequest, UUID pUserID, UUID pSceneID)
{
return new JanusViewerSession(this)
{
AgentId = pUserID,
RegionId = pSceneID
};
}
// ======================================================================================================
private void RegisterConsoleCommands()
{
if (_Enabled) {
MainConsole.Instance.Commands.AddCommand("Webrtc", false, "janus info",
"janus info",
"Show Janus server information",
HandleJanusInfo);
MainConsole.Instance.Commands.AddCommand("Webrtc", false, "janus list rooms",
"janus list rooms",
"List the rooms on the Janus server",
HandleJanusListRooms);
// List rooms
// List participants in a room
}
}
private async void HandleJanusInfo(string module, string[] cmdparms)
{
if (_ViewerSession is not null && _ViewerSession.Session is not null)
{
WriteOut("{0} Janus session: {1}", LogHeader, _ViewerSession.Session.SessionId);
string infoURI = _ViewerSession.Session.JanusServerURI + "/info";
var resp = await _ViewerSession.Session.GetFromJanus(infoURI);
if (resp is not null)
MainConsole.Instance.Output(resp.ToJson());
}
}
private async void HandleJanusListRooms(string module, string[] cmdparms)
{
if (_ViewerSession is not null && _ViewerSession.Session is not null && _ViewerSession.AudioBridge is not null)
{
var ab = _ViewerSession.AudioBridge;
var resp = await ab.SendAudioBridgeMsg(new AudioBridgeListRoomsReq());
if (resp is not null && resp.isSuccess)
{
if (resp.PluginRespData.TryGetValue("list", out OSD list))
{
MainConsole.Instance.Output("");
MainConsole.Instance.Output(
" {0,10} {1,15} {2,5} {3,10} {4,7} {5,7}",
"Room", "Description", "Num", "SampleRate", "Spatial", "Recording");
foreach (OSDMap room in list as OSDArray)
{
MainConsole.Instance.Output(
" {0,10} {1,15} {2,5} {3,10} {4,7} {5,7}",
room["room"], room["description"], room["num_participants"],
room["sampling_rate"], room["spatial_audio"], room["record"]);
var participantResp = await ab.SendAudioBridgeMsg(new AudioBridgeListParticipantsReq(room["room"].AsInteger()));
if (participantResp is not null && participantResp.AudioBridgeReturnCode == "participants")
{
if (participantResp.PluginRespData.TryGetValue("participants", out OSD participants))
{
foreach (OSDMap participant in participants as OSDArray)
{
MainConsole.Instance.Output(" {0}/{1},muted={2},talking={3},pos={4}",
participant["id"].AsLong(), participant["display"], participant["muted"],
participant["talking"], participant["spatial_position"]);
}
}
}
}
}
else
{
MainConsole.Instance.Output("No rooms");
}
}
else
{
MainConsole.Instance.Output("Failed to get room list");
}
}
}
private void WriteOut(string msg, params object[] args)
{
// m_log.InfoFormat(msg, args);
MainConsole.Instance.Output(msg, args);
}
}
}