using System; using System.Collections; using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Linq; using System.Net; using System.Net.Http; using System.Net.Http.Headers; using System.Net.Security; using System.Net.Sockets; using System.Net.WebSockets; using System.Text; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Channels; using System.Threading.Tasks; using ConVar; using Facepunch; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using Oxide.Core; using UnityEngine; using GC = System.GC; using Pool = Facepunch.Pool; //Reference: System.Threading.Channels //Reference: System.Net.Http //Reference: Facepunch.Sqlite namespace Oxide.Plugins; internal static class PluginVersion { public const string Version = "1.1.9"; } [Info("ToxVoice", "Maxaki", PluginVersion.Version)] [Description("Transcribes voice chat to text and filters, logs, and alerts based on user-defined rules.")] public class ToxVoice : RustPlugin { private readonly VoiceNetworking _networking; private readonly PlayerVoiceSink _voiceSink; private readonly CancellationTokenSource _shutdownCts = new(); private readonly ConfigurationFile _pluginConfig; private static readonly StringBuilder ToxVoiceCommandBuilder = new(); private const string WhitelistPermission = "toxvoice.whitelist"; private readonly HashSet _whitelistedUsers = new(); public ToxVoice() { _pluginConfig = ConfigurationFile.LoadConfiguration(); _networking = new VoiceNetworking(_pluginConfig, _shutdownCts.Token); _voiceSink = new PlayerVoiceSink(_networking, _pluginConfig, _shutdownCts.Token); } private void Init() { permission.RegisterPermission(WhitelistPermission, this); } private void OnPlayerConnected(BasePlayer player) { if (permission.UserHasPermission(player.UserIDString, WhitelistPermission)) { _whitelistedUsers.Add(player.UserIDString); } } private void OnPlayerDisconnected(BasePlayer player, string reason) { _whitelistedUsers.Remove(player.UserIDString); } private void OnServerInitialized() { Rust.Global.Runner.StartCoroutine(HandlePlayerInitializations()); ToxVoicePersistence.Init(); Task.Run(_networking.StartAsync).ConfigureAwait(false); Task.Run(_voiceSink.StartAsync).ConfigureAwait(false); } private IEnumerator HandlePlayerInitializations() { var activePlayers = BasePlayer.activePlayerList; foreach (var basePlayer in activePlayers) { OnPlayerConnected(basePlayer); yield return null; } } private void OnPlayerVoice(BasePlayer player, byte[] data) { if (_whitelistedUsers.Contains(player.UserIDString)) return; var voicePacket = SharedObjectPool.Get(); voicePacket.Init(player.userID, player.displayName, data); _voiceSink.TryWrite(voicePacket); } private void Unload() { if (!_shutdownCts.IsCancellationRequested) _shutdownCts.Cancel(); try { _voiceSink.Dispose(); } catch { // ignored } try { _networking.Dispose(); } catch { // ignored } ToxVoicePersistence.Close(); } private void OnUserPermissionGranted(string playerId, string perm) { if (perm != WhitelistPermission) return; _whitelistedUsers.Add(playerId); } private void OnUserPermissionRevoked(string playerId, string perm) { if (perm != WhitelistPermission) return; _whitelistedUsers.Remove(playerId); } public static void UnloadPlugin(string reason) { Threading.QueueOnMainThread(() => { Interface.Oxide.UnloadPlugin(nameof(ToxVoice)); Logger.Error(reason); }); } protected override void SaveConfig() { Config.WriteObject(_pluginConfig); } [ConsoleCommand("toxvoice")] private void ToxVoiceCommand(ConsoleSystem.Arg arg) { try { var args = arg.FullString.Split(' '); if (args.Length < 2) { arg.ReplyWith("[ToxVoice] Invalid command. Usage: toxvoice "); return; } var subcommand = arg.GetString(0).ToLower(); var parameter = arg.GetString(1); ToxVoiceCommandBuilder.AppendLine("{"); switch (subcommand) { case "steam": if (ulong.TryParse(parameter, out _)) { var toxVoiceUserId = ToxVoicePersistence.GetToxVoiceIdFromSteamId(parameter); if (!string.IsNullOrEmpty(toxVoiceUserId)) { ToxVoiceCommandBuilder.AppendLine($" \"SteamID\": {parameter},"); ToxVoiceCommandBuilder.AppendLine($" \"ToxVoiceUserID\": \"{toxVoiceUserId}\""); } else { ToxVoiceCommandBuilder.AppendLine($" \"Error\": \"No ToxVoiceUserID found for SteamID: {parameter}\""); } } else { ToxVoiceCommandBuilder.AppendLine(" \"Error\": \"Invalid SteamID. Please provide a valid SteamID.\""); } break; case "id": if (Guid.TryParse(parameter, out _)) { var steamId = ToxVoicePersistence.GetSteamIdFromToxVoiceId(parameter); if (!string.IsNullOrEmpty(steamId)) { ToxVoiceCommandBuilder.AppendLine($" \"ToxVoiceUserID\": \"{parameter}\","); ToxVoiceCommandBuilder.AppendLine($" \"SteamID\": {steamId}"); } else { ToxVoiceCommandBuilder.AppendLine($" \"Error\": \"No SteamID found for ToxVoiceUserID: {parameter}\""); } } else { ToxVoiceCommandBuilder.AppendLine(" \"Error\": \"Invalid ToxVoiceUserID. Please provide a valid GUID.\""); } break; case "reset": if (parameter == "all") { ToxVoicePersistence.ResetAllViolations(); arg.ReplyWith("[ToxVoice] All player violations have been reset."); return; } else if (ulong.TryParse(parameter, out _)) { ToxVoicePersistence.ResetPlayerViolation(parameter); arg.ReplyWith($"[ToxVoice] Violations for player with SteamID {parameter} have been reset."); return; } else { arg.ReplyWith("[ToxVoice] Invalid parameter. Please provide a valid SteamID or use 'all' to reset all violations."); return; } break; case "whitelist": if (!ulong.TryParse(parameter, out _)) { arg.ReplyWith("[ToxVoice] Invalid parameter. Please provide a valid SteamID"); return; } if (permission.UserHasPermission(parameter, WhitelistPermission)) { permission.RevokeUserPermission(parameter, WhitelistPermission); arg.ReplyWith($"[ToxVoice] User({parameter}) removed from whitelist."); return; } else { permission.GrantUserPermission(parameter, WhitelistPermission, this); arg.ReplyWith($"[ToxVoice] User({parameter}) added to whitelist."); return; } break; default: ToxVoiceCommandBuilder.AppendLine($" \"Error\": \"Unknown subcommand: {subcommand}\""); break; } ToxVoiceCommandBuilder.AppendLine("}"); var jsonString = ToxVoiceCommandBuilder.ToString(); arg.ReplyWith($"[ToxVoice]\n{jsonString}"); } finally { ToxVoiceCommandBuilder.Clear(); } } public class ConfigurationFile { private static readonly string ConfigFile = Path.Combine(Interface.Oxide.ConfigDirectory, "ToxVoice.json"); public ConfigurationFile() { } public ToxVoiceConfiguration ToxVoice { get; set; } = new(); public TranscriptionLogs TranscriptionLogs { get; set; } = new(); public WeightConfiguration WeightConfiguration { get; set; } = new(); public TriggerFilterConfiguration TriggerFilter { get; set; } = new(); public static List GetDefaultFilters() => new() { new TriggerFilter {Regex = false, Triggers = new List {"word"}, Weight = 8}, new TriggerFilter {Regex = false, Triggers = new List {"word1", "word2"}, Weight = 3}, new TriggerFilter {Regex = false, Triggers = new List {"testing"}, Weight = 50}, new TriggerFilter {Regex = true, Triggers = new List {@"\bword1\b.*\bword2\b.*\bword3\b"}, Weight = 8} }; public bool TryCreateDiscordLogsHttpClient(out DiscordHttpClient? discordHttpClient) { if (TranscriptionLogs.DiscordLog.Enabled) { if (Uri.TryCreate(TranscriptionLogs.DiscordLog.WebhookUrl, UriKind.Absolute, out var uri)) { discordHttpClient = new DiscordHttpClient(uri, TranscriptionLogs.DiscordLog.HideSteamId, TranscriptionLogs.ProximityLogs); return true; } } discordHttpClient = default; return false; } public bool TryCreateTranscriptionFilter(out TranscriptionFilter? transcriptionFilter) { if (TriggerFilter.Enabled) { transcriptionFilter = new TranscriptionFilter(this); return true; } transcriptionFilter = default; return false; } public bool TryCreateDiscordAlertHttpClient(out DiscordHttpClient? discordHttpClient) { if (WeightConfiguration.DiscordWeightThreshold.Enabled) { if (Uri.TryCreate(WeightConfiguration.DiscordWeightThreshold.AlertWebhookUrl, UriKind.Absolute, out var uri)) { discordHttpClient = new DiscordHttpClient(uri, TranscriptionLogs.DiscordLog.HideSteamId, TranscriptionLogs.ProximityLogs); return true; } } discordHttpClient = default; return false; } private static void SaveConfiguration(ConfigurationFile config) { var settings = new JsonSerializerSettings { DefaultValueHandling = DefaultValueHandling.Include, NullValueHandling = NullValueHandling.Ignore, Formatting = Formatting.Indented }; var json = JsonConvert.SerializeObject(config, settings); File.WriteAllText(ConfigFile, json); } public static ConfigurationFile LoadConfiguration() { if (!File.Exists(ConfigFile)) { var config = new ConfigurationFile(); SaveConfiguration(config); return config; } var json = File.ReadAllText(ConfigFile); var settings = new JsonSerializerSettings { DefaultValueHandling = DefaultValueHandling.Populate, NullValueHandling = NullValueHandling.Ignore, ObjectCreationHandling = ObjectCreationHandling.Replace }; var existingConfig = JsonConvert.DeserializeObject(json, settings) ?? new ConfigurationFile(); SaveConfiguration(existingConfig); return existingConfig; } } public class RecipientInfo : Pool.IPooled { public ulong UserId { get; set; } public string DisplayName { get; set; } = string.Empty; public float Distance { get; set; } public long Timestamp { get; set; } public void Init(ulong userId, string displayName, float distance, long timestamp) { UserId = userId; DisplayName = displayName; Distance = distance; Timestamp = timestamp; } public void EnterPool() { UserId = 0; DisplayName = string.Empty; Distance = 0; Timestamp = 0; } public void LeavePool() { } } public class VoicePacket : Pool.IPooled { public ulong UserId { get; private set; } public string DisplayName { get; set; } = string.Empty; public byte[] Data { get; private set; } = Array.Empty(); public List Recipients { get; private set; } = new(); public void Init(ulong userId, string displayName, byte[] data) { UserId = userId; DisplayName = displayName; Data = data; } public void EnterPool() { UserId = 0; DisplayName = string.Empty; Data = Array.Empty(); foreach (var recipientInfo in Recipients) { SharedObjectPool.Return(recipientInfo); } Recipients.Clear(); } public void LeavePool() { } } private class PlayerVoiceSink : IDisposable { private bool _disposed; private readonly VoiceNetworking _voiceNetworking; private readonly CancellationToken _shutdownToken; private readonly List _idleEntries = new(); private readonly ConcurrentDictionary _playerVoiceContexts = new(); private readonly Channel _voiceSink = Channel.CreateUnbounded(); private readonly TimeSpan _idleThreshold = TimeSpan.FromMinutes(1); private readonly bool _proximityLogs; public PlayerVoiceSink(VoiceNetworking voiceNetworking, ConfigurationFile config, CancellationToken shutdownToken) { _voiceNetworking = voiceNetworking; _proximityLogs = config.TranscriptionLogs.ProximityLogs; _shutdownToken = shutdownToken; } public void TryWrite(VoicePacket voicePacket) { _voiceSink.Writer.TryWrite(voicePacket); } public async Task StartAsync() { await using var timer = new System.Threading.Timer(Callback, null, 1000, 1000); try { while (await _voiceSink.Reader.WaitToReadAsync(_shutdownToken).ConfigureAwait(false)) { while (_voiceSink.Reader.TryRead(out var voicePacket)) { GetOrAddPlayerVoiceRecording(voicePacket.UserId, voicePacket.DisplayName).Enqueue(ref voicePacket); } } } catch (OperationCanceledException) { } catch (Exception e) { Logger.Error($"Unexpected exception in voice sink: {e.Message}"); } } private void Callback(object state) { if (_disposed) return; _idleEntries.Clear(); foreach (var playerVoiceContext in _playerVoiceContexts) { if (playerVoiceContext.Value.IsIdle(_idleThreshold)) { _idleEntries.Add(playerVoiceContext.Key); continue; } if (!playerVoiceContext.Value.IsReady()) { continue; } _voiceNetworking.TryWrite(playerVoiceContext.Value); } foreach (var idleEntry in _idleEntries) { if (!_playerVoiceContexts.TryRemove(idleEntry, out var removedContext)) continue; if (!removedContext.IsIdle(_idleThreshold)) { _playerVoiceContexts.TryAdd(idleEntry, removedContext); continue; } removedContext.Dispose(); } } private PlayerVoiceContext GetOrAddPlayerVoiceRecording(ulong userId, string displayName) { if (_playerVoiceContexts.TryGetValue(userId, out var playerVoiceRecording)) return playerVoiceRecording; playerVoiceRecording = new PlayerVoiceContext(userId, displayName, _proximityLogs); _playerVoiceContexts[userId] = playerVoiceRecording; return playerVoiceRecording; } public void Dispose() { if (_disposed) return; _disposed = true; foreach (var playerVoiceContext in _playerVoiceContexts) { playerVoiceContext.Value.Dispose(); } _playerVoiceContexts.Clear(); _voiceSink.Writer.TryComplete(); } } private static class Logger { private const string Prefix = "[ToxVoice]"; internal static void Info(string message) { if (Thread.CurrentThread.ManagedThreadId == 1) { Debug.Log($"{Prefix} {message}"); return; } Threading.QueueOnMainThread(() => { Debug.Log($"{Prefix} {message}"); }); } internal static void Error(string message) { if (Thread.CurrentThread.ManagedThreadId == 1) { Debug.LogError($"{Prefix} {message}"); return; } Threading.QueueOnMainThread(() => { Debug.LogError($"{Prefix} {message}"); }); } internal static void Warning(string message) { if (Thread.CurrentThread.ManagedThreadId == 1) { Debug.LogWarning($"{Prefix} {message}"); return; } Threading.QueueOnMainThread(() => { Debug.LogWarning($"{Prefix} {message}"); }); } } private static class ToxVoicePersistence { private static readonly Facepunch.Sqlite.Database Identity = new(); private static readonly ConcurrentDictionary ToxVoiceUserIdCache = new(); private static readonly object Lock = new(); private static readonly string DataFile = Path.Combine(Interface.Oxide.DataDirectory, "ToxVoice.db"); static ToxVoicePersistence() { } public static void Init() { lock (Lock) { Identity.Open(DataFile); Identity.Execute("CREATE TABLE IF NOT EXISTS users (steamid TEXT PRIMARY KEY, toxvoiceuserid TEXT)"); Identity.Execute("CREATE TABLE IF NOT EXISTS violations (steamid TEXT PRIMARY KEY, violationcount INTEGER)"); } } public static void Close() { try { lock (Lock) { Identity.Close(); } } catch (Exception exception) { Logger.Error("Error closing database: " + exception.Message); } finally { //This is REQUIRED to fully close the database GC.Collect(); GC.WaitForPendingFinalizers(); } } public static void ResetAllViolations() { lock (Lock) { Identity.Execute("DELETE FROM violations"); } } public static void ResetPlayerViolation(string steamId) { lock (Lock) { Identity.Execute("DELETE FROM violations WHERE steamid = ?", steamId); } } public static int IncrementViolationCount(string steamId) { lock (Lock) { var existingCount = Identity.Query("SELECT violationcount FROM violations WHERE steamid = ?", steamId); if (existingCount > 0) { Identity.Execute("UPDATE violations SET violationcount = violationcount + 1 WHERE steamid = ?", steamId); return existingCount + 1; } Identity.Execute("INSERT INTO violations (steamid, violationcount) VALUES (?, 1)", steamId); return 1; } } public static string GetOrGenerateToxVoiceId(string playerID) { if (Identity == null) { throw new InvalidOperationException("Identity database is not initialized."); } var toxVoiceId = ToxVoiceUserIdCache.FirstOrDefault(x => x.Value == playerID).Key; if (!string.IsNullOrEmpty(toxVoiceId)) { return toxVoiceId; } lock (Lock) { var toxVoiceUserId = Identity.Query("SELECT toxvoiceuserid FROM users WHERE steamid = ?", playerID); if (!string.IsNullOrEmpty(toxVoiceUserId)) { ToxVoiceUserIdCache[toxVoiceUserId] = playerID; return toxVoiceUserId; } toxVoiceId = GenerateId(); Identity.Execute("INSERT INTO users (steamid, toxvoiceuserid) VALUES (?, ?)", playerID, toxVoiceId); ToxVoiceUserIdCache[toxVoiceId] = playerID; } return toxVoiceId; } public static string GetSteamIdFromToxVoiceIdCache(string toxVoiceUserId) => ToxVoiceUserIdCache.GetValueOrDefault(toxVoiceUserId, string.Empty); public static string GetSteamIdFromToxVoiceId(string toxVoiceUserId) { if (ToxVoiceUserIdCache.TryGetValue(toxVoiceUserId, out var steamId)) { return steamId; } lock (Lock) { steamId = Identity.Query("SELECT steamid FROM users WHERE toxvoiceuserid = ?", toxVoiceUserId); if (!string.IsNullOrEmpty(steamId)) { ToxVoiceUserIdCache[toxVoiceUserId] = steamId; } } return steamId; } public static string GetToxVoiceIdFromSteamId(string userId) { var toxVoiceUserId = ToxVoiceUserIdCache.FirstOrDefault(x => x.Value == userId).Key; if (!string.IsNullOrEmpty(toxVoiceUserId)) { return toxVoiceUserId; } lock (Lock) { toxVoiceUserId = Identity.Query("SELECT toxvoiceuserid FROM users WHERE steamid = ?", userId); if (!string.IsNullOrEmpty(toxVoiceUserId)) { ToxVoiceUserIdCache[toxVoiceUserId] = userId; } } return toxVoiceUserId; } private static string GenerateId() => Guid.NewGuid().ToString("N"); } public class TranscriptionFilter { public readonly List Filters; public TranscriptionFilter(ConfigurationFile configurationFile) { Filters = configurationFile.TriggerFilter.Filters; } public int GetViolatedFilterWeight(string text) => GetViolatedFilterWeightCore(text, Filters); private static int GetViolatedFilterWeightCore(string text, List filters) { var totalWeight = 0; foreach (var filter in filters) { if (filter.Regex) { if (!IsRegexMatch(text, filter.Triggers)) continue; } else { if (!IsWordListContained(text, filter)) continue; } totalWeight += filter.Weight; } return totalWeight; } private static bool IsRegexMatch(string text, List regexPatterns) { foreach (var pattern in regexPatterns) { try { if (!Regex.IsMatch(text, pattern, RegexOptions.IgnoreCase)) { return false; } } catch (Exception exception) { Logger.Warning("Error in regex pattern: " + pattern); } } return true; } private static bool IsWordListContained(ReadOnlySpan text, TriggerFilter triggerFilter) { foreach (var word in triggerFilter.Triggers) { if (!ContainsWord(text, word.AsSpan())) { return false; } } return true; } private static bool ContainsWord(ReadOnlySpan text, ReadOnlySpan word) { var textLength = text.Length; var wordLength = word.Length; for (var i = 0; i <= textLength - wordLength; i++) { var substring = text.Slice(i, wordLength); if (substring.Length == wordLength && substring.Equals(word, StringComparison.OrdinalIgnoreCase)) { return true; } } return false; } } public class TranscriptionResult : Pool.IPooled { public Dictionary Metadata { get; set; } = new(); public byte[] AudioData { get; set; } public void EnterPool() { Metadata.Clear(); AudioData = Array.Empty(); } public void LeavePool() { } } public class DiscordHttpClient : IDisposable { private bool _disposed; private readonly bool _hideSteamId; private readonly bool _proximityLogs; private readonly HttpClient _httpClient; private readonly StringBuilder _multiPartBuilder = new(); private readonly JsonSerializerSettings _recipientSerializer = new() { Converters = new List {new PooledRecipientInfoConverter()} }; public DiscordHttpClient(Uri webhookUri, bool hideSteamId, bool proximityLogs) { _hideSteamId = hideSteamId; _proximityLogs = proximityLogs; _httpClient = new HttpClient(); _httpClient.BaseAddress = webhookUri; } private MultipartFormDataContent CreateMultipartContent(string steamId, TranscriptionResult transcription, int violatedFilterWeight, int violations) { var content = new MultipartFormDataContent(); var userId = transcription.GetString("ToxVoiceUserId"); var displayName = transcription.GetString("DisplayName"); var position = transcription.GetString("Position"); var text = transcription.GetString("Text"); var credits = transcription.GetCredits(); if (!_hideSteamId && !string.IsNullOrEmpty(steamId)) { userId = steamId; } try { _multiPartBuilder.AppendLine("```md"); _multiPartBuilder.AppendLine("# Transcript"); _multiPartBuilder.AppendLine($"- Player: {displayName} ({userId})"); _multiPartBuilder.AppendLine($"- Position: {position}"); _multiPartBuilder.AppendLine("- Text: {text}"); if (violatedFilterWeight > 0) { _multiPartBuilder.AppendLine("# Moderation"); _multiPartBuilder.AppendLine($"- Weight: {violatedFilterWeight}"); if (violations > 0) _multiPartBuilder.AppendLine($"- Violations: {violations}"); } if (_proximityLogs && transcription.Metadata.TryGetValue("Recipients", out var recipientsJson)) { _multiPartBuilder.AppendLine("# Proximity"); AppendRecipientInfo(_multiPartBuilder, recipientsJson); } if (credits < 30) { _multiPartBuilder.AppendLine("# System"); _multiPartBuilder.AppendLine($"- **WARNING: Low credits ({(int)credits})**"); } _multiPartBuilder.Append("```"); var contentWithoutText = _multiPartBuilder.ToString(); var availableSpace = 2000 - contentWithoutText.Length + 6; if (text.Length > availableSpace) { text = text.Substring(0, availableSpace - 3) + "..."; } var finalContent = contentWithoutText.Replace("{text}", text); var textContent = new StringContent(finalContent); content.Add(textContent, "content"); var fileContent = new ByteArrayContent(transcription.AudioData); fileContent.Headers.ContentType = MediaTypeHeaderValue.Parse("audio/mp3"); content.Add(fileContent, "file", $"{userId}.mp3"); return content; } finally { _multiPartBuilder.Clear(); } } private void AppendRecipientInfo(StringBuilder sb, string recipientsJson) { try { var recipients = JsonConvert.DeserializeObject>(recipientsJson, _recipientSerializer); if (recipients is {Count: > 0}) { foreach (var recipient in recipients) { sb.AppendLine($"- Player: {recipient.DisplayName} ({recipient.UserId})"); sb.AppendLine($" Distance: {recipient.Distance:F2}"); SharedObjectPool.Return(recipient); } } else { sb.AppendLine("- No recipients found."); } } catch (Exception ex) { sb.AppendLine($"- Error parsing recipient data: {ex.Message}"); } } public async Task SendMessageWithRetryAsync(string steamId, TranscriptionResult transcription, int violatedFilterWeight, int violations, CancellationToken cancellationToken) { const int maxRetries = 3; const int retryDelay = 5000; using var content = CreateMultipartContent(steamId, transcription, violatedFilterWeight, violations); for (var retry = 0; retry < maxRetries; retry++) { if (cancellationToken.IsCancellationRequested) return new HttpResponseMessage(HttpStatusCode.InternalServerError); try { var response = await _httpClient.PostAsync("", content, cancellationToken).ConfigureAwait(false); if (response.IsSuccessStatusCode) return response; if (IsRateLimited(response)) { var resetAfter = GetRateLimitResetAfter(response); Logger.Warning($"Discord Rate limited. Retrying after {resetAfter.TotalSeconds} seconds."); await Task.Delay(resetAfter, cancellationToken).ConfigureAwait(false); continue; } } catch (OperationCanceledException) { throw; } catch (Exception exception) { Logger.Warning($"Failed to upload discord file. Retry attempt: {retry + 1}\n{exception.Message}"); } await Task.Delay(retryDelay, cancellationToken).ConfigureAwait(false); } return new HttpResponseMessage(HttpStatusCode.InternalServerError); } private static TimeSpan GetRateLimitResetAfter(HttpResponseMessage response) { if (response.Headers.TryGetValues("X-RateLimit-Reset-After", out var resetAfterValues) && double.TryParse(resetAfterValues.FirstOrDefault(), out var resetAfterSeconds)) { return TimeSpan.FromSeconds(resetAfterSeconds); } if (!response.Headers.TryGetValues("X-RateLimit-Reset", out var resetValues) || !long.TryParse(resetValues.FirstOrDefault(), out var resetTimestamp)) return TimeSpan.FromSeconds(60); var resetTime = DateTimeOffset.FromUnixTimeSeconds(resetTimestamp); var currentTime = DateTimeOffset.UtcNow; return resetTime - currentTime; } private static bool IsRateLimited(HttpResponseMessage response) => response.StatusCode == HttpStatusCode.TooManyRequests || response.Headers.TryGetValues("X-RateLimit-Remaining", out var values) && values.FirstOrDefault() == "0"; public async Task SendInsufficientCreditsWarningAsync(CancellationToken cancellationToken) { const string warningMessage = "**:warning: WARNING: INSUFFICIENT CREDITS :warning:**\n\nThe ToxVoice plugin has been unloaded due to insufficient credits."; var content = new MultipartFormDataContent(); var embed = new { color = 0xFF0000, description = warningMessage }; var payload = new { embeds = new[] {embed} }; var jsonContent = JsonConvert.SerializeObject(payload); content.Add(new StringContent(jsonContent, Encoding.UTF8, "application/json"), "payload_json"); const int maxRetries = 3; const int retryDelay = 5000; for (var retry = 0; retry < maxRetries; retry++) { if (cancellationToken.IsCancellationRequested) return; try { var response = await _httpClient.PostAsync("", content, cancellationToken).ConfigureAwait(false); if (response.IsSuccessStatusCode) return; if (IsRateLimited(response)) { var resetAfter = GetRateLimitResetAfter(response); Logger.Warning($"Discord Rate limited. Retrying after {resetAfter.TotalSeconds} seconds."); await Task.Delay(resetAfter, cancellationToken).ConfigureAwait(false); continue; } } catch (OperationCanceledException) { throw; } catch (Exception exception) { Logger.Warning($"Failed to send insufficient credits warning to Discord. Retry attempt: {retry + 1}\n{exception.Message}"); } await Task.Delay(retryDelay, cancellationToken).ConfigureAwait(false); } Logger.Error("Failed to send insufficient credits warning to Discord after multiple attempts."); } public void Dispose() { if (_disposed) return; _disposed = true; _httpClient.Dispose(); } } private class PooledRecipientInfoConverter : JsonConverter { public override bool CanConvert(Type objectType) => objectType == typeof(RecipientInfo); public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) { var jObject = JObject.Load(reader); var recipientInfo = SharedObjectPool.Get(); recipientInfo.Init( jObject["UserId"].Value(), jObject["DisplayName"].Value(), jObject["Distance"].Value(), jObject["Timestamp"].Value() ); return recipientInfo; } public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) { throw new NotImplementedException("Writing to JSON is not implemented for this converter."); } } public class ToxVoiceConfiguration { public string Token { get; set; } = "18cf2506-6619-4d0f-b1c5-898211f079f3"; public bool IsValid() => Guid.TryParse(Token, out _); } public class TranscriptionLogs { [JsonProperty(DefaultValueHandling = DefaultValueHandling.Populate)] public bool ProximityLogs { get; set; } = false; public DiscordLogs DiscordLog { get; set; } = new(); public ConsoleLogs ConsoleLog { get; set; } = new(); public class DiscordLogs { public bool Enabled { get; set; } = false; public bool HideSteamId { get; set; } = false; public string WebhookUrl { get; set; } = "YOUR_DISCORD_WEBHOOK_URL"; } public class ConsoleLogs { public bool Enabled { get; set; } = true; } } public class WeightConfiguration { [JsonProperty(PropertyName = "DiscordWeightThreshold", ObjectCreationHandling = ObjectCreationHandling.Replace)] public DiscordWeightThreshold DiscordWeightThreshold { get; set; } = new(); [JsonProperty(PropertyName = "ViolationWeightThreshold", ObjectCreationHandling = ObjectCreationHandling.Replace)] public ViolationWeightThreshold ViolationWeightThreshold { get; set; } = new(); } public class DiscordWeightThreshold { public bool Enabled { get; set; } = false; public int TriggerAlertWeightThreshold { get; set; } = 20; public string AlertWebhookUrl { get; set; } = "WEBHOOK_URL"; } public class TriggerFilter { public int Weight { get; set; } public bool Regex { get; set; } public List Triggers { get; set; } } public class TriggerFilterConfiguration { public bool Enabled { get; set; } = false; [JsonProperty(PropertyName = "TriggerFilters", ObjectCreationHandling = ObjectCreationHandling.Replace)] public List Filters { get; set; } = ConfigurationFile.GetDefaultFilters(); } public class ViolationWeightThreshold { public bool Enabled { get; set; } = false; public int TriggerActionWeightThreshold { get; set; } = 10; [JsonProperty(PropertyName = "ViolationActions", ObjectCreationHandling = ObjectCreationHandling.Replace)] public Dictionary ViolationActions { get; set; } = new() { {1, new ViolationAction("warn {steamid} \"First warning for violating the rules\"", 30)}, {2, new ViolationAction("warn {steamid} \"Second warning for violating the rules\"", 30)}, {3, new ViolationAction("mute {steamid} 30s \"Muted for repeated rule violations\"", 30)}, {4, new ViolationAction("mute {steamid} 1m \"Muted for continued rule violations\"", 60)}, {5, new ViolationAction("mute {steamid} 5m \"Muted for persistent rule violations\"", 300)}, {6, new ViolationAction("mute {steamid} 1h \"Muted for ongoing rule violations\"", 3600)}, {7, new ViolationAction("mute {steamid} 3h \"Muted for frequent rule violations\"", 10800)}, {8, new ViolationAction("mute {steamid} 12h \"Muted for excessive rule violations\"", 43200)}, {9, new ViolationAction("mute {steamid} 1d \"Muted for numerous rule violations\"", 86400)}, {10, new ViolationAction("ban {steamid} 1d \"Banned for repeated and severe rule violations\"", 86400)} }; public ViolationAction GetViolationAction(int violationCount) { if (ViolationActions.TryGetValue(violationCount, out var violationAction)) { return violationAction; } return ViolationActions[ViolationActions.Keys.Max()]; } } public class ViolationAction { [JsonProperty("Action")] public string Action { get; set; } [JsonProperty("CooldownSeconds")] public int CooldownSeconds { get; set; } public ViolationAction(string action, int cooldownSeconds) { Action = action; CooldownSeconds = cooldownSeconds; } } public class PlayerVoiceContext : IDisposable { private readonly ulong _userId; private readonly bool _proximityLogs; private bool _disposed; private long _lastProcessedTimeTicks; private const long TimeoutThresholdTicks = TimeSpan.TicksPerSecond*5; private int _recording; private readonly ConcurrentQueue _voiceQueue = new(); private Dictionary Metadata { get; set; } = new(); private readonly List _voiceClips = new(); private readonly StringBuilder _jsonBuilder = new(1024); public PlayerVoiceContext(ulong userId, string displayName, bool proximityLogs) { _userId = userId; _proximityLogs = proximityLogs; var toxVoiceUserId = ToxVoicePersistence.GetOrGenerateToxVoiceId(userId.ToString()); Metadata["ToxVoiceUserId"] = toxVoiceUserId; Metadata["DisplayName"] = displayName; Metadata["Version"] = PluginVersion.Version; } public void Enqueue(ref VoicePacket voicePacket) { Interlocked.Exchange(ref _recording, 1); Interlocked.Exchange(ref _lastProcessedTimeTicks, DateTime.UtcNow.Ticks); _voiceQueue.Enqueue(voicePacket); } public void Reset() { Interlocked.Exchange(ref _recording, 0); Interlocked.Exchange(ref _lastProcessedTimeTicks, DateTime.UtcNow.Ticks); } public bool IsReady() { if (Interlocked.CompareExchange(ref _recording, 0, 0) == 0) return false; var count = _voiceQueue.Count; if (count > 600) return true; var lastProcessedTicks = Interlocked.Read(ref _lastProcessedTimeTicks); return count > 12 && DateTime.UtcNow.Ticks - lastProcessedTicks >= TimeoutThresholdTicks; } public bool IsIdle(TimeSpan idleThreshold) { if (Interlocked.CompareExchange(ref _recording, 0, 0) == 1) return false; var lastProcessedTicks = Interlocked.Read(ref _lastProcessedTimeTicks); return DateTime.UtcNow.Ticks - lastProcessedTicks >= idleThreshold.Ticks; } private List GetVoicePackets() { _voiceClips.Clear(); while (_voiceQueue.TryDequeue(out var voicePacket)) { if (voicePacket.Data.Length <= 12) continue; _voiceClips.Add(voicePacket); } return _voiceClips; } public async Task WriteBytes(MemoryStream reusableStream) { reusableStream.SetLength(0); var voicePackets = GetVoicePackets(); var currentPosition = string.Empty; var recipientInfos = await InvokeMainThreadAsync(() => { var player = RelationshipManager.FindByID(_userId); if (player is null) return default; currentPosition = player.transform.position.ToString(); return _proximityLogs ? SphereHelper.GetRecipientsWithin(player) : default; }).ConfigureAwait(false); if (recipientInfos is {Count: > 0}) { recipientInfos.Sort((a, b) => a.Distance.CompareTo(b.Distance)); if (recipientInfos.Count > 5) { recipientInfos.RemoveRange(5, recipientInfos.Count - 5); } Metadata["Recipients"] = BuildRecipientsJsonFromPositions(recipientInfos); } if (!string.IsNullOrEmpty(currentPosition)) { Metadata["Position"] = currentPosition; } try { await using var binaryWriter = new BinaryWriter(reusableStream, Encoding.UTF8, true); binaryWriter.SerializeMetadata(Metadata); binaryWriter.SerializeVoice(voicePackets); } catch { // } finally { foreach (var voicePacket in voicePackets) { SharedObjectPool.Return(voicePacket); } } } private static async Task InvokeMainThreadAsync(Func action, int timeoutMs = 1000) { var cts = new TaskCompletionSource(); using var cancellationTokenSource = new CancellationTokenSource(timeoutMs); cancellationTokenSource.Token.Register(() => cts.TrySetCanceled(), false); Threading.QueueOnMainThread(() => { try { var result = action(); cts.TrySetResult(result); } catch (Exception ex) { cts.TrySetException(ex); } }); try { return await cts.Task.ConfigureAwait(false); } catch (TaskCanceledException) { throw new TimeoutException($"Operation timed out after {timeoutMs}ms"); } } private string BuildRecipientsJsonFromPositions(List? positions) { if (positions == null || positions.Count == 0) { return "[]"; } _jsonBuilder.Clear(); _jsonBuilder.Append('['); for (var i = 0; i < positions.Count; i++) { if (i > 0) _jsonBuilder.Append(','); var recipient = positions[i]; _jsonBuilder.Append("{\"UserId\":") .Append(recipient.UserId) .Append(",\"DisplayName\":") .Append(JsonConvert.ToString(recipient.DisplayName)) .Append(",\"Distance\":") .Append(recipient.Distance.ToString("F2")) .Append(",\"Timestamp\":") .Append(recipient.Timestamp) .Append('}'); } _jsonBuilder.Append(']'); return _jsonBuilder.ToString(); } public void Dispose() { if (_disposed) return; _disposed = true; _voiceQueue.Clear(); } } private static class SphereHelper { private static readonly List RecipientsInSphere = new(); public static List GetRecipientsWithin(BasePlayer player) { if (Thread.CurrentThread.ManagedThreadId != 1) return new List(); // Just in case another developer comes in and hurr durr lets use this method var distance = 50f; if (player.HasPlayerFlag(BasePlayer.PlayerFlags.VoiceRangeBoost)) { distance += Voice.voiceRangeBoostAmount; } RecipientsInSphere.Clear(); var squaredDistance = distance*distance; var subscribers = BaseNetworkable.GlobalNetworkGroup.subscribers; for (var i = 0; i < subscribers.Count; i++) { var connection = subscribers[i]; if (!connection.active) continue; if (connection.player is not BasePlayer basePlayer) continue; var sqrDist = player.SqrDistance(basePlayer); if (sqrDist > squaredDistance) continue; if (connection.userid == player.userID) continue; var recipientInfo = SharedObjectPool.Get(); recipientInfo.Init(connection.userid, basePlayer.displayName, Mathf.Sqrt(sqrDist), DateTime.UtcNow.Ticks); RecipientsInSphere.Add(recipientInfo); } return RecipientsInSphere; } } private static class SharedObjectPool where T : class, Pool.IPooled, new() { private static readonly ConcurrentQueue Pool = new(); public static T Get() { if (!Pool.TryDequeue(out var item)) return new T(); item.LeavePool(); return item; } public static void Return(T? item) { if (item is null) return; item.EnterPool(); Pool.Enqueue(item); } } private static class ToxVoiceViolationCooldownCache { private static readonly Dictionary PlayerViolationCooldowns = new(); public static void SetPlayerCooldown(string playerId, int cooldownSeconds) { if (string.IsNullOrEmpty(playerId)) return; var expirationTicks = DateTime.UtcNow.AddSeconds(cooldownSeconds).Ticks; PlayerViolationCooldowns[playerId] = expirationTicks; } public static bool IsPlayerOnCooldown(string playerId) { if (!PlayerViolationCooldowns.TryGetValue(playerId, out var expirationTicks)) return false; if (DateTime.UtcNow.Ticks < expirationTicks) return true; PlayerViolationCooldowns.Remove(playerId); return false; } } public class TranscriptionLogSink : IDisposable { private bool _disposed; private readonly DiscordHttpClient? _discordAlertLogsHttpClient; private readonly DiscordHttpClient? _defaultDiscordClient; private readonly TranscriptionFilter? _transcriptionFilter; private readonly ViolationWeightThreshold _violationWeightThreshold; private readonly int _discordAlertThreshold; private readonly bool _consoleLogsEnabled; private readonly Channel _transcriptionSink = Channel.CreateUnbounded(); public TranscriptionLogSink(ConfigurationFile configuration, CancellationToken abortToken) { if (configuration.TryCreateDiscordLogsHttpClient(out _defaultDiscordClient)) { } if (configuration.TryCreateDiscordAlertHttpClient(out _discordAlertLogsHttpClient)) { _discordAlertThreshold = configuration.WeightConfiguration.DiscordWeightThreshold.TriggerAlertWeightThreshold; } if (!configuration.TryCreateTranscriptionFilter(out _transcriptionFilter)) { } _consoleLogsEnabled = configuration.TranscriptionLogs.ConsoleLog.Enabled; _violationWeightThreshold = configuration.WeightConfiguration.ViolationWeightThreshold; _ = TranscriptionSinkTask(abortToken); } public void TryWrite(TranscriptionResult transcription) => _transcriptionSink.Writer.TryWrite(transcription); private async Task TranscriptionSinkTask(CancellationToken cancellationToken) { while (await _transcriptionSink.Reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false)) { while (_transcriptionSink.Reader.TryRead(out var transcription)) { try { if (cancellationToken.IsCancellationRequested) return; var credits = transcription.GetCredits(); if (credits <= 0) { if (_defaultDiscordClient is not null) await _defaultDiscordClient.SendInsufficientCreditsWarningAsync(cancellationToken).ConfigureAwait(false); UnloadPlugin("Insufficient credits. Unloading plugin."); return; } var text = transcription.Metadata["Text"]; var steamId = ToxVoicePersistence.GetSteamIdFromToxVoiceIdCache(transcription.Metadata["ToxVoiceUserId"]); var messageSent = false; var violatedFilterWeight = 0; if (_transcriptionFilter is not null) { violatedFilterWeight = _transcriptionFilter.GetViolatedFilterWeight(text); } var violations = 0; var action = string.Empty; if (_violationWeightThreshold.Enabled) { if (_violationWeightThreshold.TriggerActionWeightThreshold <= violatedFilterWeight) { violations = ToxVoicePersistence.IncrementViolationCount(steamId); var cachedCooldown = ToxVoiceViolationCooldownCache.IsPlayerOnCooldown(steamId); if (!cachedCooldown) { var violationAction = _violationWeightThreshold.GetViolationAction(violations); action = violationAction.Action.Replace("{steamid}", steamId); ToxVoiceViolationCooldownCache.SetPlayerCooldown(steamId, violationAction.CooldownSeconds); } } } var position = transcription.GetString("Position"); Threading.QueueOnMainThread(() => { if (!string.IsNullOrEmpty(action)) ConsoleSystem.Run(ConsoleSystem.Option.Server, action); if (_consoleLogsEnabled) DebugEx.Log($"[VOICE] [{steamId}] : {text}"); Interface.CallHook("OnPlayerVoiceText", steamId, text, position); }); if (_discordAlertThreshold <= violatedFilterWeight && _discordAlertLogsHttpClient is not null) { var response = await _discordAlertLogsHttpClient.SendMessageWithRetryAsync(steamId, transcription, violatedFilterWeight, violations, cancellationToken).ConfigureAwait(false); if (response.IsSuccessStatusCode) { messageSent = true; } else { Logger.Warning($"Failed to upload discord file. Status code: {response.StatusCode}"); } } if (!messageSent && _defaultDiscordClient != null) { var response = await _defaultDiscordClient.SendMessageWithRetryAsync(steamId, transcription, violatedFilterWeight, violations, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { Logger.Warning($"Failed to upload discord file to default webhook. Status code: {response.StatusCode}"); } } } catch (OperationCanceledException) { return; } catch (Exception exception) { Logger.Error($"Unexpected transcription log exception: {exception.Message}"); } finally { SharedObjectPool.Return(transcription); } } } } public void Dispose() { if (_disposed) return; _disposed = true; _transcriptionSink.Writer.TryComplete(); _discordAlertLogsHttpClient?.Dispose(); } } public class HandshakeFailedException : Exception { public HandshakeFailedException(string message) : base(message) { } } private sealed class ToxVoiceWebSocket : IAsyncDisposable { private readonly WebSocket _webSocket; private readonly Stream _stream; private readonly Socket _socket; private bool _disposed; public ToxVoiceWebSocket(WebSocket webSocket, Stream stream, Socket socket) { _webSocket = webSocket ?? throw new ArgumentNullException(nameof(webSocket)); _stream = stream ?? throw new ArgumentNullException(nameof(stream)); _socket = socket ?? throw new ArgumentNullException(nameof(socket)); } public WebSocketState State => _webSocket.State; public async ValueTask DisposeAsync() { await DisposeAsyncCore().ConfigureAwait(false); GC.SuppressFinalize(this); } private async ValueTask DisposeAsyncCore() { if (_disposed) return; _disposed = true; await CloseWebSocketAsync().ConfigureAwait(false); await DisposeStreamAsync().ConfigureAwait(false); CloseAndDisposeSocket(); } private async Task CloseWebSocketAsync() { if (_webSocket.State == WebSocketState.Open) { try { await _webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Disposing", CancellationToken.None).ConfigureAwait(false); } catch (Exception ex) { Logger.Warning($"Closing WebSocket Exception: {ex.Message}"); } } try { _webSocket.Dispose(); } catch { // } } private async Task DisposeStreamAsync() { try { await _stream.DisposeAsync().ConfigureAwait(false); } catch (Exception ex) { Logger.Warning($"Disposing stream failed: {ex.Message}"); } } private void CloseAndDisposeSocket() { try { if (_socket.Connected) { _socket.Shutdown(SocketShutdown.Both); } _socket.Close(); } catch (Exception ex) { } finally { try { _socket.Dispose(); } catch { // } } } public Task ReceiveAsync(byte[] buffer, CancellationToken cancellationToken) => _webSocket.ReceiveAsync(buffer, cancellationToken); public Task CloseAsync(WebSocketCloseStatus normalClosure, string connectionClosedByTheServer, CancellationToken cancellationToken) => _webSocket.CloseAsync(normalClosure, connectionClosedByTheServer, cancellationToken); public Task SendAsync(byte[] bytes, WebSocketMessageType binary, bool endOfMessage, CancellationToken cancellationToken) => _webSocket.SendAsync(bytes, binary, endOfMessage, cancellationToken); public Task SendAsync(ArraySegment bytes, WebSocketMessageType binary, bool endOfMessage, CancellationToken cancellationToken) => _webSocket.SendAsync(bytes, binary, endOfMessage, cancellationToken); } private class VoiceNetworking : IDisposable { private bool _disposed; private readonly ConfigurationFile _config; private readonly Channel _playerVoiceSegmentSink = Channel.CreateUnbounded(); private readonly CancellationToken _shutDownToken; private readonly Uri _voiceUri; public VoiceNetworking(ConfigurationFile config, CancellationToken shutdownToken) { _config = config; _shutDownToken = shutdownToken; _voiceUri = new Uri("wss://voice.toxvoice.com:2096/voice-sink"); } public async Task StartAsync() { using var sink = new TranscriptionLogSink(_config, _shutDownToken); while (!_shutDownToken.IsCancellationRequested) { var disconnectCts = CancellationTokenSource.CreateLinkedTokenSource(_shutDownToken); try { await using var webSocket = await WebSocketConnector.ConnectAsync(_voiceUri, _config.ToxVoice.Token, _shutDownToken).ConfigureAwait(false); try { var sendTask = SendCog(webSocket, disconnectCts.Token).ContinueWith(_ => { if (!disconnectCts.IsCancellationRequested) disconnectCts.Cancel(); }, disconnectCts.Token); var receiveTask = ReceiveCog(webSocket, sink, disconnectCts.Token).ContinueWith(_ => { if (!disconnectCts.IsCancellationRequested) disconnectCts.Cancel(); }, disconnectCts.Token); Logger.Info("Connected"); await Task.WhenAll(sendTask, receiveTask).ConfigureAwait(false); } catch (OperationCanceledException) { } catch (Exception ex) { Logger.Error($"Unhandled Exception occurred: {ex.Message}"); } finally { Logger.Info("Disconnected"); } } catch (HandshakeFailedException ex) { UnloadPlugin($"Handshake failed ({ex.Message}). Unloading plugin."); return; } catch (OperationCanceledException) { return; } catch (Exception ex) { } } } private static async Task ReceiveCog(ToxVoiceWebSocket webSocket, TranscriptionLogSink sink, CancellationToken cancellationToken) { var buffer = new byte[1024*4]; while (!cancellationToken.IsCancellationRequested) { try { WebSocketReceiveResult receiveResult; using var ms = new MemoryStream(); do { receiveResult = await webSocket.ReceiveAsync(buffer, cancellationToken).ConfigureAwait(false); ms.Write(buffer, 0, receiveResult.Count); } while (!receiveResult.EndOfMessage); if (receiveResult.MessageType == WebSocketMessageType.Close) { await webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Connection closed by the server.", cancellationToken).ConfigureAwait(false); break; } ms.Position = 0; while (ms.Position < ms.Length) { var transcriptionResult = DeserializeTranscriptionResult(ms); sink.TryWrite(transcriptionResult); } } catch (OperationCanceledException) { break; } catch (Exception exception) { if (cancellationToken.IsCancellationRequested) break; Logger.Error($"WebSocket Receive Exception: {exception.Message}"); break; } } } private static TranscriptionResult DeserializeTranscriptionResult(MemoryStream ms) { using var reader = new BinaryReader(ms, Encoding.UTF8, true); var result = SharedObjectPool.Get(); var metadataCount = reader.ReadInt32(); for (var i = 0; i < metadataCount; i++) { var key = reader.ReadString(); var value = reader.ReadString(); result.Metadata[key] = value; } var audioDataLength = reader.ReadInt32(); result.AudioData = reader.ReadBytes(audioDataLength); return result; } private async Task SendCog(ToxVoiceWebSocket webSocket, CancellationToken cancellationToken) { using var ms = new MemoryStream(20000); while (!cancellationToken.IsCancellationRequested) { try { while (await _playerVoiceSegmentSink.Reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false)) { while (_playerVoiceSegmentSink.Reader.TryRead(out var playerVoiceContext)) { try { await playerVoiceContext.WriteBytes(ms).ConfigureAwait(false); if (ms.TryGetBuffer(out var buffer)) { await webSocket.SendAsync(buffer, WebSocketMessageType.Binary, true, cancellationToken).ConfigureAwait(false); } } catch (OperationCanceledException) { return; } catch (Exception exception) { if (cancellationToken.IsCancellationRequested) return; Logger.Error($"Error sending voice clip: {exception.Message}"); return; } finally { playerVoiceContext.Reset(); } } } } catch (ChannelClosedException) { break; } } } public void Dispose() { if (_disposed) return; _disposed = true; _playerVoiceSegmentSink.Writer.TryComplete(); } public void TryWrite(PlayerVoiceContext value) { _playerVoiceSegmentSink.Writer.TryWrite(value); } } private static class WebSocketConnector { public static async Task ConnectAsync(Uri uri, string token, CancellationToken cancellationToken) { var addresses = await DnsResolver.ResolveHostnameAsync(uri.Host); while (!cancellationToken.IsCancellationRequested) { foreach (var ipAddress in addresses) { if (cancellationToken.IsCancellationRequested) throw new OperationCanceledException(); try { return await ConnectToAddressAsync(uri, ipAddress, token, cancellationToken); } catch (HandshakeFailedException) { throw; } catch (OperationCanceledException) { throw; } catch (Exception ex) { } } Logger.Warning($"Failed to connect. Retrying..."); await Task.Delay(5000, cancellationToken).ConfigureAwait(false); } return default; } private static async Task ConnectToAddressAsync(Uri uri, IPAddress ipAddress, string token, CancellationToken cancellationToken) { var socket = SocketFactory.CreateSocket(ipAddress.AddressFamily); await socket.ConnectAsync(ipAddress, uri.Port); var stream = await StreamFactory.CreateAndHandshakeStreamAsync(uri, socket, token); if (cancellationToken.IsCancellationRequested) { await stream.DisposeAsync().ConfigureAwait(false); throw new OperationCanceledException(); } var webSocket = WebSocket.CreateFromStream(stream, false, null, TimeSpan.Zero); var toxVoiceWebSocket = new ToxVoiceWebSocket(webSocket, stream, socket); return toxVoiceWebSocket; } } private static class DnsResolver { public static async Task ResolveHostnameAsync(string hostname) { var addresses = await Dns.GetHostAddressesAsync(hostname); var ipv4Addresses = addresses.Where(addr => addr.AddressFamily == AddressFamily.InterNetwork).ToArray(); if (ipv4Addresses.Length == 0) { throw new Exception($"Unable to resolve hostname to IPv4 address: {hostname}"); } return ipv4Addresses; } } private static class SocketFactory { public static Socket CreateSocket(AddressFamily addressFamily) => addressFamily == AddressFamily.InterNetworkV6 ? new Socket(AddressFamily.InterNetworkV6, SocketType.Stream, ProtocolType.Tcp) : new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); } private static class StreamFactory { public static async Task CreateAndHandshakeStreamAsync(Uri uri, Socket socket, string token) { Stream stream = null; try { stream = await CreateStreamAsync(uri, socket); await PerformHandshakeAsync(stream, uri, token); return stream; } catch (Exception) { if (stream is not null) await stream.DisposeAsync(); throw; } } private static async Task CreateStreamAsync(Uri uri, Socket socket) { if (uri.Scheme.ToLower() != "wss") return new NetworkStream(socket, true); var sslStream = new SslStream(new NetworkStream(socket, true), false, (_, _, _, _) => true); await sslStream.AuthenticateAsClientAsync(uri.Host); return sslStream; } private static async Task PerformHandshakeAsync(Stream stream, Uri uri, string token) { var handshakeRequest = CreateHandshakeRequest(uri, token); var requestBytes = Encoding.ASCII.GetBytes(handshakeRequest); await stream.WriteAsync(requestBytes); var responseBuffer = new byte[1024]; var bytesRead = await stream.ReadAsync(responseBuffer); var response = Encoding.ASCII.GetString(responseBuffer, 0, bytesRead); if (response.Contains("101 Switching Protocols")) return; // Success! :) // Handle specific failure scenarios if (response.Contains("Invalid Token")) throw new HandshakeFailedException("Invalid token"); if (response.Contains("Insufficient credits")) throw new HandshakeFailedException("Insufficient credits"); throw new Exception("Handshake failed"); } private static string CreateHandshakeRequest(Uri uri, string token) => $"GET {uri.PathAndQuery} HTTP/1.1\r\n" + $"Host: {uri.Host}:{uri.Port}\r\n" + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n" + "Sec-WebSocket-Version: 13\r\n" + $"Token: {token}\r\n" + "\r\n"; } } public static class TranscriptionExtensions { public static string GetString(this ToxVoice.TranscriptionResult transcriptionResult, string key) => !transcriptionResult.Metadata.TryGetValue(key, out var str) ? string.Empty : str; public static double GetCredits(this ToxVoice.TranscriptionResult transcriptionResult) { if (!transcriptionResult.Metadata.TryGetValue("Credits", out var credits)) return 0.1; if (!double.TryParse(credits, out var result)) return 0.1; return result; } } public static class VoiceContextSerializer { public static void SerializeMetadata(this BinaryWriter writer, Dictionary metadata) { writer.Write((uint)metadata.Count); foreach (var kvp in metadata) { writer.Write(kvp.Key); writer.Write(kvp.Value); } } public static void SerializeVoice(this BinaryWriter writer, List voicePackets) { writer.Write((uint)voicePackets.Count); foreach (var bytes in voicePackets) { if (bytes.Data.Length <= 12) { continue; } var newLength = bytes.Data.Length - 12; writer.Write(newLength); writer.Write(bytes.Data, 8, newLength); } } }