C#:
using System;
using System.Collections;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Globalization;
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;
[Info("ToxVoice", "Maxaki", "1.4.2")]
[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<string> _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);
}
protected override void LoadDefaultMessages()
{
lang.RegisterMessages(new Dictionary<string, string>
{
["InvalidCommand"] = "Invalid command. Usage: toxvoice <subcommand> <parameter>",
["ViolationsResetAll"] = "All player violations have been reset.",
["ViolationsResetPlayer"] = "Violations for player with SteamID {0} have been reset.",
["InvalidSteamId"] = "Invalid parameter. Please provide a valid SteamID or use 'all' to reset all violations.",
["InvalidSteamIdWhitelist"] = "Invalid parameter. Please provide a valid SteamID",
["WhitelistRemoved"] = "User({0}) removed from whitelist.",
["WhitelistAdded"] = "User({0}) added to whitelist."
}, this);
}
private string Lang(string key, params object[] args) => string.Format(lang.GetMessage(key, this), args);
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, ArraySegment<byte> data)
{
if (_whitelistedUsers.Contains(player.UserIDString))
return;
var voicePacket = SharedObjectPool<VoicePacket>.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.ToString().Split(' ');
if (args.Length < 2)
{
arg.ReplyWith($"[ToxVoice] {Lang("InvalidCommand")}");
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] {Lang("ViolationsResetAll")}");
return;
}
else if (ulong.TryParse(parameter, out _))
{
ToxVoicePersistence.ResetPlayerViolation(parameter);
arg.ReplyWith($"[ToxVoice] {Lang("ViolationsResetPlayer", parameter)}");
return;
}
else
{
arg.ReplyWith($"[ToxVoice] {Lang("InvalidSteamId")}");
return;
}
case "whitelist":
if (!ulong.TryParse(parameter, out _))
{
arg.ReplyWith($"[ToxVoice] {Lang("InvalidSteamIdWhitelist")}");
return;
}
if (permission.UserHasPermission(parameter, WhitelistPermission))
{
permission.RevokeUserPermission(parameter, WhitelistPermission);
arg.ReplyWith($"[ToxVoice] {Lang("WhitelistRemoved", parameter)}");
return;
}
else
{
permission.GrantUserPermission(parameter, WhitelistPermission, this);
arg.ReplyWith($"[ToxVoice] {Lang("WhitelistAdded", parameter)}");
return;
}
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<TriggerFilter> GetDefaultFilters() => new()
{
new TriggerFilter { Regex = false, Triggers = new List<string> { "word" }, Weight = 8 },
new TriggerFilter { Regex = false, Triggers = new List<string> { "word1", "word2" }, Weight = 3 },
new TriggerFilter { Regex = false, Triggers = new List<string> { "testing" }, Weight = 50 },
new TriggerFilter { Regex = true, Triggers = new List<string> { @"\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 = null;
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<ConfigurationFile>(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<byte>();
public List<RecipientInfo> Recipients { get; private set; } = new();
public void Init(ulong userId, string displayName, ArraySegment<byte> data)
{
UserId = userId;
DisplayName = displayName;
if (data.Array == null || data.Count == 0)
{
Data = Array.Empty<byte>();
return;
}
Data = new byte[data.Count];
Buffer.BlockCopy(data.Array, data.Offset, Data, 0, data.Count);
}
public void EnterPool()
{
UserId = 0;
DisplayName = string.Empty;
Data = Array.Empty<byte>();
foreach (var recipientInfo in Recipients)
{
SharedObjectPool<RecipientInfo>.Return(recipientInfo);
}
Recipients.Clear();
}
public void LeavePool()
{ }
}
public class VoiceSnapshot : Pool.IPooled
{
public ulong UserId { get; private set; }
public Dictionary<string, string> Metadata { get; private set; } = new();
public List<VoicePacket> VoicePackets { get; private set; } = new();
public bool ProximityLogs { get; private set; }
public void Init(ulong userId, Dictionary<string, string> metadata, List<VoicePacket> voicePackets, bool proximityLogs)
{
UserId = userId;
foreach (var kvp in metadata)
{
Metadata[kvp.Key] = kvp.Value;
}
VoicePackets.AddRange(voicePackets);
ProximityLogs = proximityLogs;
}
public void EnterPool()
{
UserId = 0;
Metadata.Clear();
foreach (var voicePacket in VoicePackets)
{
SharedObjectPool<VoicePacket>.Return(voicePacket);
}
VoicePackets.Clear();
ProximityLogs = false;
}
public void LeavePool()
{ }
}
private class PlayerVoiceSink : IDisposable
{
private bool _disposed;
private int _callbackRunning;
private readonly VoiceNetworking _voiceNetworking;
private readonly CancellationToken _shutdownToken;
private readonly ConcurrentDictionary<ulong, PlayerVoiceContext> _playerVoiceContexts = new();
private readonly Channel<VoicePacket> _voiceSink = Channel.CreateUnbounded<VoicePacket>();
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(voicePacket);
}
}
}
catch (OperationCanceledException)
{ }
catch (Exception e)
{
Logger.Error($"Unexpected exception in voice sink: {e.Message}");
}
}
private void Callback(object state)
{
if (_disposed)
return;
if (Interlocked.CompareExchange(ref _callbackRunning, 1, 0) != 0)
return;
try
{
var idleEntries = new List<ulong>();
foreach (var playerVoiceContext in _playerVoiceContexts)
{
if (playerVoiceContext.Value.IsIdle(_idleThreshold))
{
idleEntries.Add(playerVoiceContext.Key);
continue;
}
var snapshot = playerVoiceContext.Value.TakeSnapshot(_proximityLogs);
if (snapshot != null)
{
_voiceNetworking.TryWrite(snapshot);
}
}
foreach (var idleEntry in idleEntries)
{
if (!_playerVoiceContexts.TryRemove(idleEntry, out var removedContext))
continue;
if (!removedContext.IsIdle(_idleThreshold))
{
_playerVoiceContexts.TryAdd(idleEntry, removedContext);
continue;
}
removedContext.Dispose();
}
}
catch (Exception e)
{
Logger.Error($"Voice sink callback error: {e.Message}");
}
finally
{
Interlocked.Exchange(ref _callbackRunning, 0);
}
}
private PlayerVoiceContext GetOrAddPlayerVoiceRecording(ulong userId, string displayName)
{
if (_playerVoiceContexts.TryGetValue(userId, out var playerVoiceRecording))
return playerVoiceRecording;
playerVoiceRecording = new PlayerVoiceContext(userId, displayName);
_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<string, string> 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
{
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<int, string>("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<string, string>("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<string, string>("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<string, string>("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<TriggerFilter> Filters;
public TranscriptionFilter(ConfigurationFile configurationFile)
{
Filters = configurationFile.TriggerFilter.Filters;
}
public int GetViolatedFilterWeight(string text) => GetViolatedFilterWeightCore(text, Filters);
private static int GetViolatedFilterWeightCore(string text, List<TriggerFilter> 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<string> regexPatterns)
{
foreach (var pattern in regexPatterns)
{
try
{
if (!Regex.IsMatch(text, pattern, RegexOptions.IgnoreCase))
{
return false;
}
}
catch (Exception)
{
Logger.Warning("Error in regex pattern: " + pattern);
}
}
return true;
}
private static bool IsWordListContained(ReadOnlySpan<char> text, TriggerFilter triggerFilter)
{
foreach (var word in triggerFilter.Triggers)
{
if (!ContainsWord(text, word.AsSpan()))
{
return false;
}
}
return true;
}
private static bool ContainsWord(ReadOnlySpan<char> text, ReadOnlySpan<char> 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<string, string> Metadata { get; set; } = new();
public byte[] AudioData { get; set; } = Array.Empty<byte>();
public void EnterPool()
{
Metadata.Clear();
AudioData = Array.Empty<byte>();
}
public void LeavePool()
{ }
}
public sealed class DiscordWorkItem
{
public string SteamId { get; init; } = string.Empty;
public string UserId { get; init; } = string.Empty;
public string DisplayName { get; init; } = string.Empty;
public string Position { get; init; } = string.Empty;
public string Text { get; init; } = string.Empty;
public string RecipientsJson { get; init; } = string.Empty;
public byte[] AudioData { get; init; } = Array.Empty<byte>();
public int ViolatedFilterWeight { get; init; }
public int Violations { get; init; }
public bool UseAlertWebhook { get; init; }
}
public readonly struct DiscordSendResult
{
public bool IsSuccess { get; }
public HttpStatusCode StatusCode { get; }
private DiscordSendResult(bool isSuccess, HttpStatusCode statusCode)
{
IsSuccess = isSuccess;
StatusCode = statusCode;
}
public static DiscordSendResult Success(HttpStatusCode statusCode) => new(true, statusCode);
public static DiscordSendResult Failure(HttpStatusCode statusCode) => new(false, statusCode);
}
public class DiscordHttpClient : IDisposable
{
private bool _disposed;
private bool _permanentlyDisabled;
private readonly bool _hideSteamId;
private readonly bool _proximityLogs;
private readonly Uri _webhookUri;
private readonly HttpClient _httpClient;
private readonly JsonSerializerSettings _recipientSerializer = new()
{
Converters = new List<JsonConverter> { new PooledRecipientInfoConverter() }
};
public DiscordHttpClient(Uri webhookUri, bool hideSteamId, bool proximityLogs)
{
_hideSteamId = hideSteamId;
_proximityLogs = proximityLogs;
_webhookUri = webhookUri;
_httpClient = new HttpClient();
}
private MultipartFormDataContent CreateMultipartContent(DiscordWorkItem workItem)
{
var content = new MultipartFormDataContent();
var userId = workItem.UserId;
var displayName = workItem.DisplayName;
var position = workItem.Position;
var text = workItem.Text;
if (!_hideSteamId && !string.IsNullOrEmpty(workItem.SteamId))
{
userId = workItem.SteamId;
}
var multiPartBuilder = Pool.Get<StringBuilder>();
try
{
multiPartBuilder.AppendLine("```md");
multiPartBuilder.AppendLine("# Transcript");
multiPartBuilder.AppendLine($"- Player: {displayName} ({userId})");
multiPartBuilder.AppendLine($"- Position: {position}");
multiPartBuilder.AppendLine("- Text: {text}");
if (workItem.ViolatedFilterWeight > 0)
{
multiPartBuilder.AppendLine("# Moderation");
multiPartBuilder.AppendLine($"- Weight: {workItem.ViolatedFilterWeight}");
if (workItem.Violations > 0)
multiPartBuilder.AppendLine($"- Violations: {workItem.Violations}");
}
if (_proximityLogs && !string.IsNullOrEmpty(workItem.RecipientsJson))
{
multiPartBuilder.AppendLine("# Proximity");
AppendRecipientInfo(multiPartBuilder, workItem.RecipientsJson);
}
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(workItem.AudioData);
fileContent.Headers.ContentType = MediaTypeHeaderValue.Parse("audio/mp3");
content.Add(fileContent, "file", $"{userId}.mp3");
return content;
}
finally
{
Pool.FreeUnmanaged(ref multiPartBuilder);
}
}
private void AppendRecipientInfo(StringBuilder sb, string recipientsJson)
{
try
{
var recipients = JsonConvert.DeserializeObject<List<RecipientInfo>>(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<RecipientInfo>.Return(recipient);
}
}
else
{
sb.AppendLine("- No recipients found.");
}
}
catch (Exception ex)
{
sb.AppendLine($"- Error parsing recipient data: {ex.Message}");
}
}
public async Task<DiscordSendResult> SendMessageWithRetryAsync(DiscordWorkItem workItem, CancellationToken cancellationToken)
{
const int maxRetries = 3;
const int baseRetryDelay = 1000;
if (_permanentlyDisabled)
return DiscordSendResult.Failure(HttpStatusCode.NotFound);
using var content = CreateMultipartContent(workItem);
var pendingRateLimitDelay = TimeSpan.Zero;
var lastStatus = HttpStatusCode.InternalServerError;
for (var retry = 0; retry < maxRetries; retry++)
{
if (cancellationToken.IsCancellationRequested)
return DiscordSendResult.Failure(HttpStatusCode.InternalServerError);
if (pendingRateLimitDelay > TimeSpan.Zero)
{
Logger.Info($"Proactively waiting {pendingRateLimitDelay.TotalSeconds:F2} seconds to avoid rate limit.");
await Task.Delay(pendingRateLimitDelay, cancellationToken).ConfigureAwait(false);
pendingRateLimitDelay = TimeSpan.Zero;
}
try
{
using var response = await _httpClient.PostAsync(_webhookUri, content, cancellationToken).ConfigureAwait(false);
lastStatus = response.StatusCode;
if (response.IsSuccessStatusCode)
return DiscordSendResult.Success(response.StatusCode);
if (IsRateLimited(response))
{
if (IsGlobalRateLimit(response))
Logger.Warning("Discord global rate limit triggered.");
var resetAfter = GetRateLimitResetAfter(response);
Logger.Warning($"Discord rate limited. Retrying after {resetAfter.TotalSeconds:F2} seconds.");
await Task.Delay(resetAfter, cancellationToken).ConfigureAwait(false);
continue;
}
if (IsNonRetriableClientError(response))
{
if ((int)response.StatusCode == 404)
{
_permanentlyDisabled = true;
Logger.Error("Discord webhook returned 404 — invalid or deleted. Disabling client permanently to avoid Discord temporary restrictions.");
}
else
{
Logger.Error($"Discord webhook returned non-retriable status {(int)response.StatusCode}. Aborting.");
}
return DiscordSendResult.Failure(response.StatusCode);
}
Logger.Warning($"Discord request failed with status {response.StatusCode}. Retry attempt: {retry + 1}");
if (IsBucketExhausted(response))
pendingRateLimitDelay = GetRateLimitResetAfter(response);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception exception)
{
Logger.Warning($"Failed to upload discord file. Retry attempt: {retry + 1}\n{exception.Message}");
}
var delay = baseRetryDelay * (int)Math.Pow(2, retry);
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
}
return DiscordSendResult.Failure(lastStatus);
}
private static TimeSpan GetRateLimitResetAfter(HttpResponseMessage response)
{
if (response.Headers.TryGetValues("Retry-After", out var retryAfterValues) &&
double.TryParse(retryAfterValues.FirstOrDefault(), NumberStyles.Float, CultureInfo.InvariantCulture, out var retryAfterSeconds))
{
return TimeSpan.FromSeconds(retryAfterSeconds);
}
if (response.Headers.TryGetValues("X-RateLimit-Reset-After", out var resetAfterValues) &&
double.TryParse(resetAfterValues.FirstOrDefault(), NumberStyles.Float, CultureInfo.InvariantCulture, out var resetAfterSecondsHeader))
{
return TimeSpan.FromSeconds(resetAfterSecondsHeader);
}
// X-RateLimit-Reset is unix epoch seconds, can include decimals for ms precision
if (response.Headers.TryGetValues("X-RateLimit-Reset", out var resetValues) &&
double.TryParse(resetValues.FirstOrDefault(), NumberStyles.Float, CultureInfo.InvariantCulture, out var resetTimestamp))
{
var resetTime = DateTimeOffset.FromUnixTimeMilliseconds((long)(resetTimestamp * 1000));
var timeUntilReset = resetTime - DateTimeOffset.UtcNow;
return timeUntilReset > TimeSpan.Zero ? timeUntilReset : TimeSpan.FromSeconds(1);
}
return TimeSpan.FromSeconds(5);
}
private static bool IsBucketExhausted(HttpResponseMessage response)
{
return response.Headers.TryGetValues("X-RateLimit-Remaining", out var remainingValues) &&
int.TryParse(remainingValues.FirstOrDefault(), out var remaining) &&
remaining <= 0;
}
private static bool IsGlobalRateLimit(HttpResponseMessage response)
{
return response.Headers.TryGetValues("X-RateLimit-Global", out var values) &&
bool.TryParse(values.FirstOrDefault(), out var isGlobal) && isGlobal;
}
private static bool IsNonRetriableClientError(HttpResponseMessage response)
{
// 4xx errors are client-side and won't succeed on retry — except 408 (Request Timeout)
// and 429 (Rate Limited) which are handled separately. Retrying these wastes Discord's
// Cloudflare invalid-request budget (10K/10min triggers a temporary IP ban).
var status = (int)response.StatusCode;
return status >= 400 && status < 500 && status != 408 && status != 429;
}
private static bool IsRateLimited(HttpResponseMessage response)
{
if (response.StatusCode == HttpStatusCode.TooManyRequests)
return true;
if (response.Headers.TryGetValues("X-RateLimit-Remaining", out var values) &&
values.FirstOrDefault() == "0")
{
return response.Headers.Contains("X-RateLimit-Reset-After") ||
response.Headers.Contains("X-RateLimit-Reset");
}
return false;
}
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<RecipientInfo>.Get();
recipientInfo.Init(
jObject["UserId"].Value<ulong>(),
jObject["DisplayName"].Value<string>(),
jObject["Distance"].Value<float>(),
jObject["Timestamp"].Value<long>()
);
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<string> Triggers { get; set; } = new();
}
public class TriggerFilterConfiguration
{
public bool Enabled { get; set; } = false;
[JsonProperty(PropertyName = "TriggerFilters", ObjectCreationHandling = ObjectCreationHandling.Replace)]
public List<TriggerFilter> 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<int, ViolationAction> 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 (ViolationAction
// ... [Code preview truncated due to length. Full source in attached file] ...