From 674c3a0424ddde5281bb8d6b87f97e8ca71e237f Mon Sep 17 00:00:00 2001 From: UbitUmarov Date: Sun, 22 Feb 2026 17:11:34 +0000 Subject: [PATCH] add Robert Adams os-webrtc-janus experimental module. License changed to same BSD as rest of OpenSimulator by Robert, for OpenSimulator use --- .../Addons/os-webrtc-janus/Janus/BHasher.cs | 428 ++++++++++++ .../os-webrtc-janus/Janus/JanusAudioBridge.cs | 251 +++++++ .../os-webrtc-janus/Janus/JanusMessages.cs | 638 +++++++++++++++++ .../os-webrtc-janus/Janus/JanusPlugin.cs | 161 +++++ .../Addons/os-webrtc-janus/Janus/JanusRoom.cs | 138 ++++ .../os-webrtc-janus/Janus/JanusSession.cs | 658 ++++++++++++++++++ .../Janus/JanusViewerSession.cs | 109 +++ .../Janus/WebRtcJanusService.cs | 465 +++++++++++++ OpenSim/Addons/os-webrtc-janus/LICENSE | 390 +++++++++++ OpenSim/Addons/os-webrtc-janus/README.md | 234 +++++++ .../WebRtcVoice/IVoiceViewerSession.cs | 54 ++ .../WebRtcVoice/IWebRtcVoiceService.cs | 58 ++ .../WebRtcVoice/VoiceViewerSession.cs | 132 ++++ .../WebRtcVoice/WebRtcVoiceServerConnector.cs | 172 +++++ .../WebRtcVoiceServiceConnector.cs | 218 ++++++ .../WebRtcVoiceRegionModule.cs | 597 ++++++++++++++++ .../WebRtcVoiceServiceModule.cs | 303 ++++++++ bin/config/os-webrtc-janus.ini | 29 + prebuild.xml | 190 +++++ 19 files changed, 5225 insertions(+) create mode 100644 OpenSim/Addons/os-webrtc-janus/Janus/BHasher.cs create mode 100644 OpenSim/Addons/os-webrtc-janus/Janus/JanusAudioBridge.cs create mode 100644 OpenSim/Addons/os-webrtc-janus/Janus/JanusMessages.cs create mode 100644 OpenSim/Addons/os-webrtc-janus/Janus/JanusPlugin.cs create mode 100644 OpenSim/Addons/os-webrtc-janus/Janus/JanusRoom.cs create mode 100644 OpenSim/Addons/os-webrtc-janus/Janus/JanusSession.cs create mode 100644 OpenSim/Addons/os-webrtc-janus/Janus/JanusViewerSession.cs create mode 100644 OpenSim/Addons/os-webrtc-janus/Janus/WebRtcJanusService.cs create mode 100644 OpenSim/Addons/os-webrtc-janus/LICENSE create mode 100644 OpenSim/Addons/os-webrtc-janus/README.md create mode 100644 OpenSim/Addons/os-webrtc-janus/WebRtcVoice/IVoiceViewerSession.cs create mode 100644 OpenSim/Addons/os-webrtc-janus/WebRtcVoice/IWebRtcVoiceService.cs create mode 100644 OpenSim/Addons/os-webrtc-janus/WebRtcVoice/VoiceViewerSession.cs create mode 100644 OpenSim/Addons/os-webrtc-janus/WebRtcVoice/WebRtcVoiceServerConnector.cs create mode 100644 OpenSim/Addons/os-webrtc-janus/WebRtcVoice/WebRtcVoiceServiceConnector.cs create mode 100644 OpenSim/Addons/os-webrtc-janus/WebRtcVoiceRegionModule/WebRtcVoiceRegionModule.cs create mode 100644 OpenSim/Addons/os-webrtc-janus/WebRtcVoiceServiceModule/WebRtcVoiceServiceModule.cs create mode 100644 bin/config/os-webrtc-janus.ini diff --git a/OpenSim/Addons/os-webrtc-janus/Janus/BHasher.cs b/OpenSim/Addons/os-webrtc-janus/Janus/BHasher.cs new file mode 100644 index 0000000000..fc10f48215 --- /dev/null +++ b/OpenSim/Addons/os-webrtc-janus/Janus/BHasher.cs @@ -0,0 +1,428 @@ +/* + * 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.Generic; +using System.Linq; +using System.Text; +using System.Security.Cryptography; + +namespace WebRtcVoice +{ + + // There are several different hashing systems ranging from int's to SHA versions. + // The model here is to create a hasher of the desired type, do Add's of things to + // hash, and complete with a Finish() to return a BHash that contains the hash value. + // Since some hash functions are incremental (doing Add's) while some are buffer + // oriented (create a hash of a byte buffer), the interface is built to cover both. + // Some optimizations are implemented internally (like not copying the buffer + // for buffer based hashers if Finish(bytes) is used). + // + // var hasher = new BHashSHA256(); + // BHash bHash = hasher.Finish(buffer); + // byte[] theHash = bHash.ToByte(); + // + // Note that BHash has IEquatable and IComparible so it can be used in Dictionaries + // and sorted Lists. + // + // The C# GetHashCode() method returns an int that is usually based on location. + // Signatures should really be at least 64 bits so these routines generate + // ulong's for hashes and fold them to make the int for GetHashCode(). + + // Create a BHasher, do a bunch of 'Add's, then Finish(). + public interface IBHasher { + // Create a new object implementing BHasher, then Add values to be hashed + void Add(byte c); + void Add(ushort c); + void Add(uint c); + void Add(ulong c); + void Add(float c); + void Add(byte[] c, int offset, int len); + void Add(string c); + void Add(BHash c); + + BHash Finish(); + // Finish and add byte array. + // If no Add's before, can do the hashing without copying the byte array + BHash Finish(byte[] c); + BHash Finish(byte[] c, int offset, int len); + // Get the hash code after doing a Finish() + BHash Hash(); + } + + // ====================================================================== + // BHasher computes a BHash which holds the hash value after Finish() is called. + public abstract class BHash : IEquatable, IComparable { + public abstract override string ToString(); + public abstract byte[] ToBytes(); + public abstract ulong ToULong(); // returns the hash of the hash if not int based hash + public abstract bool Equals(BHash other); + public abstract int CompareTo(BHash obj); + // public abstract int Compare(BHash x, BHash y); // TODO: do we need this function? + public abstract override int GetHashCode(); // to match the C# standard hash function + } + + // A hash that is an UInt64 + public class BHashULong : BHash { + protected ulong _hash; + + public BHashULong() { + _hash = 5131; + } + public BHashULong(ulong initialHash) { + _hash = initialHash; + } + // the .NET GetHashCode uses an int. Make conversion easy. + public BHashULong(int initialHash) { + _hash = (ulong)initialHash; + } + public override string ToString() { + return _hash.ToString(); + } + public override byte[] ToBytes() { + return BitConverter.GetBytes(_hash); + } + public override ulong ToULong() { + return _hash; + } + public override bool Equals(BHash other) { + bool ret = false; + if (other != null) { + BHash bh = other as BHashULong; + if (bh != null) { + ret = _hash.Equals(bh.ToULong()); + } + } + return ret; + } + public override int CompareTo(BHash other) { + int ret = 1; + if (other != null) { + if (other is BHashULong bh) { + ret = _hash.CompareTo(bh.ToULong()); + } + } + return ret; + } + public override int GetHashCode() { + ulong upper = (_hash >> 32) & 0xffffffff; + ulong lower = _hash & 0xffffffff; + return (int)(upper ^ lower); + } + } + + // A hash that is an array of bytes + public class BHashBytes : BHash { + private readonly byte[] _hash; + + public BHashBytes() { + _hash = new byte[0]; + } + public BHashBytes(byte[] initialHash) { + _hash = initialHash; + } + public override string ToString() { + // BitConverter puts a hyphen between each byte. Remove them + return BitConverter.ToString(_hash).Replace("-", String.Empty); + } + public override byte[] ToBytes() { + return _hash; + } + public override ulong ToULong() { + return this.MakeHashCode(); + } + public override bool Equals(BHash other) { + bool ret = false; + if (other != null) { + BHash bh = other as BHashBytes; + if (bh != null) { + ret = _hash.Equals(bh.ToBytes()); + } + } + return ret; + } + public override int CompareTo(BHash other) { + int ret = 1; + if (other != null) { + BHash bh = other as BHashBytes; + if (bh != null) { + byte[] otherb = bh.ToBytes(); + if (_hash.Length != otherb.Length) { + ret = _hash.Length.CompareTo(otherb.Length); + } + else { + ret = 0; // start off assuming they are equal + for (int ii = 0; ii < _hash.Length; ii++) { + ret = _hash[ii].CompareTo(otherb[ii]); + if (ret != 0) break; + } + } + } + } + return ret; + } + public override int GetHashCode() + { + ulong hashhash = this.MakeHashCode(); + ulong upper = (hashhash >> 32 )& 0xffffffff; + ulong lower = hashhash & 0xffffffff; + return (int)(upper ^ lower); + } + public ulong MakeHashCode() { + ulong h = 5381; + for (int ii = 0; ii < _hash.Length; ii++) { + h = ((h << 5) + h) + (ulong)(_hash[ii]); + } + return h; + } + + } + + // ====================================================================== + // ====================================================================== + public abstract class BHasher : IBHasher + { + public BHasher() { + } + + public abstract void Add(byte c); + public abstract void Add(ushort c); + public abstract void Add(uint c); + public abstract void Add(ulong c); + public abstract void Add(float c); + public abstract void Add(byte[] c, int offset, int len); + public abstract void Add(string c); + public abstract void Add(BHash c); + public abstract BHash Finish(); + public abstract BHash Finish(byte[] c); + public abstract BHash Finish(byte[] c, int offset, int len); + public abstract BHash Hash(); + } + + // A hasher that builds up a buffer of bytes ('building') and then hashes over same + public abstract class BHasherBytes : BHasher { + protected byte[] building; + protected int buildingLoc; + protected int allocStep = 1024; + + public BHasherBytes() : base() { + building = new byte[allocStep]; + buildingLoc = 0; + } + + private byte[] oneByte = new byte[1]; + public override void Add(byte c) { + oneByte[0] = c; + AddBytes(oneByte, 0, 1); + } + + public override void Add(ushort c) { + byte[] bytes = BitConverter.GetBytes(c); + AddBytes(bytes, 0, bytes.Length); + } + + public override void Add(uint c) { + byte[] bytes = BitConverter.GetBytes(c); + AddBytes(bytes, 0, bytes.Length); + } + + public override void Add(ulong c) { + byte[] bytes = BitConverter.GetBytes(c); + AddBytes(bytes, 0, bytes.Length); + } + + public override void Add(float c) { + byte[] bytes = BitConverter.GetBytes(c); + AddBytes(bytes, 0, bytes.Length); + } + + public override void Add(byte[] c, int offset, int len) { + AddBytes(c, offset, len); + } + + public override void Add(string c) + { + byte[] bytes = Encoding.UTF8.GetBytes(c); + AddBytes(bytes, 0, bytes.Length); + } + + public override void Add(BHash c) { + byte[] bytes = c.ToBytes(); + AddBytes(bytes, 0, bytes.Length); + } + + // Implemented by derived class + // public abstract BHash Finish(); + + // Helper function for simple byte array + public override BHash Finish(byte[] c) { + return this.Finish(c, 0, c.Length); + } + + // Implemented by derived class + // public abstract BHash Finish(byte[] c, int offset, int len); + + // Implemented by derived class + // public abstract BHash Hash(); + + // Add the given number of bytes to the byte array being built + protected void AddBytes(byte[] addition, int offset, int len) { + // byte[] tempBytes = new byte[len]; // DEBUG DEBUG + // Array.Copy(addition, offset, tempBytes, 0, len); // DEBUG DEBUG + // System.Console.WriteLine(String.Format("AddBytes: offset={0}, len={1}, bytes={2}", // DEBUG DEBUG + // offset, len, BitConverter.ToString(tempBytes).Replace("-", String.Empty))); // DEBUG DEBUG + if (len < 0 || offset < 0 || addition == null) { + throw new ArgumentException(String.Format("BHasherBytes.AddBytes: Bad parameters. offset={0}, len={1}", + offset, len)); + } + if (offset + len > addition.Length) { + throw new ArgumentException(String.Format("BHasherBytes.AddBytes: addition parameters off end of array. addition.len={0}, offset={1}, len={2}", + addition.Length, offset, len)); + } + if (len > 0) { + if (buildingLoc + len > building.Length) { + // New data requires expanding the data buffer + byte[] newBuilding = new byte[buildingLoc + len + allocStep]; + Buffer.BlockCopy(building, 0, newBuilding, 0, buildingLoc); + building = newBuilding; + } + Buffer.BlockCopy(addition, offset, building, buildingLoc, len); + buildingLoc += len; + } + } + } + + // ====================================================================== + // ULong hash code taken from Meshmerizer + public class BHasherMdjb2 : BHasherBytes, IBHasher { + BHashULong hash = new BHashULong(); + + public BHasherMdjb2() : base() { + } + + public override BHash Finish() { + hash = new BHashULong(ComputeMdjb2Hash(building, 0, buildingLoc)); + return hash; + } + + public override BHash Finish(byte[] c, int offset, int len) { + if (building.Length > 0) { + AddBytes(c, offset, len); + hash = new BHashULong(ComputeMdjb2Hash(building, 0, buildingLoc)); + } + else { + // if no 'Add's were done, don't copy the input data + hash = new BHashULong(ComputeMdjb2Hash(c, offset, len)); + } + return hash; + } + + private ulong ComputeMdjb2Hash(byte[] c, int offset, int len) { + ulong h = 5381; + for (int ii = offset; ii < offset+len; ii++) { + h = ((h << 5) + h) + (ulong)(c[ii]); + } + return h; + } + + public override BHash Hash() { + return hash; + } + } + + // ====================================================================== + public class BHasherMD5 : BHasherBytes, IBHasher { + BHashBytes hash = new BHashBytes(); + + public BHasherMD5() : base() { + } + + public override BHash Finish() { + MD5 md5 = MD5.Create(); + hash = new BHashBytes(md5.ComputeHash(building, 0, buildingLoc)); + return hash; + } + + public override BHash Finish(byte[] c) { + return this.Finish(c, 0, c.Length); + } + + public override BHash Finish(byte[] c, int offset, int len) { + MD5 md5 = MD5.Create(); + if (building.Length > 0) { + AddBytes(c, offset, len); + hash = new BHashBytes(md5.ComputeHash(building, 0, buildingLoc)); + } + else { + // if no 'Add's were done, don't copy the input data + hash = new BHashBytes(md5.ComputeHash(c, offset, len)); + } + return hash; + } + + public override BHash Hash() { + return hash; + } + } + + // ====================================================================== + public class BHasherSHA256 : BHasherBytes, IBHasher { + BHashBytes hash = new BHashBytes(); + + public BHasherSHA256() : base() { + } + + public override BHash Finish() { + using (SHA256 SHA256 = SHA256.Create()) { + hash = new BHashBytes(SHA256.ComputeHash(building, 0, buildingLoc)); + } + return hash; + } + + public override BHash Finish(byte[] c) { + return this.Finish(c, 0, c.Length); + } + + public override BHash Finish(byte[] c, int offset, int len) { + using (SHA256 SHA256 = SHA256.Create()) { + if (buildingLoc > 0) { + AddBytes(c, offset, len); + hash = new BHashBytes(SHA256.ComputeHash(building, 0, buildingLoc)); + } + else { + // if no 'Add's were done, don't copy the input data + hash = new BHashBytes(SHA256.ComputeHash(c, offset, len)); + } + } + return hash; + } + + public override BHash Hash() { + return hash; + } + } +} diff --git a/OpenSim/Addons/os-webrtc-janus/Janus/JanusAudioBridge.cs b/OpenSim/Addons/os-webrtc-janus/Janus/JanusAudioBridge.cs new file mode 100644 index 0000000000..4885648b4a --- /dev/null +++ b/OpenSim/Addons/os-webrtc-janus/Janus/JanusAudioBridge.cs @@ -0,0 +1,251 @@ +/* + * 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.Generic; +using System.Reflection; +using System.Threading.Tasks; + +using log4net; + +namespace WebRtcVoice +{ + // Encapsulization of a Session to the Janus server + public class JanusAudioBridge : JanusPlugin + { + private static readonly ILog m_log = LogManager.GetLogger(MethodBase.GetCurrentMethod().DeclaringType); + private static readonly string LogHeader = "[JANUS AUDIO BRIDGE]"; + + // Wrapper around the session connection to Janus-gateway + public JanusAudioBridge(JanusSession pSession) : base(pSession, "janus.plugin.audiobridge") + { + // m_log.DebugFormat("{0} JanusAudioBridge constructor", LogHeader); + } + + public override void Dispose() + { + if (IsConnected) + { + // Close the handle + + } + base.Dispose(); + } + + public async Task SendAudioBridgeMsg(PluginMsgReq pMsg) + { + AudioBridgeResp ret = null; + try + { + ret = new AudioBridgeResp(await SendPluginMsg(pMsg)); + } + catch (Exception e) + { + m_log.ErrorFormat("{0} SendPluginMsg. Exception {1}", LogHeader, e); + } + return ret; + } + + /// + /// Create a room with the given criteria. This talks to Janus to create the room. + /// If the room with this RoomId already exists, just return it. + /// Janus could create and return the RoomId but this presumes that the Janus server + /// is only being used for our voice service. + /// + /// integer room ID to create + /// boolean on whether room will be spatial or non-spatial + /// added as "description" to the created room + /// + public async Task CreateRoom(int pRoomId, bool pSpatial, string pRoomDesc) + { + JanusRoom ret = null; + try + { + JanusMessageResp resp = await SendPluginMsg(new AudioBridgeCreateRoomReq(pRoomId, pSpatial, pRoomDesc)); + AudioBridgeResp abResp = new AudioBridgeResp(resp); + + m_log.DebugFormat("{0} CreateRoom. ReturnCode: {1}", LogHeader, abResp.AudioBridgeReturnCode); + switch (abResp.AudioBridgeReturnCode) + { + case "created": + ret = new JanusRoom(this, pRoomId); + break; + case "event": + if (abResp.AudioBridgeErrorCode == 486) + { + m_log.WarnFormat("{0} CreateRoom. Room {1} already exists. Reusing! {2}", LogHeader, pRoomId, abResp.ToString()); + // if room already exists, just use it + ret = new JanusRoom(this, pRoomId); + } + else + { + m_log.ErrorFormat("{0} CreateRoom. XX Room creation failed: {1}", LogHeader, abResp.ToString()); + } + break; + default: + m_log.ErrorFormat("{0} CreateRoom. YY Room creation failed: {1}", LogHeader, abResp.ToString()); + break; + } + } + catch (Exception e) + { + m_log.ErrorFormat("{0} CreateRoom. Exception {1}", LogHeader, e); + } + return ret; + } + + public async Task DestroyRoom(JanusRoom janusRoom) + { + bool ret = false; + try + { + JanusMessageResp resp = await SendPluginMsg(new AudioBridgeDestroyRoomReq(janusRoom.RoomId)); + ret = true; + } + catch (Exception e) + { + m_log.ErrorFormat("{0} DestroyRoom. Exception {1}", LogHeader, e); + } + return ret; + } + + // Constant used to denote that this is a spatial audio room for the region (as opposed to parcels) + public const int REGION_ROOM_ID = -999; + private Dictionary _rooms = new Dictionary(); + + // Calculate a room number for the given parameters. The room number is a hash of the parameters. + // The attempt is to deterministicly create a room number so all regions will generate the + // same room number across sessions and across the grid. + // getHashCode() is not deterministic across sessions. + public static int CalcRoomNumber(string pRegionId, string pChannelType, int pParcelLocalID, string pChannelID) + { + var hasher = new BHasherMdjb2(); + // If there is a channel specified it must be group + switch (pChannelType) + { + case "local": + // A "local" channel is unique to the region and parcel + hasher.Add(pRegionId); + hasher.Add(pChannelType); + hasher.Add(pParcelLocalID); + break; + case "multiagent": + // A "multiagent" channel is unique to the grid + // should add a GridId here + hasher.Add(pChannelID); + hasher.Add(pChannelType); + break; + default: + throw new Exception("Unknown channel type: " + pChannelType); + } + var hashed = hasher.Finish(); + // The "Abs()" is because Janus room number must be a positive integer + // And note that this is the BHash.GetHashCode() and not Object.getHashCode(). + int roomNumber = Math.Abs(hashed.GetHashCode()); + return roomNumber; + } + public async Task SelectRoom(string pRegionId, string pChannelType, bool pSpatial, int pParcelLocalID, string pChannelID) + { + int roomNumber = CalcRoomNumber(pRegionId, pChannelType, pParcelLocalID, pChannelID); + + // Should be unique for the given use and channel type + m_log.DebugFormat("{0} SelectRoom: roomNumber={1}", LogHeader, roomNumber); + + // Check to see if the room has already been created + lock (_rooms) + { + if (_rooms.ContainsKey(roomNumber)) + { + return _rooms[roomNumber]; + } + } + + // The room doesn't exist. Create it. + string roomDesc = pRegionId + "/" + pChannelType + "/" + pParcelLocalID + "/" + pChannelID; + JanusRoom ret = await CreateRoom(roomNumber, pSpatial, roomDesc); + + JanusRoom existingRoom = null; + if (ret is not null) + { + lock (_rooms) + { + if (_rooms.ContainsKey(roomNumber)) + { + // If the room was created while we were waiting, + existingRoom = _rooms[roomNumber]; + } + else + { + // Our room is the first one created. Save it. + _rooms[roomNumber] = ret; + } + } + } + if (existingRoom is not null) + { + // The room we created was already created by someone else. Delete ours and use the existing one + await DestroyRoom(ret); + ret = existingRoom; + } + return ret; + } + + // Return the room with the given room ID or 'null' if no such room + public JanusRoom GetRoom(int pRoomId) + { + JanusRoom ret = null; + lock (_rooms) + { + _rooms.TryGetValue(pRoomId, out ret); + } + return ret; + } + + public override void Handle_Event(JanusMessageResp pResp) + { + base.Handle_Event(pResp); + AudioBridgeResp abResp = new AudioBridgeResp(pResp); + if (abResp is not null && abResp.AudioBridgeReturnCode == "event") + { + // An audio bridge event! + m_log.DebugFormat("{0} Handle_Event. {1}", LogHeader, abResp.ToString()); + } + + } + public override void Handle_Message(JanusMessageResp pResp) + { + base.Handle_Message(pResp); + AudioBridgeResp abResp = new AudioBridgeResp(pResp); + if (abResp is not null && abResp.AudioBridgeReturnCode == "event") + { + // An audio bridge event! + m_log.DebugFormat("{0} Handle_Event. {1}", LogHeader, abResp.ToString()); + } + + } + } +} diff --git a/OpenSim/Addons/os-webrtc-janus/Janus/JanusMessages.cs b/OpenSim/Addons/os-webrtc-janus/Janus/JanusMessages.cs new file mode 100644 index 0000000000..5b061567ef --- /dev/null +++ b/OpenSim/Addons/os-webrtc-janus/Janus/JanusMessages.cs @@ -0,0 +1,638 @@ +/* + * 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 OpenMetaverse.StructuredData; +using OpenMetaverse; + +using log4net; + +namespace WebRtcVoice +{ + + /// + /// Wrappers around the Janus requests and responses. + /// Since the messages are JSON and, because of the libraries we are using, + /// the internal structure is an OSDMap, these routines hold all the logic + /// to getting and setting the values in the JSON. + /// + public class JanusMessage + { + protected static readonly ILog m_log = LogManager.GetLogger(MethodBase.GetCurrentMethod().DeclaringType); + protected static readonly string LogHeader = "[JANUS MESSAGE]"; + + protected OSDMap m_message = new OSDMap(); + + public JanusMessage() + { + } + // A basic Janus message is: + // { + // "janus": "operation", + // "transaction": "baefcec8-70c5-4e79-b2c1-d653b9617dea", + // "session_id": 5645225333294848, // optional, gives the session ID + // "handle_id": 6969906757968657 // optional, gives the plugin handle ID + // "sender": 6969906757968657 // optional, gives the ID of the sending subsystem + // "jsep": { "type": "offer", "sdp": "..." } // optional, gives the SDP + // } + public JanusMessage(string pType) : this() + { + m_message["janus"] = pType; + m_message["transaction"] = UUID.Random().ToString(); + } + public OSDMap RawBody => m_message; + + public string TransactionId { + get { return m_message.ContainsKey("transaction") ? m_message["transaction"] : null; } + set { m_message["transaction"] = value; } + } + public string Sender { + get { return m_message.ContainsKey("sender") ? m_message["sender"] : null; } + set { m_message["sender"] = value; } + } + public OSDMap Jsep { + get { return m_message.ContainsKey("jsep") ? (m_message["jsep"] as OSDMap) : null; } + set { m_message["jsep"] = value; } + } + public void SetJsep(string pOffer, string pSdp) + { + m_message["jsep"] = new OSDMap() + { + { "type", pOffer }, + { "sdp", pSdp } + }; + } + + public void AddAPIToken(string pToken) + { + m_message["apisecret"] = pToken; + } + // Note that the session_id is a long number in the JSON so we convert the string. + public string sessionId { + get { return m_message.ContainsKey("session_id") ? OSDToLong(m_message["session_id"]).ToString() : String.Empty; } + set { m_message["session_id"] = long.Parse(value); } + } + public bool hasSessionId { get { return m_message.ContainsKey("session_id"); } } + public void AddSessionId(string pToken) + { + AddSessionId(long.Parse(pToken)); + } + public void AddSessionId(long pToken) + { + m_message["session_id"] = pToken; + } + public bool hasHandleId { get { return m_message.ContainsKey("handle_id"); } } + public void AddHandleId(string pToken) + { + m_message["handle_id"] = long.Parse(pToken); + } + public string sender { + get { return m_message.ContainsKey("sender") ? m_message["sender"] : String.Empty; } + } + + public virtual string ToJson() + { + return m_message.ToString(); + } + public override string ToString() + { + return m_message.ToString(); + } + // Utility function to convert an OSD object to an long. The OSD object can be an OSDInteger + // or an OSDArray of 4 or 8 integers. + // This exists because the JSON to OSD parser can return an OSDArray for a long number + // since there is not an OSDLong type. + // The design of the OSD conversion functions kinda needs one to know how the number + // is stored in order to extract it. Like, if it's stored as a long value (8 bytes) + // and one fetches it with .AsInteger(), it will return the first 4 bytes as an integer + // and not the long value. So this function looks at the type of the OSD object and + // extracts the number appropriately. + public long OSDToLong(OSD pIn) + { + long ret = 0; + switch (pIn.Type) + { + case OSDType.Integer: + ret = (long)(pIn as OSDInteger).AsInteger(); + break; + case OSDType.Binary: + byte[] value = (pIn as OSDBinary).value; + if (value.Length == 4) + { + ret = (long)(pIn as OSDBinary).AsInteger(); + } + if (value.Length == 8) + { + ret = (pIn as OSDBinary).AsLong(); + } + break; + case OSDType.Array: + if ((pIn as OSDArray).Count == 4) + { + ret = (long)pIn.AsInteger(); + } + if ((pIn as OSDArray).Count == 8) + { + ret = pIn.AsLong(); + } + break; + } + return ret; + } + } + // ============================================================== + // A Janus request message is a basic Janus message with an API token + public class JanusMessageReq : JanusMessage + { + public JanusMessageReq(string pType) : base(pType) + { + } + } + + // ============================================================== + // Janus message response is a basic Janus message with the response data + // { + // "janus": "success", + // "transaction": "baefcec8-70c5-4e79-b2c1-d653b9617dea", // ID of the requesting message + // "data": { ... } // the response data + // "error": { "code": 123, "reason": "..." } // if there was an error + // } + // The "janus" return code changes depending on the request. The above is for + // a successful response. Could be + // "event": for an event message (See JanusEventResp) + // "keepalive": for a keepalive event + public class JanusMessageResp : JanusMessage + { + public JanusMessageResp() : base() + { + } + + public JanusMessageResp(string pType) : base(pType) + { + } + + public JanusMessageResp(OSDMap pMap) : base() + { + m_message = pMap; + } + + public static JanusMessageResp FromJson(string pJson) + { + var newBody = OSDParser.DeserializeJson(pJson) as OSDMap; + return new JanusMessageResp(newBody); + } + + // Return the "data" portion of the response as an OSDMap or null if there is none + public OSDMap dataSection { get { return m_message.ContainsKey("data") ? (m_message["data"] as OSDMap) : null; } } + + // Check if a successful response code is in the response + public virtual bool isSuccess { get { return CheckReturnCode("success"); } } + public virtual bool isEvent { get { return CheckReturnCode("event"); } } + public virtual bool isError { get { return CheckReturnCode("error"); } } + public virtual bool CheckReturnCode(string pCode) + { + return ReturnCode == pCode; + } + public virtual string ReturnCode { get { + string ret = String.Empty; + if (m_message is not null && m_message.ContainsKey("janus")) + { + ret = m_message["janus"].AsString(); + } + return ret; + } } + } + + // ============================================================== + // An error response is a Janus response with an error code and reason. + // { + // "janus": "error", + // "transaction": "baefcec8-70c5-4e79-b2c1-d653b9617dea", // ID of the requesting message + // "error": { "code": 123, "reason": "..." } // if there was an error + // } + public class ErrorResp : JanusMessageResp + { + public ErrorResp() : base("error") + { + } + + public ErrorResp(string pType) : base(pType) + { + } + + public ErrorResp(JanusMessageResp pResp) : base(pResp.RawBody) + { + } + + public void SetError(int pCode, string pReason) + { + m_message["error"] = new OSDMap() + { + { "code", pCode }, + { "reason", pReason } + }; + } + + // Dig through the response to get the error code or 0 if there is none + public int errorCode { get { + int ret = 0; + if (m_message.ContainsKey("error")) + { + var err = m_message["error"]; + if (err is OSDMap) + ret = (int)OSDToLong((err as OSDMap)["code"]); + } + return ret; + }} + + // Dig through the response to get the error reason or empty string if there is none + public string errorReason { get { + string ret = String.Empty; + if (m_message.ContainsKey("error")) + { + var err = m_message["error"]; + if (err is OSDMap) + ret = (err as OSDMap)["reason"]; + } + // return ((m_message["error"] as OSDMap)?["reason"]) ?? String.Empty; + return ret; + }} + } + // ============================================================== + // Create session request and response + public class CreateSessionReq : JanusMessageReq + { + public CreateSessionReq() : base("create") + { + } + } + public class CreateSessionResp : JanusMessageResp + { + public CreateSessionResp(JanusMessageResp pResp) : base(pResp.RawBody) + { } + public string returnedId { get { + // The JSON response gives a long number (not a string) + // and the ODMap conversion interprets it as a long (OSDLong). + // If one just does a "ToString()" on the OSD object, you + // get an interpretation of the binary value. + return dataSection.ContainsKey("id") ? OSDToLong(dataSection["id"]).ToString() : String.Empty; + }} + } + // ============================================================== + public class DestroySessionReq : JanusMessageReq + { + public DestroySessionReq() : base("destroy") + { + // Doesn't include the session ID because it is the URI + } + } + // ============================================================== + public class TrickleReq : JanusMessageReq + { + // An empty trickle request is used to signal the end of the trickle + public TrickleReq(JanusViewerSession pVSession) : base("trickle") + { + m_message["candidate"] = new OSDMap() + { + { "completed", true }, + }; + + } + public TrickleReq(JanusViewerSession pVSession, OSD pCandidates) : base("trickle") + { + m_message["viewer_session"] = pVSession.ViewerSessionID; + if (pCandidates is OSDArray) + m_message["candidates"] = pCandidates; + else + m_message["candidate"] = pCandidates; + } + } + // ============================================================== + public class AttachPluginReq : JanusMessageReq + { + public AttachPluginReq(string pPlugin) : base("attach") + { + m_message["plugin"] = pPlugin; + } + } + public class AttachPluginResp : JanusMessageResp + { + public AttachPluginResp(JanusMessageResp pResp) : base(pResp.RawBody) + { } + public string pluginId { get { + return dataSection.ContainsKey("id") ? OSDToLong(dataSection["id"]).ToString() : String.Empty; + }} + } + // ============================================================== + public class DetachPluginReq : JanusMessageReq + { + public DetachPluginReq() : base("detach") + { + // Doesn't include the session ID or plugin ID because it is the URI + } + } + // ============================================================== + public class HangupReq : JanusMessageReq + { + public HangupReq() : base("hangup") + { + // Doesn't include the session ID or plugin ID because it is the URI + } + } + // ============================================================== + // Plugin messages are defined here as wrappers around OSDMap. + // The ToJson() method is overridden to put the OSDMap into the + // message body. + // A plugin request is formatted like: + // { + // "janus": "message", + // "transaction": "baefcec8-70c5-4e79-b2c1-d653b9617dea", + // "body": { + // "request": "create", + // "room": 10, + // "is_private": false, + // } + public class PluginMsgReq : JanusMessageReq + { + private OSDMap m_body = new OSDMap(); + + // Note that the passed OSDMap is placed in the "body" section of the message + public PluginMsgReq(OSDMap pBody) : base("message") + { + m_body = pBody; + } + public void AddStringToBody(string pKey, string pValue) + { + m_body[pKey] = pValue; + } + public void AddIntToBody(string pKey, int pValue) + { + m_body[pKey] = pValue; + } + public void AddBoolToBody(string pKey, bool pValue) + { + m_body[pKey] = pValue; + } + public void AddOSDToBody(string pKey, OSD pValue) + { + m_body[pKey] = pValue; + } + + public override string ToJson() + { + m_message["body"] = m_body; + return base.ToJson(); + } + } + // A plugin response is formatted like: + // { + // "janus": "success", + // "session_id": 5645225333294848, + // "transaction": "baefcec8-70c5-4e79-b2c1-d653b9617dea", + // "sender": 6969906757968657, + // "plugindata": { + // "plugin": "janus.plugin.audiobridge", + // "data": { + // "audiobridge": "created", + // "room": 10, + // "permanent": false + // } + // } + public class PluginMsgResp : JanusMessageResp + { + public OSDMap m_pluginData; + public OSDMap m_data; + public PluginMsgResp(JanusMessageResp pResp) : base(pResp.RawBody) + { + if (m_message is not null && m_message.ContainsKey("plugindata")) + { + // Move the plugin data up into the m_data var so it is easier to get to + m_pluginData = m_message["plugindata"] as OSDMap; + if (m_pluginData is not null && m_pluginData.ContainsKey("data")) + { + m_data = m_pluginData["data"] as OSDMap; + // m_log.DebugFormat("{0} AudioBridgeResp. Found both plugindata and data: data={1}", LogHeader, m_data.ToString()); + } + } + } + + public OSDMap PluginRespData { get { return m_data; } } + + // Get an integer value for a key in the response data or zero if not there + public int PluginRespDataInt(string pKey) + { + if (m_data is null) + return 0; + return m_data.ContainsKey(pKey) ? (int)OSDToLong(m_data[pKey]) : 0; + } + // Get a string value for a key in the response data or empty string if not there + public string PluginRespDataString(string pKey) + { + if (m_data is null) + return String.Empty; + return m_data.ContainsKey(pKey) ? m_data[pKey].AsString() : String.Empty; + } + } + // ============================================================== + // Plugin messages for the audio bridge. + // Audiobridge responses are formatted like: + // { + // "janus": "success", + // "session_id": 5645225333294848, + // "transaction": "baefcec8-70c5-4e79-b2c1-d653b9617dea", + // "sender": 6969906757968657, + // "plugindata": { + // "plugin": "janus.plugin.audiobridge", + // "data": { + // "audiobridge": "created", + // "room": 10, + // "permanent": false + // } + // } + public class AudioBridgeResp: PluginMsgResp + { + public AudioBridgeResp(JanusMessageResp pResp) : base(pResp) + { + } + public override bool isSuccess { get { return PluginRespDataString("audiobridge") == "success"; } } + // Return the return code if it is in the response or empty string if not + public string AudioBridgeReturnCode { get { return PluginRespDataString("audiobridge"); } } + // Return the error code if it is in the response or zero if not + public int AudioBridgeErrorCode { get { return PluginRespDataInt("error_code"); } } + // Return the room ID if it is in the response or zero if not + public int RoomId { get { return PluginRespDataInt("room"); } } + } + // ============================================================== + public class AudioBridgeCreateRoomReq : PluginMsgReq + { + public AudioBridgeCreateRoomReq(int pRoomId) : this(pRoomId, false, null) + { + } + public AudioBridgeCreateRoomReq(int pRoomId, bool pSpatial, string pDesc) : base(new OSDMap() { + { "room", pRoomId }, + { "request", "create" }, + { "is_private", false }, + { "permanent", false }, + { "sampling_rate", 48000 }, + { "spatial_audio", pSpatial }, + { "denoise", false }, + { "record", false } + }) + { + if (!String.IsNullOrEmpty(pDesc)) + AddStringToBody("description", pDesc); + } + } + // ============================================================== + public class AudioBridgeDestroyRoomReq : PluginMsgReq + { + public AudioBridgeDestroyRoomReq(int pRoomId) : base(new OSDMap() { + { "request", "destroy" }, + { "room", pRoomId }, + { "permanent", true } + }) + { + } + } + // ============================================================== + public class AudioBridgeJoinRoomReq : PluginMsgReq + { + public AudioBridgeJoinRoomReq(int pRoomId, string pAgentName) : base(new OSDMap() { + { "request", "join" }, + { "room", pRoomId }, + { "display", pAgentName } + }) + { + } + } + // A successful response contains the participant ID and the SDP + public class AudioBridgeJoinRoomResp : AudioBridgeResp + { + public AudioBridgeJoinRoomResp(JanusMessageResp pResp) : base(pResp) + { + } + public int ParticipantId { get { return PluginRespDataInt("id"); } } + } + // ============================================================== + public class AudioBridgeConfigRoomReq : PluginMsgReq + { + // TODO: + public AudioBridgeConfigRoomReq(int pRoomId, string pSdp) : base(new OSDMap() { + { "request", "configure" }, + }) + { + } + } + public class AudioBridgeConfigRoomResp : AudioBridgeResp + { + // TODO: + public AudioBridgeConfigRoomResp(JanusMessageResp pResp) : base(pResp) + { + } + } + // ============================================================== + public class AudioBridgeLeaveRoomReq : PluginMsgReq + { + public AudioBridgeLeaveRoomReq(int pRoomId, int pAttendeeId) : base(new OSDMap() { + { "request", "leave" }, + { "room", pRoomId }, + { "id", pAttendeeId } + }) + { + } + } + // ============================================================== + public class AudioBridgeListRoomsReq : PluginMsgReq + { + public AudioBridgeListRoomsReq() : base(new OSDMap() { + { "request", "list" } + }) + { + } + } + // ============================================================== + public class AudioBridgeListParticipantsReq : PluginMsgReq + { + public AudioBridgeListParticipantsReq(int pRoom) : base(new OSDMap() { + { "request", "listparticipants" }, + { "room", pRoom } + }) + { + } + } + // ============================================================== + public class AudioBridgeEvent : AudioBridgeResp + { + public AudioBridgeEvent(JanusMessageResp pResp) : base(pResp) + { + } + } + // ============================================================== + // The LongPoll request returns events from the plugins. These are formatted + // like the other responses but are not responses to requests. + // They are formatted like: + // { + // "janus": "event", + // "sender": 6969906757968657, + // "transaction": "baefcec8-70c5-4e79-b2c1-d653b9617dea", + // "plugindata": { + // "plugin": "janus.plugin.audiobridge", + // "data": { + // "audiobridge": "event", + // "room": 10, + // "participants": 1, + // "participants": [ + // { + // "id": 1234, + // "display": "John Doe", + // "audio_level": 0.0, + // "video_room": false, + // "video_muted": false, + // "audio_muted": false, + // "feed": 1234 + // } + // ] + // } + // } + public class EventResp : JanusMessageResp + { + public EventResp() : base() + { + } + + public EventResp(string pType) : base(pType) + { + } + + public EventResp(JanusMessageResp pResp) : base(pResp.RawBody) + { + } + } + // ============================================================== +} diff --git a/OpenSim/Addons/os-webrtc-janus/Janus/JanusPlugin.cs b/OpenSim/Addons/os-webrtc-janus/Janus/JanusPlugin.cs new file mode 100644 index 0000000000..dd2a5033f2 --- /dev/null +++ b/OpenSim/Addons/os-webrtc-janus/Janus/JanusPlugin.cs @@ -0,0 +1,161 @@ +/* + * 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.Interfaces; +using OpenSim.Services.Base; + +using OpenMetaverse.StructuredData; +using OpenMetaverse; + +using Nini.Config; +using log4net; + +namespace WebRtcVoice +{ + // Encapsulization of a Session to the Janus server + public class JanusPlugin : IDisposable + { + private static readonly ILog m_log = LogManager.GetLogger(MethodBase.GetCurrentMethod().DeclaringType); + private static readonly string LogHeader = "[JANUS PLUGIN]"; + + protected IConfigSource _Config; + protected JanusSession _JanusSession; + + public string PluginName { get; private set; } + public string PluginId { get; private set; } + public string PluginUri { get ; private set ; } + + public bool IsConnected => !String.IsNullOrEmpty(PluginId); + + // Wrapper around the session connection to Janus-gateway + public JanusPlugin(JanusSession pSession, string pPluginName) + { + _JanusSession = pSession; + PluginName = pPluginName; + } + + public virtual void Dispose() + { + if (IsConnected) + { + // Close the handle + + } + } + + public Task SendPluginMsg(OSDMap pParams) + { + return _JanusSession.SendToJanus(new PluginMsgReq(pParams), PluginUri); + } + public Task SendPluginMsg(PluginMsgReq pJMsg) + { + return _JanusSession.SendToJanus(pJMsg, PluginUri); + } + + /// + /// Make the create a handle to a plugin within the session. + /// + /// TRUE if handle was created successfully + public async Task Activate(IConfigSource pConfig) + { + _Config = pConfig; + + bool ret = false; + try + { + var resp = await _JanusSession.SendToSession(new AttachPluginReq(PluginName)); + if (resp is not null && resp.isSuccess) + { + var handleResp = new AttachPluginResp(resp); + PluginId = handleResp.pluginId; + PluginUri = _JanusSession.SessionUri + "/" + PluginId; + m_log.DebugFormat("{0} Activate. Plugin attached. ID={1}, URL={2}", LogHeader, PluginId, PluginUri); + _JanusSession.PluginId = PluginId; + _JanusSession.OnEvent += Handle_Event; + _JanusSession.OnMessage += Handle_Message; + ret = true; + } + else + { + m_log.ErrorFormat("{0} Activate: failed to attach to plugin {1}", LogHeader, PluginName); + } + } + catch (Exception e) + { + m_log.ErrorFormat("{0} Activate: exception attaching to plugin {1}: {2}", LogHeader, PluginName, e); + } + + return ret; + } + + public virtual async Task Detach() + { + bool ret = false; + if (!IsConnected || _JanusSession is null) + { + m_log.WarnFormat("{0} Detach. Not connected", LogHeader); + return ret; + } + try + { + _JanusSession.OnEvent -= Handle_Event; + _JanusSession.OnMessage -= Handle_Message; + // We send the 'detach' message to the plugin URI + var resp = await _JanusSession.SendToJanus(new DetachPluginReq(), PluginUri); + if (resp is not null && resp.isSuccess) + { + m_log.DebugFormat("{0} Detach. Detached", LogHeader); + ret = true; + } + else + { + m_log.ErrorFormat("{0} Detach: failed", LogHeader); + } + } + catch (Exception e) + { + m_log.ErrorFormat("{0} Detach: exception {1}", LogHeader, e); + } + + return ret; + } + + public virtual void Handle_Event(JanusMessageResp pResp) + { + m_log.DebugFormat("{0} Handle_Event: {1}", LogHeader, pResp.ToString()); + } + public virtual void Handle_Message(JanusMessageResp pResp) + { + m_log.DebugFormat("{0} Handle_Message: {1}", LogHeader, pResp.ToString()); + } + } +} \ No newline at end of file diff --git a/OpenSim/Addons/os-webrtc-janus/Janus/JanusRoom.cs b/OpenSim/Addons/os-webrtc-janus/Janus/JanusRoom.cs new file mode 100644 index 0000000000..202db3bd11 --- /dev/null +++ b/OpenSim/Addons/os-webrtc-janus/Janus/JanusRoom.cs @@ -0,0 +1,138 @@ +/* + * 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 OpenSim.Framework; +using OpenSim.Services.Interfaces; +using OpenSim.Services.Base; + +using OpenMetaverse.StructuredData; +using OpenMetaverse; + +using Nini.Config; +using log4net; +using System.Threading.Tasks; +using System.Text.RegularExpressions; +using System.Collections.Generic; + +namespace WebRtcVoice +{ + // Encapsulization of a Session to the Janus server + public class JanusRoom : IDisposable + { + private static readonly ILog m_log = LogManager.GetLogger(MethodBase.GetCurrentMethod().DeclaringType); + private static readonly string LogHeader = "[JANUS ROOM]"; + + public int RoomId { get; private set; } + + private JanusPlugin _AudioBridge; + + // Wrapper around the session connection to Janus-gateway + public JanusRoom(JanusPlugin pAudioBridge, int pRoomId) + { + _AudioBridge = pAudioBridge; + RoomId = pRoomId; + } + + public void Dispose() + { + // Close the room + } + + public async Task JoinRoom(JanusViewerSession pVSession) + { + bool ret = false; + try + { + // m_log.DebugFormat("{0} JoinRoom. New joinReq for room {1}", LogHeader, RoomId); + + // Discovered that AudioBridge doesn't care if the data portion is present + // and, if removed, the viewer complains that the "m=" sections are + // out of order. Not "cleaning" (removing the data section) seems to work. + // string cleanSdp = CleanupSdp(pSdp); + var joinReq = new AudioBridgeJoinRoomReq(RoomId, pVSession.AgentId.ToString()); + // joinReq.SetJsep("offer", cleanSdp); + joinReq.SetJsep("offer", pVSession.Offer); + + JanusMessageResp resp = await _AudioBridge.SendPluginMsg(joinReq); + AudioBridgeJoinRoomResp joinResp = new AudioBridgeJoinRoomResp(resp); + + if (joinResp is not null && joinResp.AudioBridgeReturnCode == "joined") + { + pVSession.ParticipantId = joinResp.ParticipantId; + pVSession.Answer = joinResp.Jsep; + ret = true; + m_log.DebugFormat("{0} JoinRoom. Joined room {1}. Participant={2}", LogHeader, RoomId, pVSession.ParticipantId); + } + else + { + m_log.ErrorFormat("{0} JoinRoom. Failed to join room {1}. Resp={2}", LogHeader, RoomId, joinResp.ToString()); + } + } + catch (Exception e) + { + m_log.ErrorFormat("{0} JoinRoom. Exception {1}", LogHeader, e); + } + return ret; + } + + // TODO: this doesn't work. + // Not sure if it is needed. Janus generates Hangup events when the viewer leaves. + /* + public async Task Hangup(JanusViewerSession pAttendeeSession) + { + bool ret = false; + try + { + } + catch (Exception e) + { + m_log.ErrorFormat("{0} LeaveRoom. Exception {1}", LogHeader, e); + } + return ret; + } + */ + + public async Task LeaveRoom(JanusViewerSession pAttendeeSession) + { + bool ret = false; + try + { + JanusMessageResp resp = await _AudioBridge.SendPluginMsg( + new AudioBridgeLeaveRoomReq(RoomId, pAttendeeSession.ParticipantId)); + } + catch (Exception e) + { + m_log.ErrorFormat("{0} LeaveRoom. Exception {1}", LogHeader, e); + } + return ret; + } + + } +} diff --git a/OpenSim/Addons/os-webrtc-janus/Janus/JanusSession.cs b/OpenSim/Addons/os-webrtc-janus/Janus/JanusSession.cs new file mode 100644 index 0000000000..686604717b --- /dev/null +++ b/OpenSim/Addons/os-webrtc-janus/Janus/JanusSession.cs @@ -0,0 +1,658 @@ +/* + * 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.Generic; +using System.Net.Http; +using System.Net.Mime; +using System.Reflection; +using System.Threading.Tasks; + +using OpenMetaverse.StructuredData; + +using log4net; +using log4net.Core; +using System.Reflection.Metadata; +using System.Threading; + +namespace WebRtcVoice +{ + // 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 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 CancellationTokenSource(); + private HttpClient _HttpClient = new HttpClient(); + + public bool IsConnected { get; set; } + + // Wrapper around the session connection to Janus-gateway + public JanusSession(string pServerURI, string pAPIToken, string pAdminURI, string pAdminToken, bool pDebugMessages = false) + { + m_log.DebugFormat("{0} JanusSession constructor", LogHeader); + _JanusServerURI = pServerURI; + _JanusAPIToken = pAPIToken; + _JanusAdminURI = pAdminURI; + _JanusAdminToken = pAdminToken; + _MessageDetails = pDebugMessages; + } + + public void Dispose() + { + ClearEventSubscriptions(); + if (IsConnected) + { + _ = DestroySession(); + } + if (_HttpClient is not null) + { + _HttpClient.Dispose(); + _HttpClient = null; + } + } + + /// + /// 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 + { + var resp = await SendToJanus(new CreateSessionReq()); + if (resp is not null && resp.isSuccess) + { + var sessionResp = new CreateSessionResp(resp); + SessionId = sessionResp.returnedId; + IsConnected = true; + SessionUri = _JanusServerURI + "/" + SessionId; + m_log.DebugFormat("{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()); + if (resp is not null && resp.isSuccess) + { + // Note that setting IsConnected to false will cause the long poll to exit + m_log.DebugFormat("{0} DestroySession. Destroyed", LogHeader); + } + else + { + if (resp.isError) + { + ErrorResp eResp = new ErrorResp(resp); + switch (eResp.errorCode) + { + case 458: + // This is the error code for a session that is already destroyed + m_log.DebugFormat("{0} DestroySession: session already destroyed", LogHeader); + break; + case 459: + // This is the error code for handle already destroyed + m_log.DebugFormat("{0} DestroySession: Handle not found", LogHeader); + break; + default: + m_log.ErrorFormat("{0} DestroySession: failed {1}", LogHeader, eResp.errorReason); + break; + } + } + else + { + m_log.ErrorFormat("{0} DestroySession: failed. Resp: {1}", LogHeader, resp.ToString()); + } + } + } + catch (Exception e) + { + m_log.ErrorFormat("{0} DestroySession: exception {1}", LogHeader, 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)); + } + else + { + ret = await SendToJanusNoWait(new TrickleReq(pVSession), pVSession.AudioBridge.PluginUri); + } + 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)); + } + else + { + ret = await SendToJanusNoWait(new TrickleReq(pVSession), pVSession.AudioBridge.PluginUri); + } + return ret; + } + // ==================================================================== + public Dictionary _Plugins = new Dictionary(); + public void AddPlugin(JanusPlugin pPlugin) + { + _Plugins.Add(pPlugin.PluginName, pPlugin); + } + // ==================================================================== + // 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 Dictionary _OutstandingRequests = new Dictionary(); + + // 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) + { + AddJanusHeaders(pReq); + // m_log.DebugFormat("{0} SendToJanus", LogHeader); + if (_MessageDetails) m_log.DebugFormat("{0} SendToJanus. URI={1}, req={2}", LogHeader, pURI, pReq.ToJson()); + + JanusMessageResp ret = null; + try + { + OutstandingRequest outReq = new OutstandingRequest + { + TransactionId = pReq.TransactionId, + RequestTime = DateTime.Now, + TaskCompletionSource = new TaskCompletionSource() + }; + _OutstandingRequests.Add(pReq.TransactionId, outReq); + + string reqStr = pReq.ToJson(); + + HttpRequestMessage reqMsg = new HttpRequestMessage(HttpMethod.Post, pURI); + reqMsg.Content = new StringContent(reqStr, System.Text.Encoding.UTF8, MediaTypeNames.Application.Json); + reqMsg.Headers.Add("Accept", "application/json"); + HttpResponseMessage response = await _HttpClient.SendAsync(reqMsg, _CancelTokenSource.Token); + + if (response.IsSuccessStatusCode) + { + string respStr = await response.Content.ReadAsStringAsync(); + ret = JanusMessageResp.FromJson(respStr); + if (ret.CheckReturnCode("ack")) + { + // Some messages are asynchronous and completed with an event + if (_MessageDetails) m_log.DebugFormat("{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 + } + else + { + // If the response is not an ack, that means a synchronous request/response so return the response + _OutstandingRequests.Remove(pReq.TransactionId); + if (_MessageDetails) m_log.DebugFormat("{0} SendToJanus: response {1}", LogHeader, respStr); + } + } + else + { + m_log.ErrorFormat("{0} SendToJanus: response not successful {1}", LogHeader, response); + _OutstandingRequests.Remove(pReq.TransactionId); + } + } + catch (Exception e) + { + m_log.ErrorFormat("{0} SendToJanus: exception {1}", LogHeader, e.Message); + } + + 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 JanusMessageResp(); + + AddJanusHeaders(pReq); + + try { + HttpRequestMessage reqMsg = new HttpRequestMessage(HttpMethod.Post, pURI); + string reqStr = pReq.ToJson(); + reqMsg.Content = new StringContent(reqStr, System.Text.Encoding.UTF8, MediaTypeNames.Application.Json); + reqMsg.Headers.Add("Accept", "application/json"); + HttpResponseMessage response = await _HttpClient.SendAsync(reqMsg); + string respStr = await response.Content.ReadAsStringAsync(); + ret = JanusMessageResp.FromJson(respStr); + } + catch (Exception e) + { + m_log.ErrorFormat("{0} SendToJanusNoWait: exception {1}", LogHeader, 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) + { + // Authentication token + if (!String.IsNullOrEmpty(_JanusAPIToken)) + { + pReq.AddAPIToken(_JanusAPIToken); + } + // Transaction ID that matches responses to requests + if (String.IsNullOrEmpty(pReq.TransactionId)) + { + pReq.TransactionId = Guid.NewGuid().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 (!pReq.hasSessionId && !String.IsNullOrEmpty(SessionId)) + { + pReq.AddSessionId(SessionId); + } + // HandleId connects to the plugin + if (!pReq.hasHandleId && !String.IsNullOrEmpty(PluginId)) + { + pReq.AddHandleId(PluginId); + } + } + + bool TryGetOutstandingRequest(string pTransactionId, out OutstandingRequest pOutstandingRequest) + { + if (String.IsNullOrEmpty(pTransactionId)) + { + pOutstandingRequest = null; + return false; + } + + bool ret = false; + lock (_OutstandingRequests) + { + if (_OutstandingRequests.TryGetValue(pTransactionId, out pOutstandingRequest)) + { + _OutstandingRequests.Remove(pTransactionId); + ret = true; + } + } + return ret; + } + + public Task SendToJanusAdmin(JanusMessageReq pReq) + { + return SendToJanus(pReq, _JanusAdminURI); + } + + 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) + { + if (!String.IsNullOrEmpty(_JanusAPIToken)) + { + pURI += "?apisecret=" + _JanusAPIToken; + } + + JanusMessageResp ret = null; + try + { + // m_log.DebugFormat("{0} GetFromJanus: URI = \"{1}\"", LogHeader, pURI); + HttpRequestMessage reqMsg = new HttpRequestMessage(HttpMethod.Get, pURI); + reqMsg.Headers.Add("Accept", "application/json"); + HttpResponseMessage response = null; + try + { + response = await _HttpClient.SendAsync(reqMsg, _CancelTokenSource.Token); + if (response is not null && response.IsSuccessStatusCode) + { + string respStr = await response.Content.ReadAsStringAsync(); + ret = JanusMessageResp.FromJson(respStr); + // m_log.DebugFormat("{0} GetFromJanus: response {1}", LogHeader, respStr); + } + else + { + m_log.ErrorFormat("{0} GetFromJanus: response not successful {1}", LogHeader, response); + var eResp = new ErrorResp("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); + } + else + { + eResp.SetError(0, "Connection refused"); + } + ret = eResp; + } + } + catch (TaskCanceledException e) + { + m_log.DebugFormat("{0} GetFromJanus: task canceled: {1}", LogHeader, e.Message); + var eResp = new ErrorResp("GETERROR"); + eResp.SetError(499, "Task canceled"); + ret = eResp; + } + catch (Exception e) + { + m_log.ErrorFormat("{0} GetFromJanus: exception {1}", LogHeader, e.Message); + var eResp = new ErrorResp("GETERROR"); + eResp.SetError(400, "Exception: " + e.Message); + ret = eResp; + } + } + catch (Exception e) + { + m_log.ErrorFormat("{0} GetFromJanus: exception {1}", LogHeader, e); + var eResp = new ErrorResp("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.DebugFormat("{0} EventLongPoll", LogHeader); + Task.Run(async () => { + while (running && IsConnected) + { + try + { + var resp = await GetFromJanus(SessionUri); + 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.DebugFormat("{0} EventLongPoll: server_info {1}", LogHeader, resp.ToString()); + break; + case "ack": + // 'ack' says the request was received and an event will follow + if (_MessageDetails) m_log.DebugFormat("{0} EventLongPoll: ack {1}", LogHeader, resp.ToString()); + break; + case "success": + // success is a sync response that says the request was completed + if (_MessageDetails) m_log.DebugFormat("{0} EventLongPoll: success {1}", LogHeader, resp.ToString()); + 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.DebugFormat("{0} EventLongPoll: trickle {1}", LogHeader, resp.ToString()); + 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()); + 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()); + 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()); + 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()); + break; + case "slowlink": + // Janus detected a slowlink (uplink: true/false) on this PeerConnection; + m_log.DebugFormat("{0} EventLongPoll: slowlink {1}", LogHeader, resp.ToString()); + 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.ErrorFormat("{0} EventLongPoll: error with no transaction. {1}", LogHeader, resp.ToString()); + } + break; + case "event": + if (_MessageDetails) m_log.DebugFormat("{0} EventLongPoll: event {1}", LogHeader, resp.ToString()); + 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()); + 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 + var errorResp = new ErrorResp(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 + 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); + } + } + m_log.InfoFormat("{0} EventLongPoll: Exiting long poll loop", LogHeader); + }); + } + } +} \ No newline at end of file diff --git a/OpenSim/Addons/os-webrtc-janus/Janus/JanusViewerSession.cs b/OpenSim/Addons/os-webrtc-janus/Janus/JanusViewerSession.cs new file mode 100644 index 0000000000..d62f307c1e --- /dev/null +++ b/OpenSim/Addons/os-webrtc-janus/Janus/JanusViewerSession.cs @@ -0,0 +1,109 @@ +/* + * 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.Reflection; +using System.Threading.Tasks; + +using OMV = OpenMetaverse; +using OpenMetaverse.StructuredData; + +using log4net; + +namespace WebRtcVoice +{ + public class JanusViewerSession : IVoiceViewerSession + { + protected static readonly ILog m_log = LogManager.GetLogger(MethodBase.GetCurrentMethod().DeclaringType); + protected static readonly string LogHeader = "[JANUS VIEWER SESSION]"; + + // 'viewer_session' that is passed to and from the viewer + // IVoiceViewerSession.ViewerSessionID + public string ViewerSessionID { get; set; } + // IVoiceViewerSession.VoiceService + public IWebRtcVoiceService VoiceService { get; set; } + // The Janus server keeps track of the user by this ID + // IVoiceViewerSession.VoiceServiceSessionId + public string VoiceServiceSessionId { get; set; } + // IVoiceViewerSession.RegionId + public OMV.UUID RegionId { get; set; } + // IVoiceViewerSession.AgentId + public OMV.UUID AgentId { get; set; } + + // Janus keeps track of the user by this ID + public int ParticipantId { get; set; } + + // Connections to the Janus server + public JanusSession Session { get; set; } + public JanusAudioBridge AudioBridge { get; set; } + public JanusRoom Room { get; set; } + + // This keeps copies of the offer/answer incase we need to resend + public string OfferOrig { get; set; } + public string Offer { get; set; } + // Contains "type" and "sdp" fields + public OSDMap Answer { get; set; } + + public JanusViewerSession(IWebRtcVoiceService pVoiceService) + { + ViewerSessionID = OMV.UUID.Random().ToString(); + VoiceService = pVoiceService; + m_log.DebugFormat("{0} JanusViewerSession created {1}", LogHeader, ViewerSessionID); + } + public JanusViewerSession(string pViewerSessionID, IWebRtcVoiceService pVoiceService) + { + ViewerSessionID = pViewerSessionID; + VoiceService = pVoiceService; + m_log.DebugFormat("{0} JanusViewerSession created {1}", LogHeader, ViewerSessionID); + } + + // Send the messages to the voice service to try and get rid of the session + // IVoiceViewerSession.Shutdown + public async Task Shutdown() + { + m_log.DebugFormat("{0} JanusViewerSession shutdown {1}", LogHeader, ViewerSessionID); + if (Room is not null) + { + var rm = Room; + Room = null; + await rm.LeaveRoom(this); + } + if (AudioBridge is not null) + { + var ab = AudioBridge; + AudioBridge = null; + await ab.Detach(); + } + if (Session is not null) + { + var s = Session; + Session = null; + await s.DestroySession(); + s.Dispose(); + } + } + } +} diff --git a/OpenSim/Addons/os-webrtc-janus/Janus/WebRtcJanusService.cs b/OpenSim/Addons/os-webrtc-janus/Janus/WebRtcJanusService.cs new file mode 100644 index 0000000000..e4ead64480 --- /dev/null +++ b/OpenSim/Addons/os-webrtc-janus/Janus/WebRtcJanusService.cs @@ -0,0 +1,465 @@ +/* + * 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 WebRtcVoice +{ + 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 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 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 ProvisionVoiceAccountRequest(OSDMap pRequest, UUID pUserID, UUID pSceneID) + { + throw new NotImplementedException(); + } + + // This module should not be invoked with this signature + // IWebRtcVoiceService.VoiceSignalingRequest + public Task 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); + } + + + } + } diff --git a/OpenSim/Addons/os-webrtc-janus/LICENSE b/OpenSim/Addons/os-webrtc-janus/LICENSE new file mode 100644 index 0000000000..f315705741 --- /dev/null +++ b/OpenSim/Addons/os-webrtc-janus/LICENSE @@ -0,0 +1,390 @@ +os-webrtc-janus original work, by Robert Adams, license changed to same BSD of rest of OpenSimulator +By Robert Adams, for OpenSimulator + +Original license: + +// Copyright 2024 Robert Adams (misterblue@misterblue.com) +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/OpenSim/Addons/os-webrtc-janus/README.md b/OpenSim/Addons/os-webrtc-janus/README.md new file mode 100644 index 0000000000..b44aa65313 --- /dev/null +++ b/OpenSim/Addons/os-webrtc-janus/README.md @@ -0,0 +1,234 @@ +# os-webrtc-janus + +Addon-module for [OpenSimulator] to provide webrtc voice support +using Janus-gateway. + +For an explanation of background and architecture, +this project was presented at the +[OpenSimulator Community Conference] 2024 +in the presentation +[WebRTC Voice for OpenSimulator](https://www.youtube.com/watch?v=nL78fieIFYg). + +This addon works by taking viewer requests for voice service and +using a separate, external [Janus-Gateway WebRTC server]. +This can be configured to allow local region spatial voice +and grid-wide group and spatial voice. See the sections below. + +For running that separate Janus server, check out +[os-webrtc-janus-docker] which has instructions for running +Janus-Gateway on Linux and Windows WSL using Docker. + +Instructions for: + +- [Building into OpenSimulator](#Building): Build OpenSimulator with WebRTC voice service +- [Configuring Simulator for Voice Services](#Configure_Simulator) +- [Configuring Robust Grid Service](#Configure_Robust) +- [Configure Standalone Region](#Configure_Standalone) +- [Managing Voice Service](#Managing_Voice) (console commands, etc) + +**Note**: as of January 2024, this solution does not provide true spatial +voice service using Janus. There are people working on additions to Janus +to provide this but the existing solution provides only non-spatial +voice services using the `AudioBridge` Janus plugin. Additionally, +features like muting and individual avatar volume are not yet implemented. + + +## Known Issues + +- No spatial audio +- One can see your own "white dot" but you don't see other avatar's white dots +- No muting +- No individual volume control + +And probably more found at [os-webrtc-janus issues](https://github.com/Misterblue/os-webrtc-janus/issues). + + +## Building Plugin into OpenSimulator + +`os-webrtc-janus` is integrated as a source build into [OpenSimulator]. +It uses the [OpenSimulator] addon-module feature which makes the +build as easy as cloning the `os-webrtc-janus` sources into the +[OpenSimulator] source tree, running the build configuration script, +and then building OpenSimulator. + +The steps are: + +``` +# Get the OpenSimulator sources +git clone git://opensimulator.org/git/opensim +cd opensim # cd into the top level OpenSim directory + +# Fetch the WebRtc addon +cd addon-modules +git clone https://github.com/Misterblue/os-webrtc-janus.git +cd .. + +# Build the project files +./runprebuild.sh + +# Compile OpenSimulator with the webrtc addon +./compile.sh + +# Copy the INI file for webrtc into a config dir that is read at boot +mkdir bin/config +cp addon-modules/os-webrtc-janus/os-webrtc-janus.ini bin/config +``` + +These building steps create several `.dll` files for `os-webrtc-janus` +in `bin/WebRtc*.dll`. Some adventurous people have found that, rather +than building the [OpenSimulator] sources, you can just copy the `.dll`s +into an existing `/bin` directory. Just make sure the `WebRtc*.dll` files +were built on the same version of [OpenSimulator] you are running. + + +## Configure a Region for Voice + +The last step in [Building](#Building) copied `os-webrtc-janus.ini` into +the `bin/config` directory. [OpenSimulator] reads all the `.ini` files +in that directory so this copy operation adds the configuration for `os-webrtc-janus` +and this is what needs to be configured for the simulator and region. + +The sample `.ini` file has two sections: `[WebRtcVoice]` and `[JanusWebRtcVoice]`. +The `WebRtcVoice` section configures the what services the simulator uses +for WebRtc voice. The `[JanusWebRtcVoice]` section configures any connection +the simulator makes to the Janus server. The latter section is only updated +if this simulator is using a local Janus server for spatial voice. + +The values for `SpatialVoiceService` and `NonSpatialVoiceService` point +either directly to a Janus service or to a Robust grid server that is providing +the grid voice service. Both these options are in the sample `os-webrtc-janus.ini` +file and the proper one should be uncommented. + +The viewer makes requests for either spatial voice (used in the region and parcels) +or non-spatial voice (used for group chats or person-to-person voice conversations). +`os-webrtc-janus` allows these two types of voice connections to be handled by +different voice services. Thus there are two different configurations: + +- all voice service is provided by the grid (both spatial and non-spatial point to a robust service), and +- the region simulator provides a local Janus server for region spatial voice while the grid service is used for group chats + +#### Grid Only Voice Services + +The most common configuration will be for a simulator that uses the grid supplied +voice services. For this configuration, `os-webrtc-janus.ini` would look like: + +``` +[WebRtcVoice] + Enabled = true + SpatialVoiceService = WebRtcVoice.dll:WebRtcVoiceServiceConnector + NonSpatialVoiceService = WebRtcVoice.dll:WebRtcVoiceServiceConnector + WebRtcVoiceServerURI = ${Const|PrivURL}:${Const|PrivatePort} +``` + +This directs both spatial and non-spatial voice to the grid service connector +and `WebRtcVoiceServerURI` points to the configured Robust grid service. + +There is no need for a `[JanusWebRtcVoice]` section because all that is handled by the grid services. + +#### Local Simulator Janus Service + +In a grid setup, there might be a need for a single simulator/region to use its own +Janus server for either privacy or to off-load the grid voice service. +In this configuration, spatial voice is directed to the local Janus service +while the non-spatial voice goes to the grid services to allow grid wide group +chat and region independent person-to-person chat. + +This is done with a `os-webrtc-janus.ini` that looks like: +``` +[WebRtcVoice] + Enabled = true + SpatialVoiceService = WebRtcJanusService.dll:WebRtcJanusService + NonSpatialVoiceService = WebRtcVoice.dll:WebRtcVoiceServiceConnector + WebRtcVoiceServerURI = ${Const|PrivURL}:${Const|PrivatePort} +[JanusWebRtcVoice] + JanusGatewayURI = http://janus.example.org:14223/voice + APIToken = APITokenToNeverCheckIn + JanusGatewayAdminURI = http://janus.example.org/admin + AdminAPIToken = AdminAPITokenToNeverCheckIn +``` + +Notice that, since the simulator has its own Janus service, it must configure the +connection parameters to access that Janus service. The details of running and +configuring a Janus service is provided at [os-webrtc-janus-docker] but, the configuration +here needs to specify the URI to address the Janus server and the API keys +to allow this simulator access to its interfaces. The example above +contains just sample entries. + + +## Configure Robust Server for WebRTC Voice + +For the grid services side, `os-webrtc-janus` is configured as an additional service +in the Robust OpenSimulator server. The additions to `Robust.ini` are: + +``` +... +[ServiceList] + ... + VoiceServiceConnector = "${Const|PrivatePort}/WebRtcVoice.dll:WebRtcVoiceServerConnector" + ... + +[WebRtcVoice] + Enabled = true + SpatialVoiceService = WebRtcJanusService.dll:WebRtcJanusService + NonSpatialVoiceService = WebRtcJanusService.dll:WebRtcJanusService +[JanusWebRtcVoice] + JanusGatewayURI = http://janus.example.org:14223/voice + APIToken = APITokenToNeverCheckIn + JanusGatewayAdminURI = http://janus.example.org/admin + AdminAPIToken = AdminAPITokenToNeverCheckIn +... +``` + +This adds `VoiceServiceConnector` to the list of services presented by this Robust server +and adds the WebRtcVoice configuration that says to do both spatial and non-spatial voice +using the Janus server, and the configuration for the Janus server itself. + +One can configure multiple Robust services to distribute the load of services +and a Robust server with only `VoiceServiceConnector` in its ServiceList is possible. + + +## Configure Standalone Region + +[OpenSimulator] can be run "standalone" where all the grid services and regions are +run in one simulator instance. Adding voice to this configuration is sometimes useful +for very private meetings or testing. For this configuration, a Janus server is set up +and the standalone simulator is configured to point all voice to that Janus server: + +``` +[WebRtcVoice] + Enabled = true + SpatialVoiceService = WebRtcJanusService.dll:WebRtcJanusService + NonSpatialVoiceService = WebRtcJanusService.dll:WebRtcJanusService + WebRtcVoiceServerURI = ${Const|PrivURL}:${Const|PrivatePort} +[JanusWebRtcVoice] + JanusGatewayURI = http://janus.example.org:14223/voice + APIToken = APITokenToNeverCheckIn + JanusGatewayAdminURI = http://janus.example.org/admin + AdminAPIToken = AdminAPITokenToNeverCheckIn +``` + +This directs both spatial and non-spatial voice to the Janus server +and configures the URI address of the Janus server and the API access +keys for that server. + + +## Managing Voice (Console commands) + +There are a few console commands for checking on and controlling the voice system. +The current list of commands for the simulator can be listed with the +console command `help webrtc`. + +This is a growing section and will be added to over time. + +**webrtc list sessions** -- not implemented + +**janus info** -- list many details of the Janus-Gateway configuration. Very ugly, non-formated JSON. + +**janus list rooms** -- list the rooms that have been allocated in the `AudioBridge` Janus plugin + +[SecondLife WebRTC Voice]: https://wiki.secondlife.com/wiki/WebRTC_Voice +[OpenSimulator]: http://opensimulator.org +[OpenSimulator Community Conference]: https://conference.opensimulator.org +[os-webrtc-janus]: https://github.com/Misterblue/os-webrtc-janus +[Janus-Gateway WebRTC server]: https://janus.conf.meetecho.com/ +[os-webrtc-janus-docker]: https://github.com/Misterblue/os-webrtc-janus-docker diff --git a/OpenSim/Addons/os-webrtc-janus/WebRtcVoice/IVoiceViewerSession.cs b/OpenSim/Addons/os-webrtc-janus/WebRtcVoice/IVoiceViewerSession.cs new file mode 100644 index 0000000000..fd955ebfc8 --- /dev/null +++ b/OpenSim/Addons/os-webrtc-janus/WebRtcVoice/IVoiceViewerSession.cs @@ -0,0 +1,54 @@ +/* + * 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.Threading.Tasks; + +using OMV = OpenMetaverse; + +namespace WebRtcVoice +{ + /// + /// This is the interface for the viewer session. It is used to store the + /// state of the viewer session and to disconnect the session when needed. + /// + public interface IVoiceViewerSession + { + // This ID is passed to and from the viewer to identify the session + public string ViewerSessionID { get; set; } + public IWebRtcVoiceService VoiceService { get; set; } + // THis ID is passed between us and the voice service to idetify the session + public string VoiceServiceSessionId { get; set; } + // The UUID of the region that is being connected to + public OMV.UUID RegionId { get; set; } + + // The simulator has a GUID to identify the user + public OMV.UUID AgentId { get; set; } + + // Disconnect the connection to the voice service for this session + public Task Shutdown(); + } +} diff --git a/OpenSim/Addons/os-webrtc-janus/WebRtcVoice/IWebRtcVoiceService.cs b/OpenSim/Addons/os-webrtc-janus/WebRtcVoice/IWebRtcVoiceService.cs new file mode 100644 index 0000000000..c24f231fb2 --- /dev/null +++ b/OpenSim/Addons/os-webrtc-janus/WebRtcVoice/IWebRtcVoiceService.cs @@ -0,0 +1,58 @@ +/* + * 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 OpenSim.Framework; + +using OpenMetaverse; +using OpenMetaverse.StructuredData; +using System.Threading.Tasks; + +namespace WebRtcVoice +{ + /// + /// This is the interface for the voice service. It is used to connect + /// the user to the voice server and to handle the capability messages + /// from the viewer. + /// + public interface IWebRtcVoiceService + { + // The user is requesting a voice connection. The message contains the offer + // from the user and we must return the answer. + // If there are problems, the returned map will contain an error message. + + // Initial calls to the voice server to get the user connected + public Task ProvisionVoiceAccountRequest(OSDMap pRequest, UUID pUserID, UUID pScene); + public Task VoiceSignalingRequest(OSDMap pRequest, UUID pUserID, UUID pScene); + + // Once connection state is looked up, the viewer session is passed in + public Task ProvisionVoiceAccountRequest(IVoiceViewerSession pVSession, OSDMap pRequest, UUID pUserID, UUID pScene); + public Task VoiceSignalingRequest(IVoiceViewerSession pVSession, OSDMap pRequest, UUID pUserID, UUID pScene); + + // Create a viewer session with all the variables needed for the underlying implementation + public IVoiceViewerSession CreateViewerSession(OSDMap pRequest, UUID pUserID, UUID pScene); + } +} diff --git a/OpenSim/Addons/os-webrtc-janus/WebRtcVoice/VoiceViewerSession.cs b/OpenSim/Addons/os-webrtc-janus/WebRtcVoice/VoiceViewerSession.cs new file mode 100644 index 0000000000..7d36bd3023 --- /dev/null +++ b/OpenSim/Addons/os-webrtc-janus/WebRtcVoice/VoiceViewerSession.cs @@ -0,0 +1,132 @@ +/* + * 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.Linq; +using System.Collections.Generic; + +using OpenMetaverse; +using System.Threading.Tasks; + +namespace WebRtcVoice +{ + public class VoiceViewerSession : IVoiceViewerSession + { + + // A simple session structure that is used when the connection is actually in the + // remote service. + public VoiceViewerSession(IWebRtcVoiceService pVoiceService, UUID pRegionId, UUID pAgentId) + { + RegionId = pRegionId; + AgentId = pAgentId; + ViewerSessionID = UUID.Random().ToString(); + VoiceService = pVoiceService; + + } + public string ViewerSessionID { get; set; } + public IWebRtcVoiceService VoiceService { get; set; } + public string VoiceServiceSessionId + { + get => throw new System.NotImplementedException(); + set => throw new System.NotImplementedException(); + } + public UUID RegionId { get; set; } + public UUID AgentId { get; set; } + + // ===================================================================== + // ViewerSessions hold the connection information for the client connection through to the voice service. + // This collection is static and is simulator wide so there will be sessions for all regions and all clients. + public static Dictionary ViewerSessions = new Dictionary(); + // Get a viewer session by the viewer session ID + public static bool TryGetViewerSession(string pViewerSessionId, out IVoiceViewerSession pViewerSession) + { + lock (ViewerSessions) + { + return ViewerSessions.TryGetValue(pViewerSessionId, out pViewerSession); + } + } + // public static bool TryGetViewerSessionByAgentId(UUID pAgentId, out IVoiceViewerSession pViewerSession) + public static bool TryGetViewerSessionByAgentId(UUID pAgentId, out IEnumerable> pViewerSessions) + { + lock (ViewerSessions) + { + pViewerSessions = ViewerSessions.Where(v => v.Value.AgentId == pAgentId); + return pViewerSessions.Count() > 0; + } + } + // Get a viewer session by the VoiceService session ID + public static bool TryGetViewerSessionByVSSessionId(string pVSSessionId, out IVoiceViewerSession pViewerSession) + { + lock (ViewerSessions) + { + var sessions = ViewerSessions.Where(v => v.Value.VoiceServiceSessionId == pVSSessionId); + if (sessions.Count() > 0) + { + pViewerSession = sessions.First().Value; + return true; + } + pViewerSession = null; + return false; + } + } + public static void AddViewerSession(IVoiceViewerSession pSession) + { + lock (ViewerSessions) + { + ViewerSessions[pSession.ViewerSessionID] = pSession; + } + } + public static void RemoveViewerSession(string pSessionId) + { + lock (ViewerSessions) + { + ViewerSessions.Remove(pSessionId); + } + } + + // Update a ViewSession from one ID to another. + // Remove the old session ID from the ViewerSessions collection, update the + // sessionID value in the IVoiceViewerSession, and add the session back to the + // collection. + // This is used in the kludge to synchronize a region's ViewerSessionID with the + // remote VoiceService's session ID. + public static void UpdateViewerSessionId(IVoiceViewerSession pSession, string pNewSessionId) + { + lock (ViewerSessions) + { + ViewerSessions.Remove(pSession.ViewerSessionID); + pSession.ViewerSessionID = pNewSessionId; + ViewerSessions[pSession.ViewerSessionID] = pSession; + } + } + + public Task Shutdown() + { + throw new System.NotImplementedException(); + } + } +} + diff --git a/OpenSim/Addons/os-webrtc-janus/WebRtcVoice/WebRtcVoiceServerConnector.cs b/OpenSim/Addons/os-webrtc-janus/WebRtcVoice/WebRtcVoiceServerConnector.cs new file mode 100644 index 0000000000..74e3dcbecf --- /dev/null +++ b/OpenSim/Addons/os-webrtc-janus/WebRtcVoice/WebRtcVoiceServerConnector.cs @@ -0,0 +1,172 @@ +/* + * 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.Net; +using System.Reflection; + +using OpenSim.Framework; +using OpenSim.Framework.Servers.HttpServer; +using OpenSim.Region.Framework.Interfaces; +using OpenSim.Services.Interfaces; +using OpenSim.Server.Base; +using OpenSim.Server.Handlers.Base; +using System.Threading.Tasks; + +using OpenMetaverse; +using OpenMetaverse.StructuredData; + +using log4net; +using Nini.Config; + +namespace WebRtcVoice +{ + // Class that provides the network interface to the WebRTC voice server. + // This is used by the Robust server to receive requests from the region servers + // and do the voice stuff on the WebRTC service (see WebRtcVoiceServiceConnector). + public class WebRtcVoiceServerConnector : IServiceConnector + { + private static readonly ILog m_log = LogManager.GetLogger(MethodBase.GetCurrentMethod().DeclaringType); + private static readonly string LogHeader = "[WEBRTC VOICE SERVER CONNECTOR]"; + + private bool m_Enabled = false; + private bool m_MessageDetails = false; + private IWebRtcVoiceService m_WebRtcVoiceService; + + public WebRtcVoiceServerConnector(IConfigSource pConfig, IHttpServer pServer, string pConfigName) + { + IConfig moduleConfig = pConfig.Configs["WebRtcVoice"]; + + if (moduleConfig is not null) + { + m_Enabled = moduleConfig.GetBoolean("Enabled", false); + if (m_Enabled) + { + m_log.InfoFormat("{0} WebRtcVoiceServerConnector enabled", LogHeader); + m_MessageDetails = moduleConfig.GetBoolean("MessageDetails", false); + + // This creates the local service that handles the requests. + // The local service provides the IWebRtcVoiceService interface and directs the requests + // to the WebRTC service. + string localServiceModule = moduleConfig.GetString("LocalServiceModule", "WebRtcVoiceServiceModule.dll:WebRtcVoiceServiceModule"); + m_log.DebugFormat("{0} loading {1}", LogHeader, localServiceModule); + + object[] args = new object[0]; + m_WebRtcVoiceService = ServerUtils.LoadPlugin(localServiceModule, args); + + // The WebRtcVoiceServiceModule is both an IWebRtcVoiceService and a ISharedRegionModule + // so we can initialize it as if it was the region module. + ISharedRegionModule sharedModule = m_WebRtcVoiceService as ISharedRegionModule; + if (sharedModule is null) + { + m_log.ErrorFormat("{0} local service module does not implement ISharedRegionModule", LogHeader); + m_Enabled = false; + return; + } + sharedModule.Initialise(pConfig); + + // Now that we have someone to handle the requests, we can set up the handlers + pServer.AddJsonRPCHandler("provision_voice_account_request", Handle_ProvisionVoiceAccountRequest); + pServer.AddJsonRPCHandler("voice_signaling_request", Handle_VoiceSignalingRequest); + } + } + } + + private bool Handle_ProvisionVoiceAccountRequest(OSDMap pJson, ref JsonRpcResponse pResponse) + { + bool ret = false; + m_log.DebugFormat("{0} Handle_ProvisionVoiceAccountRequest", LogHeader); + if (m_MessageDetails) m_log.DebugFormat("{0} PVAR: req={1}", LogHeader, pJson.ToString()); + + if (pJson.ContainsKey("params") && pJson["params"] is OSDMap paramsMap) + { + OSDMap request = paramsMap.ContainsKey("request") ? paramsMap["request"] as OSDMap : null; + UUID userID = paramsMap.ContainsKey("userID") ? paramsMap["userID"].AsUUID() : UUID.Zero; + UUID sceneID = paramsMap.ContainsKey("scene") ? paramsMap["scene"].AsUUID() : UUID.Zero; + + try + { + if (m_WebRtcVoiceService is null) + { + m_log.ErrorFormat("{0} PVAR: no local service", LogHeader); + return false; + } + OSDMap resp = m_WebRtcVoiceService.ProvisionVoiceAccountRequest(request, userID, sceneID).Result; + + pResponse = new JsonRpcResponse(); + pResponse.Result = resp; + if (m_MessageDetails) m_log.DebugFormat("{0} PVAR: resp={1}", LogHeader, resp.ToString()); + ret = true; + } + catch (Exception e) + { + m_log.ErrorFormat("{0} PVAR: exception {1}", LogHeader, e); + } + } + else + { + m_log.ErrorFormat("{0} PVAR: missing parameters", LogHeader); + } + return ret; + } + + private bool Handle_VoiceSignalingRequest(OSDMap pJson, ref JsonRpcResponse pResponse) + { + bool ret = false; + if (pJson.ContainsKey("params") && pJson["params"] is OSDMap paramsMap) + { + m_log.DebugFormat("{0} Handle_VoiceSignalingRequest", LogHeader); + if (m_MessageDetails) m_log.DebugFormat("{0} VSR: req={1}", LogHeader, paramsMap.ToString()); + + OSDMap request = paramsMap.ContainsKey("request") ? paramsMap["request"] as OSDMap : null; + UUID userID = paramsMap.ContainsKey("userID") ? paramsMap["userID"].AsUUID() : UUID.Zero; + UUID sceneID = paramsMap.ContainsKey("scene") ? paramsMap["scene"].AsUUID() : UUID.Zero; + + try + { + OSDMap resp = m_WebRtcVoiceService.VoiceSignalingRequest(request, userID, sceneID).Result; + + pResponse = new JsonRpcResponse(); + pResponse.Result = resp; + if (m_MessageDetails) m_log.DebugFormat("{0} VSR: resp={1}", LogHeader, resp.ToString()); + + ret = true; + } + catch (Exception e) + { + m_log.ErrorFormat("{0} VSR: exception {1}", LogHeader, e); + } + } + else + { + m_log.ErrorFormat("{0} VSR: missing parameters", LogHeader); + } + + return ret; + } + } +} diff --git a/OpenSim/Addons/os-webrtc-janus/WebRtcVoice/WebRtcVoiceServiceConnector.cs b/OpenSim/Addons/os-webrtc-janus/WebRtcVoice/WebRtcVoiceServiceConnector.cs new file mode 100644 index 0000000000..6812401c76 --- /dev/null +++ b/OpenSim/Addons/os-webrtc-janus/WebRtcVoice/WebRtcVoiceServiceConnector.cs @@ -0,0 +1,218 @@ +/* + * 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 OpenMetaverse; +using OpenMetaverse.StructuredData; + +using log4net; +using Nini.Config; +using OSHttpServer; + +namespace WebRtcVoice +{ + // Class that provides the local IWebRtcVoiceService interface to the JsonRPC Robust + // server. This is used by the region servers to talk to the Robust server. + public class WebRtcVoiceServiceConnector : IWebRtcVoiceService + { + private static readonly ILog m_log = LogManager.GetLogger(MethodBase.GetCurrentMethod().DeclaringType); + private static readonly string LogHeader = "[WEBRTC VOICE SERVICE CONNECTOR]"; + private bool m_Enabled = false; + private bool m_MessageDetails = false; + private IConfigSource m_Config; + + string m_serverURI = "http://localhost:8080"; + + public WebRtcVoiceServiceConnector(IConfigSource pConfig) + { + m_Config = pConfig; + IConfig moduleConfig = m_Config.Configs["WebRtcVoice"]; + + if (moduleConfig is not null) + { + m_Enabled = moduleConfig.GetBoolean("Enabled", false); + if (m_Enabled) + { + m_serverURI = moduleConfig.GetString("WebRtcVoiceServerURI", string.Empty); + if (string.IsNullOrWhiteSpace(m_serverURI)) + { + m_log.ErrorFormat("{0} WebRtcVoiceServiceConnector enabled but no WebRtcVoiceServerURI specified", LogHeader); + m_Enabled = false; + } + else + { + m_log.InfoFormat("{0} WebRtcVoiceServiceConnector enabled", LogHeader); + } + + m_MessageDetails = moduleConfig.GetBoolean("MessageDetails", false); + } + } + + } + + // Create a local viewer session. This gets a local viewer session ID that is + // later changed when the ProvisionVoiceAccountRequest response is returned + // so that the viewer session ID is the same here as from the WebRTC service. + public IVoiceViewerSession CreateViewerSession(OSDMap pRequest, UUID pUserID, UUID pSceneID) + { + m_log.DebugFormat("{0} CreateViewerSession", LogHeader); + return new VoiceViewerSession(this, pUserID, pSceneID); + } + + public Task ProvisionVoiceAccountRequest(OSDMap pRequest, UUID pUserID, UUID pSceneID) + { + m_log.DebugFormat("{0} ProvisionVoiceAccountRequest without ViewerSession. uID={1}, sID={2}", LogHeader, pUserID, pSceneID); + return null; + } + + // Received a ProvisionVoiceAccountRequest from a viewer. Forward it to the WebRTC service. + public async Task ProvisionVoiceAccountRequest(IVoiceViewerSession pVSession, OSDMap pRequest, UUID pUserID, UUID pSceneID) + { + m_log.DebugFormat("{0} VoiceSignalingRequest. uID={1}, sID={2}", LogHeader, pUserID, pSceneID); + OSDMap req = new OSDMap() + { + { "request", pRequest }, + { "userID", pUserID.ToString() }, + { "scene", pSceneID.ToString() } + }; + var resp = await JsonRpcRequest("provision_voice_account_request", m_serverURI, req); + + // Kludge to sync the viewer session number in our IVoiceViewerSession with the one from the WebRTC service. + if (resp.ContainsKey("viewer_session")) + { + string otherViewerSessionId = resp["viewer_session"].AsString(); + m_log.DebugFormat("{0} ProvisionVoiceAccountRequest: syncing viewSessionID. old={1}, new={2}", + LogHeader, pVSession.ViewerSessionID, otherViewerSessionId); + VoiceViewerSession.UpdateViewerSessionId(pVSession, otherViewerSessionId); + } + + return resp; + } + + public Task VoiceSignalingRequest(OSDMap pRequest, UUID pUserID, UUID pSceneID) + { + m_log.DebugFormat("{0} VoiceSignalingRequest without ViewerSession. uID={1}, sID={2}", LogHeader, pUserID, pSceneID); + return null; + } + + public Task VoiceSignalingRequest(IVoiceViewerSession pVSession, OSDMap pRequest, UUID pUserID, UUID pSceneID) + { + m_log.DebugFormat("{0} VoiceSignalingRequest. uID={1}, sID={2}", LogHeader, pUserID, pSceneID); + OSDMap req = new OSDMap() + { + { "request", pRequest }, + { "userID", pUserID.ToString() }, + { "scene", pSceneID.ToString() } + }; + return JsonRpcRequest("voice_signaling_request", m_serverURI, req); + } + + public Task JsonRpcRequest(string method, string uri, OSDMap pParams) + { + string jsonId = UUID.Random().ToString(); + + if(string.IsNullOrWhiteSpace(uri)) + return null; + + TaskCompletionSource tcs = new TaskCompletionSource(); + _ = Task.Run(() => + { + OSDMap request = new() + { + { "jsonrpc", OSD.FromString("2.0") }, + { "id", OSD.FromString(jsonId) }, + { "method", OSD.FromString(method) }, + { "params", pParams } + }; + + OSDMap outerResponse = null; + try + { + if (m_MessageDetails) m_log.DebugFormat("{0}: request: {1}", LogHeader, request); + outerResponse = WebUtil.PostToService(uri, request, 10000, true); + if (m_MessageDetails) m_log.DebugFormat("{0}: response: {1}", LogHeader, outerResponse); + } + catch (Exception e) + { + m_log.ErrorFormat("{0}: JsonRpc request '{1}' to {2} failed: {3}", LogHeader, method, uri, e); + m_log.DebugFormat("{0}: request: {1}", LogHeader, request); + tcs.SetResult(new OSDMap() + { + { "error", OSD.FromString(e.Message) } + }); + } + + OSD osdtmp; + if (!outerResponse.TryGetValue("_Result", out osdtmp) || (osdtmp is not OSDMap)) + { + string errm = String.Format("JsonRpc request '{0}' to {1} returned an invalid response: {2}", + method, uri, OSDParser.SerializeJsonString(outerResponse)); + m_log.ErrorFormat(errm); + tcs.SetResult(new OSDMap() + { + { "error", errm } + }); + } + + OSDMap response = osdtmp as OSDMap; + if (response.TryGetValue("error", out osdtmp)) + { + string errm = String.Format("JsonRpc request '{0}' to {1} returned an error: {2}", + method, uri, OSDParser.SerializeJsonString(osdtmp)); + m_log.ErrorFormat(errm); + tcs.SetResult(new OSDMap() + { + { "error", errm } + }); + } + + OSDMap resultmap = null; + if (!response.TryGetValue("result", out osdtmp) || (osdtmp is not OSDMap)) + { + string errm = String.Format("JsonRpc request '{0}' to {1} returned result as non-OSDMap: {2}", + method, uri, OSDParser.SerializeJsonString(outerResponse)); + m_log.ErrorFormat(errm); + tcs.SetResult(new OSDMap() + { + { "error", errm } + }); + } + resultmap = osdtmp as OSDMap; + + tcs.SetResult(resultmap); + }); + + return tcs.Task; + } + + } +} \ No newline at end of file diff --git a/OpenSim/Addons/os-webrtc-janus/WebRtcVoiceRegionModule/WebRtcVoiceRegionModule.cs b/OpenSim/Addons/os-webrtc-janus/WebRtcVoiceRegionModule/WebRtcVoiceRegionModule.cs new file mode 100644 index 0000000000..ea7aac6f29 --- /dev/null +++ b/OpenSim/Addons/os-webrtc-janus/WebRtcVoiceRegionModule/WebRtcVoiceRegionModule.cs @@ -0,0 +1,597 @@ +/* + * 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.IO; +using System.Net; +using System.Text; +using System.Collections.Generic; +using System.Reflection; + +using Mono.Addins; + +using OpenSim.Framework; +using OpenSim.Framework.Servers.HttpServer; +using OpenSim.Region.Framework.Interfaces; +using OpenSim.Region.Framework.Scenes; +using Caps = OpenSim.Framework.Capabilities.Caps; + +using OpenMetaverse; +using OpenMetaverse.StructuredData; +using OSDMap = OpenMetaverse.StructuredData.OSDMap; + +using log4net; +using Nini.Config; + +[assembly: Addin("WebRtcVoiceRegionModule", "1.0")] +[assembly: AddinDependency("OpenSim.Region.Framework", OpenSim.VersionInfo.VersionNumber)] + +namespace WebRtcVoice +{ + /// + /// This module provides the WebRTC voice interface for viewer clients.. + /// + /// In particular, it provides the following capabilities: + /// ProvisionVoiceAccountRequest, VoiceSignalingRequest, and ParcelVoiceInfoRequest. + /// which are the user interface to the voice service. + /// + /// Initially, when the user connects to the region, the region feature "VoiceServiceType" is + /// set to "webrtc" and the capabilities that support voice are enabled. + /// The capabilities then pass the user request information to the IWebRtcVoiceService interface + /// that has been registered for the reqion. + /// + [Extension(Path = "/OpenSim/RegionModules", NodeName = "RegionModule", Id = "RegionVoiceModule")] + public class WebRtcVoiceRegionModule : ISharedRegionModule + { + private static readonly ILog m_log = LogManager.GetLogger(MethodBase.GetCurrentMethod().DeclaringType); + private static readonly string logHeader = "[REGION WEBRTC VOICE]"; + + private bool _MessageDetails = false; + + // Control info + private static bool m_Enabled = false; + + private readonly Dictionary m_UUIDName = new Dictionary(); + private Dictionary m_ParcelAddress = new Dictionary(); + + private IConfig m_Config; + + // ISharedRegionModule.Initialize + public void Initialise(IConfigSource config) + { + m_Config = config.Configs["WebRtcVoice"]; + if (m_Config is not null) + { + m_Enabled = m_Config.GetBoolean("Enabled", false); + if (m_Enabled) + { + _MessageDetails = m_Config.GetBoolean("MessageDetails", false); + + m_log.Info($"{logHeader}: enabled"); + } + } + } + + // ISharedRegionModule.PostInitialize + public void PostInitialise() + { + } + + // ISharedRegionModule.AddRegion + public void AddRegion(Scene scene) + { + if (m_Enabled) + { + // Get the hook that means Capbibilities are being registered + scene.EventManager.OnRegisterCaps += (UUID agentID, Caps caps) => + { + OnRegisterCaps(scene, agentID, caps); + }; + + } + } + + // ISharedRegionModule.RemoveRegion + public void RemoveRegion(Scene scene) + { + var sfm = scene.RequestModuleInterface(); + sfm.OnSimulatorFeaturesRequest -= OnSimulatorFeatureRequestHandler; + } + + // ISharedRegionModule.RegionLoaded + public void RegionLoaded(Scene scene) + { + if (m_Enabled) + { + // Register for the region feature reporting so we can add 'webrtc' + var sfm = scene.RequestModuleInterface(); + sfm.OnSimulatorFeaturesRequest += OnSimulatorFeatureRequestHandler; + m_log.DebugFormat("{0}: registering OnSimulatorFeatureRequestHandler", logHeader); + } + } + + // ISharedRegionModule.Close + public void Close() + { + } + + // ISharedRegionModule.Name + public string Name + { + get { return "RegionVoiceModule"; } + } + + // ISharedRegionModule.ReplaceableInterface + public Type ReplaceableInterface + { + get { return null; } + } + + // Called when the simulator features are being constructed. + // Add the flag that says we support WebRtc voice. + private void OnSimulatorFeatureRequestHandler(UUID agentID, ref OSDMap features) + { + m_log.DebugFormat("{0}: setting VoiceServerType=webrtc for agent {1}", logHeader, agentID); + features["VoiceServerType"] = "webrtc"; + } + + // + // OnRegisterCaps is invoked via the scene.EventManager + // everytime OpenSim hands out capabilities to a client + // (login, region crossing). We contribute three capabilities to + // the set of capabilities handed back to the client: + // ProvisionVoiceAccountRequest, VoiceSignalingRequest, and ParcelVoiceInfoRequest. + // + // ProvisionVoiceAccountRequest allows the client to obtain + // voice communication information the the avater. + // + // VoiceSignalingRequest: Used for trickling ICE candidates. + // + // ParcelVoiceInfoRequest is invoked whenever the client + // changes from one region or parcel to another. + // + // Note that OnRegisterCaps is called here via a closure + // delegate containing the scene of the respective region (see + // Initialise()). + // + public void OnRegisterCaps(Scene scene, UUID agentID, Caps caps) + { + m_log.DebugFormat( + "{0}: OnRegisterCaps() called with agentID {1} caps {2} in scene {3}", + logHeader, agentID, caps, scene.RegionInfo.RegionName); + + caps.RegisterSimpleHandler("ProvisionVoiceAccountRequest", + new SimpleStreamHandler("/" + UUID.Random(), (IOSHttpRequest httpRequest, IOSHttpResponse httpResponse) => + { + ProvisionVoiceAccountRequest(httpRequest, httpResponse, agentID, scene); + })); + + caps.RegisterSimpleHandler("VoiceSignalingRequest", + new SimpleStreamHandler("/" + UUID.Random(), (IOSHttpRequest httpRequest, IOSHttpResponse httpResponse) => + { + VoiceSignalingRequest(httpRequest, httpResponse, agentID, scene); + })); + + caps.RegisterSimpleHandler("ParcelVoiceInfoRequest", + new SimpleStreamHandler("/" + UUID.Random(), (IOSHttpRequest httpRequest, IOSHttpResponse httpResponse) => + { + ParcelVoiceInfoRequest(httpRequest, httpResponse, agentID, scene); + })); + + caps.RegisterSimpleHandler("ChatSessionRequest", + new SimpleStreamHandler("/" + UUID.Random(), (IOSHttpRequest httpRequest, IOSHttpResponse httpResponse) => + { + ChatSessionRequest(httpRequest, httpResponse, agentID, scene); + })); + + } + + /// + /// Callback for a client request for Voice Account Details + /// + /// current scene object of the client + /// + /// + /// + /// + /// + /// + public void ProvisionVoiceAccountRequest(IOSHttpRequest request, IOSHttpResponse response, UUID agentID, Scene scene) + { + if (request.HttpMethod != "POST") + { + m_log.DebugFormat("[{0}][ProvisionVoice]: Not a POST request. Agent={1}", logHeader, agentID.ToString()); + response.StatusCode = (int)HttpStatusCode.NotFound; + return; + } + + // Deserialize the request. Convert the LLSDXml to OSD for our use + OSDMap map = BodyToMap(request, "[ProvisionVoiceAccountRequest]"); + if (map is null) + { + m_log.ErrorFormat("{0}[ProvisionVoice]: No request data found. Agent={1}", logHeader, agentID.ToString()); + response.StatusCode = (int)HttpStatusCode.NoContent; + return; + } + + // Get the voice service. If it doesn't exist, return an error. + IWebRtcVoiceService voiceService = scene.RequestModuleInterface(); + if (voiceService is null) + { + m_log.ErrorFormat("{0}[ProvisionVoice]: avatar \"{1}\": no voice service", logHeader, agentID); + response.StatusCode = (int)HttpStatusCode.NotFound; + return; + } + + // Make sure the request is for WebRtc voice + if (map.TryGetValue("voice_server_type", out OSD vstosd)) + { + if (vstosd is OSDString vst && !((string)vst).Equals("webrtc", StringComparison.OrdinalIgnoreCase)) + { + m_log.WarnFormat("{0}[ProvisionVoice]: voice_server_type is not 'webrtc'. Request: {1}", logHeader, map.ToString()); + response.RawBuffer = Util.UTF8.GetBytes(""); + return; + } + } + + // The checks passed. Send the request to the voice service. + OSDMap resp = voiceService.ProvisionVoiceAccountRequest(map, agentID, scene.RegionInfo.RegionID).Result; + + if (_MessageDetails) m_log.DebugFormat("{0}[ProvisionVoice]: response: {1}", logHeader, resp.ToString()); + + // TODO: check for errors and package the response + + // Convert the OSD to LLSDXml for the response + string xmlResp = OSDParser.SerializeLLSDXmlString(resp); + + response.StatusCode = (int)HttpStatusCode.OK; + response.RawBuffer = Util.UTF8.GetBytes(xmlResp); + return; + } + + public void VoiceSignalingRequest(IOSHttpRequest request, IOSHttpResponse response, UUID agentID, Scene scene) + { + if (request.HttpMethod != "POST") + { + m_log.ErrorFormat("[{0}][VoiceSignaling]: Not a POST request. Agent={1}", logHeader, agentID.ToString()); + response.StatusCode = (int)HttpStatusCode.NotFound; + return; + } + + // Deserialize the request. Convert the LLSDXml to OSD for our use + OSDMap map = BodyToMap(request, "[VoiceSignalingRequest]"); + if (map is null) + { + m_log.ErrorFormat("{0}[VoiceSignalingRequest]: No request data found. Agent={1}", logHeader, agentID.ToString()); + response.StatusCode = (int)HttpStatusCode.NoContent; + return; + } + + // Make sure the request is for WebRTC voice + if (map.TryGetValue("voice_server_type", out OSD vstosd)) + { + if (vstosd is OSDString vst && !((string)vst).Equals("webrtc", StringComparison.OrdinalIgnoreCase)) + { + response.RawBuffer = Util.UTF8.GetBytes(""); + return; + } + } + + IWebRtcVoiceService voiceService = scene.RequestModuleInterface(); + if (voiceService is null) + { + m_log.ErrorFormat("{0}[VoiceSignalingRequest]: avatar \"{1}\": no voice service", logHeader, agentID); + response.StatusCode = (int)HttpStatusCode.NotFound; + return; + } + + OSDMap resp = voiceService.VoiceSignalingRequest(map, agentID, scene.RegionInfo.RegionID).Result; + if (_MessageDetails) m_log.DebugFormat("{0}[VoiceSignalingRequest]: Response: {1}", logHeader, resp); + + // TODO: check for errors and package the response + + response.StatusCode = (int)HttpStatusCode.OK; + response.RawBuffer = Util.UTF8.GetBytes(""); + return; + } + + /// + /// Callback for a client request for ChatSessionRequest. + /// The viewer sends this request when the user tries to start a P2P text or voice session + /// with another user. We need to generate a new session ID and return it to the client. + /// + /// + /// + /// + /// + public void ChatSessionRequest(IOSHttpRequest request, IOSHttpResponse response, UUID agentID, Scene scene) + { + m_log.DebugFormat("{0}: ChatSessionRequest received for agent {1} in scene {2}", logHeader, agentID, scene.RegionInfo.RegionName); + if (request.HttpMethod != "POST") + { + response.StatusCode = (int)HttpStatusCode.NotFound; + return; + } + + if (!scene.TryGetScenePresence(agentID, out ScenePresence sp) || sp.IsDeleted) + { + m_log.Warn($"{logHeader} ChatSessionRequest: scene presence not found or deleted for agent {agentID}"); + response.StatusCode = (int)HttpStatusCode.NotFound; + return; + } + + OSDMap reqmap = BodyToMap(request, "[ChatSessionRequest]"); + if (reqmap is null) + { + m_log.Warn($"{logHeader} ChatSessionRequest: message body not parsable in request for agent {agentID}"); + response.StatusCode = (int)HttpStatusCode.NoContent; + return; + } + + m_log.Debug($"{logHeader} ChatSessionRequest"); + + if (!reqmap.TryGetString("method", out string method)) + { + m_log.Warn($"{logHeader} ChatSessionRequest: missing required 'method' field in request for agent {agentID}"); + response.StatusCode = (int)HttpStatusCode.NotFound; + return; + } + + if (!reqmap.TryGetUUID("session-id", out UUID sessionID)) + { + m_log.Warn($"{logHeader} ChatSessionRequest: missing required 'session-id' field in request for agent {agentID}"); + response.StatusCode = (int)HttpStatusCode.NotFound; + return; + } + + switch (method.ToLower()) + { + // Several different method requests that we don't know how to handle. + // Just return OK for now. + case "decline p2p voice": + case "decline invitation": + case "start conference": + case "fetch history": + response.StatusCode = (int)HttpStatusCode.OK; + break; + // Asking to start a P2P voice session. We need to generate a new session ID and return + // it to the client in a ChatterBoxSessionStartReply event. + case "start p2p voice": + UUID newSessionID; + if (reqmap.TryGetUUID("params", out UUID otherID)) + newSessionID = new(otherID.ulonga ^ agentID.ulonga, otherID.ulongb ^ agentID.ulongb); + else + newSessionID = UUID.Random(); + + IEventQueue queue = scene.RequestModuleInterface(); + if (queue is null) + { + m_log.ErrorFormat("{0}: no event queue for scene {1}", logHeader, scene.RegionInfo.RegionName); + response.StatusCode = (int)HttpStatusCode.InternalServerError; + } + else + { + queue.ChatterBoxSessionStartReply( + newSessionID, + sp.Name, + 2, + false, + true, + sessionID, + true, + string.Empty, + agentID); + + response.StatusCode = (int)HttpStatusCode.OK; + } + break; + default: + response.StatusCode = (int)HttpStatusCode.BadRequest; + break; + } + } + + // NOTE NOTE!! This is code from the FreeSwitch module. It is not clear if this is correct for WebRtc. + /// + /// Callback for a client request for ParcelVoiceInfo + /// + /// current scene object of the client + /// + /// + /// + /// + /// + /// + public void ParcelVoiceInfoRequest(IOSHttpRequest request, IOSHttpResponse response, UUID agentID, Scene scene) + { + if (request.HttpMethod != "POST") + { + response.StatusCode = (int)HttpStatusCode.NotFound; + return; + } + + response.StatusCode = (int)HttpStatusCode.OK; + + m_log.DebugFormat( + "{0}[PARCELVOICE]: ParcelVoiceInfoRequest() on {1} for {2}", + logHeader, scene.RegionInfo.RegionName, agentID); + + ScenePresence avatar = scene.GetScenePresence(agentID); + if (avatar == null) + { + response.RawBuffer = Util.UTF8.GetBytes("undef"); + return; + } + + string avatarName = avatar.Name; + + // - check whether we have a region channel in our cache + // - if not: + // create it and cache it + // - send it to the client + // - send channel_uri: as "sip:regionID@m_sipDomain" + try + { + string channelUri; + + if (null == scene.LandChannel) + { + m_log.ErrorFormat("region \"{0}\": avatar \"{1}\": land data not yet available", + scene.RegionInfo.RegionName, avatarName); + response.RawBuffer = Util.UTF8.GetBytes("undef"); + return; + } + + // get channel_uri: check first whether estate + // settings allow voice, then whether parcel allows + // voice, if all do retrieve or obtain the parcel + // voice channel + LandData land = scene.GetLandData(avatar.AbsolutePosition); + + // TODO: EstateSettings don't seem to get propagated... + if (!scene.RegionInfo.EstateSettings.AllowVoice) + { + m_log.DebugFormat("{0}[PARCELVOICE]: region \"{1}\": voice not enabled in estate settings", + logHeader, scene.RegionInfo.RegionName); + channelUri = String.Empty; + } + else + + if (!scene.RegionInfo.EstateSettings.TaxFree && (land.Flags & (uint)ParcelFlags.AllowVoiceChat) == 0) + { + channelUri = String.Empty; + } + else + { + channelUri = ChannelUri(scene, land); + } + + // fast foward encode + osUTF8 lsl = LLSDxmlEncode2.Start(512); + LLSDxmlEncode2.AddMap(lsl); + LLSDxmlEncode2.AddElem("parcel_local_id", land.LocalID, lsl); + LLSDxmlEncode2.AddElem("region_name", scene.Name, lsl); + LLSDxmlEncode2.AddMap("voice_credentials", lsl); + LLSDxmlEncode2.AddElem("channel_uri", channelUri, lsl); + //LLSDxmlEncode2.AddElem("channel_credentials", channel_credentials, lsl); + LLSDxmlEncode2.AddEndMap(lsl); + LLSDxmlEncode2.AddEndMap(lsl); + + response.RawBuffer = LLSDxmlEncode2.EndToBytes(lsl); + } + catch (Exception e) + { + m_log.ErrorFormat("{0}[PARCELVOICE]: region \"{1}\": avatar \"{2}\": {3}, retry later", + logHeader, scene.RegionInfo.RegionName, avatarName, e.Message); + m_log.DebugFormat("{0}[PARCELVOICE]: region \"{1}\": avatar \"{2}\": {3} failed", + logHeader, scene.RegionInfo.RegionName, avatarName, e.ToString()); + + response.RawBuffer = Util.UTF8.GetBytes("undef"); + } + } + + // NOTE NOTE!! This is code from the FreeSwitch module. It is not clear if this is correct for WebRtc. + // Not sure what this Uri is for. Is this FreeSwitch specific? + // TODO: is this useful for WebRtc? + private string ChannelUri(Scene scene, LandData land) + { + string channelUri = null; + + string landUUID; + string landName; + + // Create parcel voice channel. If no parcel exists, then the voice channel ID is the same + // as the directory ID. Otherwise, it reflects the parcel's ID. + + lock (m_ParcelAddress) + { + if (m_ParcelAddress.ContainsKey(land.GlobalID.ToString())) + { + m_log.DebugFormat("{0}: parcel id {1}: using sip address {2}", + logHeader, land.GlobalID, m_ParcelAddress[land.GlobalID.ToString()]); + return m_ParcelAddress[land.GlobalID.ToString()]; + } + } + + if (land.LocalID != 1 && (land.Flags & (uint)ParcelFlags.UseEstateVoiceChan) == 0) + { + landName = String.Format("{0}:{1}", scene.RegionInfo.RegionName, land.Name); + landUUID = land.GlobalID.ToString(); + m_log.DebugFormat("{0}: Region:Parcel \"{1}\": parcel id {2}: using channel name {3}", + logHeader, landName, land.LocalID, landUUID); + } + else + { + landName = String.Format("{0}:{1}", scene.RegionInfo.RegionName, scene.RegionInfo.RegionName); + landUUID = scene.RegionInfo.RegionID.ToString(); + m_log.DebugFormat("{0}: Region:Parcel \"{1}\": parcel id {2}: using channel name {3}", + logHeader, landName, land.LocalID, landUUID); + } + + // slvoice handles the sip address differently if it begins with confctl, hiding it from the user in + // the friends list. however it also disables the personal speech indicators as well unless some + // siren14-3d codec magic happens. we dont have siren143d so we'll settle for the personal speech indicator. + channelUri = String.Format("sip:conf-{0}@{1}", + "x" + Convert.ToBase64String(Encoding.ASCII.GetBytes(landUUID)), + /*m_freeSwitchRealm*/ "webRTC"); + + lock (m_ParcelAddress) + { + if (!m_ParcelAddress.ContainsKey(land.GlobalID.ToString())) + { + m_ParcelAddress.Add(land.GlobalID.ToString(), channelUri); + } + } + + return channelUri; + } + + /// + /// Convert the LLSDXml body of the request to an OSDMap for easier handling. + /// Also logs the request if message details is enabled. + /// + /// + /// + /// 'null' if the request body is empty or cannot be deserialized + private OSDMap BodyToMap(IOSHttpRequest request, string pCaller) + { + OSDMap? map = null; + using (Stream inputStream = request.InputStream) + { + if (inputStream.Length > 0) + { + OSD tmp = OSDParser.DeserializeLLSDXml(inputStream); + if (_MessageDetails) m_log.DebugFormat("{0} BodyToMap: Request: {1}", pCaller, tmp.ToString()); + map = tmp as OSDMap; + } + } + return map; + } + + + } +} diff --git a/OpenSim/Addons/os-webrtc-janus/WebRtcVoiceServiceModule/WebRtcVoiceServiceModule.cs b/OpenSim/Addons/os-webrtc-janus/WebRtcVoiceServiceModule/WebRtcVoiceServiceModule.cs new file mode 100644 index 0000000000..f3a44fe67b --- /dev/null +++ b/OpenSim/Addons/os-webrtc-janus/WebRtcVoiceServiceModule/WebRtcVoiceServiceModule.cs @@ -0,0 +1,303 @@ +/* + * 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.Linq; +using System.Collections.Generic; +using System.Reflection; +using System.Threading.Tasks; + +using OpenSim.Framework; +using OpenSim.Region.Framework.Scenes; +using OpenSim.Region.Framework.Interfaces; +using OpenSim.Server.Base; + +using OpenMetaverse; +using OpenMetaverse.StructuredData; + +using Mono.Addins; + +using log4net; +using Nini.Config; + +[assembly: Addin("WebRtcVoiceServiceModule", "1.0")] +[assembly: AddinDependency("OpenSim.Region.Framework", OpenSim.VersionInfo.VersionNumber)] + +namespace WebRtcVoice +{ + /// + /// Interface for the WebRtcVoiceService. + /// An instance of this is registered as the IWebRtcVoiceService for this region. + /// The function here is to direct the capability requests to the appropriate voice service. + /// For the moment, there are separate voice services for spatial and non-spatial voice + /// with the idea that a region could have a pre-region spatial voice service while + /// the grid could have a non-spatial voice service for group chat, etc. + /// Fancier configurations are possible. + /// + [Extension(Path = "/OpenSim/RegionModules", NodeName = "RegionModule", Id = "WebRtcVoiceServiceModule")] + public class WebRtcVoiceServiceModule : ISharedRegionModule, IWebRtcVoiceService + { + private static readonly ILog m_log = LogManager.GetLogger(MethodBase.GetCurrentMethod().DeclaringType); + private static string LogHeader = "[WEBRTC VOICE SERVICE MODULE]"; + + private static bool m_Enabled = false; + private IConfigSource m_Config; + + private IWebRtcVoiceService m_spatialVoiceService; + private IWebRtcVoiceService m_nonSpatialVoiceService; + + // ===================================================================== + + // ISharedRegionModule.Initialize + // Get configuration and load the modules that will handle spatial and non-spatial voice. + public void Initialise(IConfigSource pConfig) + { + m_Config = pConfig; + IConfig moduleConfig = m_Config.Configs["WebRtcVoice"]; + + if (moduleConfig is not null) + { + m_Enabled = moduleConfig.GetBoolean("Enabled", false); + if (m_Enabled) + { + // Get the DLLs for the two voice services + string spatialDllName = moduleConfig.GetString("SpatialVoiceService", String.Empty); + string nonSpatialDllName = moduleConfig.GetString("NonSpatialVoiceService", String.Empty); + if (String.IsNullOrEmpty(spatialDllName) && String.IsNullOrEmpty(nonSpatialDllName)) + { + m_log.ErrorFormat("{0} No SpatialVoiceService or NonSpatialVoiceService specified in configuration", LogHeader); + m_Enabled = false; + } + + // Default non-spatial to spatial if not specified + if (String.IsNullOrEmpty(nonSpatialDllName)) + { + m_log.DebugFormat("{0} nonSpatialDllName not specified. Defaulting to spatialDllName", LogHeader); + nonSpatialDllName = spatialDllName; + } + + // Load the two voice services + m_log.DebugFormat("{0} Loading SpatialVoiceService from {1}", LogHeader, spatialDllName); + m_spatialVoiceService = ServerUtils.LoadPlugin(spatialDllName, new object[] { m_Config }); + if (m_spatialVoiceService is null) + { + m_log.ErrorFormat("{0} Could not load SpatialVoiceService from {1}", LogHeader, spatialDllName); + m_Enabled = false; + } + + m_log.DebugFormat("{0} Loading NonSpatialVoiceService from {1}", LogHeader, nonSpatialDllName); + if (spatialDllName == nonSpatialDllName) + { + m_log.DebugFormat("{0} NonSpatialVoiceService is same as SpatialVoiceService", LogHeader); + m_nonSpatialVoiceService = m_spatialVoiceService; + } + else + { + m_nonSpatialVoiceService = ServerUtils.LoadPlugin(nonSpatialDllName, new object[] { m_Config }); + if (m_nonSpatialVoiceService is null) + { + m_log.ErrorFormat("{0} Could not load NonSpatialVoiceService from {1}", LogHeader, nonSpatialDllName); + m_Enabled = false; + } + } + + if (m_Enabled) + { + m_log.InfoFormat("{0} WebRtcVoiceService enabled", LogHeader); + } + } + } + } + + // ISharedRegionModule.PostInitialize + public void PostInitialise() + { + } + + // ISharedRegionModule.Close + public void Close() + { + } + + // ISharedRegionModule.ReplaceableInterface + public Type ReplaceableInterface + { + get { return null; } + } + + // ISharedRegionModule.Name + public string Name + { + get { return "WebRtcVoiceServiceModule"; } + } + + // ISharedRegionModule.AddRegion + public void AddRegion(Scene scene) + { + if (m_Enabled) + { + m_log.DebugFormat("{0} Adding WebRtcVoiceService to region {1}", LogHeader, scene.Name); + scene.RegisterModuleInterface(this); + + // TODO: figure out what events we care about + // When new client (child or root) is added to scene, before OnClientLogin + // scene.EventManager.OnNewClient += Event_OnNewClient; + // When client is added on login. + // scene.EventManager.OnClientLogin += Event_OnClientLogin; + // New presence is added to scene. Child, root, and NPC. See Scene.AddNewAgent() + // scene.EventManager.OnNewPresence += Event_OnNewPresence; + // scene.EventManager.OnRemovePresence += Event_OnRemovePresence; + // update to client position (either this or 'significant') + // scene.EventManager.OnClientMovement += Event_OnClientMovement; + // "significant" update to client position + // scene.EventManager.OnSignificantClientMovement += Event_OnSignificantClientMovement; + } + + } + + // ISharedRegionModule.RemoveRegion + public void RemoveRegion(Scene scene) + { + if (m_Enabled) + { + scene.UnregisterModuleInterface(this); + } + } + + // ISharedRegionModule.RegionLoaded + public void RegionLoaded(Scene scene) + { + } + + // ===================================================================== + // Thought about doing this but currently relying on the voice service + // event ("hangup") to remove the viewer session. + private void Event_OnRemovePresence(UUID pAgentID) + { + // When a presence is removed, remove the viewer sessions for that agent + IEnumerable> vSessions; + if (VoiceViewerSession.TryGetViewerSessionByAgentId(pAgentID, out vSessions)) + { + foreach(KeyValuePair v in vSessions) + { + m_log.DebugFormat("{0} Event_OnRemovePresence: removing viewer session {1}", LogHeader, v.Key); + VoiceViewerSession.RemoveViewerSession(v.Key); + v.Value.Shutdown(); + } + } + } + // ===================================================================== + // IWebRtcVoiceService + + // IWebRtcVoiceService.ProvisionVoiceAccountRequest + public async Task ProvisionVoiceAccountRequest(OSDMap pRequest, UUID pUserID, UUID pSceneID) + { + OSDMap response = null; + IVoiceViewerSession vSession = null; + if (pRequest.ContainsKey("viewer_session")) + { + // request has a viewer session. Use that to find the voice service + string viewerSessionId = pRequest["viewer_session"].AsString(); + if (!VoiceViewerSession.TryGetViewerSession(viewerSessionId, out vSession)) + { + m_log.ErrorFormat("{0} ProvisionVoiceAccountRequest: viewer session {1} not found", LogHeader, viewerSessionId); + } + } + else + { + // the request does not have a viewer session. See if it's an initial request + if (pRequest.ContainsKey("channel_type")) + { + string channelType = pRequest["channel_type"].AsString(); + if (channelType == "local") + { + // TODO: check if this userId is making a new session (case that user is reconnecting) + vSession = m_spatialVoiceService.CreateViewerSession(pRequest, pUserID, pSceneID); + VoiceViewerSession.AddViewerSession(vSession); + } + else + { + // TODO: check if this userId is making a new session (case that user is reconnecting) + vSession = m_nonSpatialVoiceService.CreateViewerSession(pRequest, pUserID, pSceneID); + VoiceViewerSession.AddViewerSession(vSession); + } + } + else + { + m_log.ErrorFormat("{0} ProvisionVoiceAccountRequest: no channel_type in request", LogHeader); + } + } + if (vSession is not null) + { + response = await vSession.VoiceService.ProvisionVoiceAccountRequest(vSession, pRequest, pUserID, pSceneID); + } + return response; + } + + // IWebRtcVoiceService.VoiceSignalingRequest + public async Task VoiceSignalingRequest(OSDMap pRequest, UUID pUserID, UUID pSceneID) + { + OSDMap response = null; + IVoiceViewerSession vSession = null; + if (pRequest.ContainsKey("viewer_session")) + { + // request has a viewer session. Use that to find the voice service + string viewerSessionId = pRequest["viewer_session"].AsString(); + if (VoiceViewerSession.TryGetViewerSession(viewerSessionId, out vSession)) + { + response = await vSession.VoiceService.VoiceSignalingRequest(vSession, pRequest, pUserID, pSceneID); + } + else + { + m_log.ErrorFormat("{0} VoiceSignalingRequest: viewer session {1} not found", LogHeader, viewerSessionId); + } + } + else + { + m_log.ErrorFormat("{0} VoiceSignalingRequest: no viewer_session in request", LogHeader); + } + return response; + } + + // This module should never be called with this signature + public Task ProvisionVoiceAccountRequest(IVoiceViewerSession pVSession, OSDMap pRequest, UUID pUserID, UUID pSceneID) + { + throw new NotImplementedException(); + } + + // This module should never be called with this signature + public Task VoiceSignalingRequest(IVoiceViewerSession pVSession, OSDMap pRequest, UUID pUserID, UUID pSceneID) + { + throw new NotImplementedException(); + } + + public IVoiceViewerSession CreateViewerSession(OSDMap pRequest, UUID pUserID, UUID pSceneID) + { + throw new NotImplementedException(); + } + } +} diff --git a/bin/config/os-webrtc-janus.ini b/bin/config/os-webrtc-janus.ini new file mode 100644 index 0000000000..c6b4465c7b --- /dev/null +++ b/bin/config/os-webrtc-janus.ini @@ -0,0 +1,29 @@ +[WebRtcVoice] + Enabled=false + ; Module to use for spatial WebRtcVoice + ; The following is for a Janus service just for spatial voice for this region. Config in [JanusWebRtcVoice] + SpatialVoiceService = WebRtcJanusService.dll:WebRtcJanusService + ; The following is for a grid spatial voice service for this region. Link is WebRtcVoiceServiceURI + ; SpatialVoiceService=WebRtcVoice.dll:WebRtcVoiceServiceConnector + ; Module to use for non-spatial WebRtcVoice. Local voice service. Config in [JanusWebRtcVoice] + NonSpatialVoiceService = WebRtcJanusService.dll:WebRtcJanusService + ; The following is for a grid non-spatial voice service. For groups and IMs. Link is WebRtcVoiceServiceURI + ; NonSpatialVoiceService=WebRtcVoice.dll:WebRtcVoiceServiceConnector + ; URL for the grid service that is providing the WebRtcVoiceService. Used by WebRtcVoiceServiceConnector. + WebRtcVoiceServerURI = ${Const|PrivURL}:${Const|PrivatePort} + ; Debugging: output to log file messages sent and received from the viewer. Very verbose. + MessageDetails = false + +[JanusWebRtcVoice] + ; URI to access the Janus Gateway + JanusGatewayURI = http://janus.example.org:14223/voice + ; APIKey to access the Janus Gateway. Must be set to the same value as the Janus Gateway. + APIToken = APITokenToNeverCheckIn + ; URI to access the admin port on Janus Gateway + JanusGatewayAdminURI = http://janus.example.org/admin + ; APIKey to access the admin port on the Janus Gateway. Must be set to the same value as the Janus Gateway. + AdminAPIToken = AdminAPITokenToNeverCheckIn + ; Debugging: output to log file messages sent and received from Janus. Very verbose. + MessageDetails = false + + diff --git a/prebuild.xml b/prebuild.xml index 6ec2f0dd4d..c9f8db43e2 100644 --- a/prebuild.xml +++ b/prebuild.xml @@ -2037,6 +2037,196 @@ + + + + + ../../../../bin/ + true + + + + + ../../../../bin/ + true + + + + ../../../../bin/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ../../../../bin/ + true + + + + + ../../../../bin/ + true + + + + ../../../../bin/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ../../../../bin/ + true + + + + + ../../../../bin/ + true + + + + ../../../../bin/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ../../../../bin/ + + + + + ../../../../bin/ + + + + ../../../../bin/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +