Иконка ресурса

FREE ToxVoice : 1.4.2 1.4.2

Нет прав для скачивания
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] ...

Автор
15FPS
Скачиваний
0
Просмотры
1
Первый выпуск
Обновление
Оценка
0.00 звёзд 0 оценок
Сверху