using System.Text.Encodings.Web; using System.Text.Json; using CounterStrikeSharp.API; using CounterStrikeSharp.API.Core; using CounterStrikeSharp.API.Core.Attributes.Registration; using CounterStrikeSharp.API.Core.Capabilities; using CounterStrikeSharp.API.Modules.Admin; using CounterStrikeSharp.API.Modules.Commands; using CounterStrikeSharp.API.Modules.Entities; using CounterStrikeSharp.API.Modules.Timers; using CounterStrikeSharp.API.Modules.Utils; using CS2_SimpleAdminApi; using Microsoft.Extensions.Logging; namespace Whisper; // Private messages between players: /pm , /r to reply, and /block, /blocklist, // /unblock. Lines use CS2-Tags' rank prefixes and colours from tags.json. While History is on, every // message is kept in CS2-SimpleAdmin's database (wh_messages): private ones, all and team chat, @ // staff chat and SimpleAdmin's say commands. Blocks are in wh_blocks. // // Chat is seen by a catch-all command listener added in Load. CSS runs catch-alls before any // per-command listener, in the order they were added, and stops at the first that returns Stop. // CS2-SimpleAdmin adds its own (which stops gagged players' chat and handles @) in // OnAllPluginsLoaded, after every plugin's Load, so this one always sees a line first. It only // looks, never blocks. // // Chat commands are caught in a say listener rather than left to CSS's chat triggers: for "!pm" the // game prints the whole line, message and all, to everyone before CSS runs the command. Stopping // "say" there means nothing is printed and CSS never dispatches it, so it runs once. // // Threads: every field below is only touched on the game thread. Database reads run in Task.Run and // hand back with Server.NextFrame; writes go through the Writer. public class Whisper : BasePlugin, IPluginConfig { public override string ModuleName => "Whisper"; public override string ModuleVersion => "0.2.0"; public override string ModuleAuthor => "astra"; public override string ModuleDescription => "Private messages between players"; private static readonly PluginCapability SimpleAdminCapability = new("simpleadmin:api"); // Chat words handled here, after a chat trigger ("!pm", "/pm"). private static readonly HashSet ChatCommands = new(StringComparer.OrdinalIgnoreCase) { "pm", "w", "r", "block", "blocklist", "unblock" }; // SimpleAdmin's commands that send a message, logged under their own name as the channel. private static readonly HashSet StaffCommands = new(StringComparer.OrdinalIgnoreCase) { "css_say", "css_asay", "css_psay", "css_csay", "css_hsay", "css_cssay" }; // Targets that mean more than one player. A PM goes to one. private static readonly HashSet GroupTargets = new(StringComparer.OrdinalIgnoreCase) { "@all", "@t", "@ct", "@spec", "@alive", "@dead", "@bot", "@bots", "@human", "@humans", "@!me" }; public WhisperConfig Config { get; set; } = new(); private ICS2_SimpleAdminApi? _simpleAdmin; private Database? _db; private Writer? _writer; private Tags? _tags; // SteamID64 -> their blocks, oldest first; null while still loading from the database. private readonly Dictionary?> _blocks = []; // SteamID64 -> who last sent them a PM, for /r. private readonly Dictionary _lastFrom = []; // SteamID64 -> when they may send again (UTC). private readonly Dictionary _nextSend = []; public void OnConfigParsed(WhisperConfig config) { config.CooldownSeconds = Math.Max(0, config.CooldownSeconds); config.MaxMessageLength = Math.Clamp(config.MaxMessageLength, 1, 512); config.MaxBlocks = Math.Max(1, config.MaxBlocks); config.SpoolMaxEntries = Math.Max(0, config.SpoolMaxEntries); Config = config; } public override void Load(bool hotReload) { _tags = new Tags(Path.Combine(Server.GameDirectory, "csgo", "addons", "counterstrikesharp", Config.TagsFile), message => Logger.LogWarning("{Message}", message)); // In Load, not OnAllPluginsLoaded: see the top of this file. AddCommandListener(null, OnAnyCommand, HookMode.Pre); AddCommandListener("say", OnSay, HookMode.Pre); AddCommandListener("say_team", OnSay, HookMode.Pre); RegisterEventHandler(OnPlayerConnectFull); RegisterListener(OnClientDisconnect); // Block lists that couldn't be read (the database was down) are tried again. AddTimer(30.0f, () => { foreach (var (steamId, blocks) in _blocks.ToList()) if (blocks == null) LoadBlocks(steamId); }, TimerFlags.REPEAT); } public override void OnAllPluginsLoaded(bool hotReload) { try { _simpleAdmin = SimpleAdminCapability.Get(); } catch (KeyNotFoundException) { } var connectionString = _simpleAdmin?.GetConnectionString(); if (string.IsNullOrEmpty(connectionString)) Logger.LogError("No CS2-SimpleAdmin MySQL database: messages work, but blocks last only until the map changes"); else { _db = new Database(connectionString); _writer = new Writer(_db, Path.Combine(ModuleDirectory, "spool.jsonl"), Config.SpoolMaxEntries, Logger); _writer.Start(); } foreach (var player in Humans()) LoadBlocks(player.SteamID); } public override void Unload(bool hotReload) { _writer?.Drain(TimeSpan.FromSeconds(5)); } // --- Players -------------------------------------------------------------------- private static List Humans() => Utilities.GetPlayers().Where(p => p is { IsValid: true, IsBot: false, IsHLTV: false } && p.Connected == PlayerConnectedState.Connected && p.SteamID != 0).ToList(); private bool IsStaff(CCSPlayerController player) => AdminManager.PlayerHasPermissions(player, Config.StaffPermission); private bool IsHidden(CCSPlayerController player) { try { return _simpleAdmin?.IsAdminSilent(player) ?? false; } catch (Exception) { return false; } } // Admins hidden with css_hide can't be found, except by staff and themselves. private bool CanSee(CCSPlayerController sender, CCSPlayerController player) => player.SteamID == sender.SteamID || !IsHidden(player) || IsStaff(sender); private static PlayerState StateOf(CCSPlayerController player) { var team = (int)player.TeamNum; var alive = team >= (int)CsTeam.Terrorist && player.PlayerPawn.Value is { IsValid: true } pawn && pawn.LifeState == (byte)LifeState_t.LIFE_ALIVE; return new PlayerState(player.SteamID, player.PlayerName, team, alive); } private HookResult OnPlayerConnectFull(EventPlayerConnectFull @event, GameEventInfo info) { if (@event.Userid is { IsValid: true, IsBot: false, IsHLTV: false } player && player.SteamID != 0) LoadBlocks(player.SteamID); return HookResult.Continue; } private void OnClientDisconnect(int slot) { var player = Utilities.GetPlayerFromSlot(slot); if (player is not { IsValid: true } || player.SteamID == 0) return; var steamId = player.SteamID; // Next frame, once they're out of the player list. Server.NextFrame(() => { if (Humans().Any(p => p.SteamID == steamId)) return; _blocks.Remove(steamId); _lastFrom.Remove(steamId); _nextSend.Remove(steamId); }); } private void LoadBlocks(ulong steamId) { if (_db == null) { _blocks.TryAdd(steamId, []); return; } if (_blocks.TryGetValue(steamId, out var known) && known != null) return; _blocks[steamId] = null; var db = _db; Task.Run(async () => { try { var blocks = await db.BlocksAsync(steamId); Server.NextFrame(() => { if (_blocks.TryGetValue(steamId, out var current) && current == null) _blocks[steamId] = blocks; }); } catch (Exception ex) { Logger.LogWarning("Couldn't read {SteamId}'s blocks, trying again shortly: {Error}", steamId, ex.Message); } }); } // --- Chat ----------------------------------------------------------------------- // Logs chat and staff messages as they're sent. Never blocks anything. private HookResult OnAnyCommand(CCSPlayerController? player, CommandInfo info) { if (!Config.History || _writer == null) return HookResult.Continue; var command = info.GetArg(0).ToLowerInvariant(); if (StaffCommands.Contains(command)) { // Typed in console, RCON (the web panel too), or "!asay" in chat, which CSS runs as css_asay. var sender = player is { IsValid: true, IsBot: false } ? StateOf(player) : player == null ? PlayerState.Console : null; if (sender == null) return HookResult.Continue; var rest = info.ArgString; var target = command == "css_psay" ? NextToken(ref rest) : null; var message = rest.Trim(); if (message.Length > 0) Log(command, sender, null, target, message, Result.Sent); return HookResult.Continue; } if (command is not ("say" or "say_team") || player is not { IsValid: true, IsBot: false }) return HookResult.Continue; var text = info.GetArg(1); if (text.Length == 0) return HookResult.Continue; var team = command == "say_team"; var channel = team ? ChatChannel.Team : ChatChannel.All; var trigger = ChatTrigger(text); if (trigger != null) { var rest = text[trigger.Length..]; var word = NextToken(ref rest).ToLowerInvariant(); // Logged on their own: PMs where they're sent, "!asay" and the like as the command. if (!ChatCommands.Contains(word) && !StaffCommands.Contains("css_" + word)) Log(channel, StateOf(player), null, null, text, Result.Command); return HookResult.Continue; } if (text.StartsWith('@')) { var chatStaff = AdminManager.PlayerHasPermissions(player, "@css/chat"); // SimpleAdmin runs this as css_say, which is logged then. if (!team && chatStaff) return HookResult.Continue; // @ team chat goes to staff, and gets through a gag. if (team) { Log(chatStaff ? ChatChannel.Staff : ChatChannel.ToStaff, StateOf(player), null, null, text[1..].Trim(), Result.Sent); return HookResult.Continue; } } Log(channel, StateOf(player), null, null, text, IsGagged(player, out _) ? Result.Gagged : Result.Sent); return HookResult.Continue; } private static string? ChatTrigger(string text) => CoreConfig.PublicChatTrigger.Concat(CoreConfig.SilentChatTrigger).FirstOrDefault(t => t.Length > 0 && text.StartsWith(t)); private HookResult OnSay(CCSPlayerController? player, CommandInfo info) { if (player is not { IsValid: true, IsBot: false }) return HookResult.Continue; var text = info.GetArg(1); var trigger = ChatTrigger(text); if (trigger == null) return HookResult.Continue; var rest = text[trigger.Length..]; var word = NextToken(ref rest); if (!ChatCommands.Contains(word)) return HookResult.Continue; Run(player, word.ToLowerInvariant(), rest.Trim(), reply => player.PrintToChat(reply)); return HookResult.Stop; } [ConsoleCommand("css_pm", "Sends a private message")] [ConsoleCommand("css_w", "Sends a private message")] [CommandHelper(usage: " ", whoCanExecute: CommandUsage.CLIENT_ONLY)] public void OnPmCommand(CCSPlayerController? player, CommandInfo command) => RunConsole(player, command, "pm"); [ConsoleCommand("css_r", "Replies to the last private message")] [CommandHelper(usage: "", whoCanExecute: CommandUsage.CLIENT_ONLY)] public void OnReplyCommand(CCSPlayerController? player, CommandInfo command) => RunConsole(player, command, "r"); [ConsoleCommand("css_block", "Stops a player sending you private messages")] [CommandHelper(usage: "<#userid|steamid64>", whoCanExecute: CommandUsage.CLIENT_ONLY)] public void OnBlockCommand(CCSPlayerController? player, CommandInfo command) => RunConsole(player, command, "block"); [ConsoleCommand("css_blocklist", "Lists who you've blocked")] [CommandHelper(whoCanExecute: CommandUsage.CLIENT_ONLY)] public void OnBlockListCommand(CCSPlayerController? player, CommandInfo command) => RunConsole(player, command, "blocklist"); [ConsoleCommand("css_unblock", "Unblocks a player, by their number in /blocklist")] [CommandHelper(usage: "", whoCanExecute: CommandUsage.CLIENT_ONLY)] public void OnUnblockCommand(CCSPlayerController? player, CommandInfo command) => RunConsole(player, command, "unblock"); private void RunConsole(CCSPlayerController? player, CommandInfo command, string word) { if (player is not { IsValid: true, IsBot: false }) return; Run(player, word, command.ArgString.Trim(), reply => command.ReplyToCommand(reply)); } private void Run(CCSPlayerController player, string word, string args, Action reply) { switch (word) { case "pm": case "w": SendCommand(player, args, reply); break; case "r": ReplyCommand(player, args, reply); break; case "block": Block(player, args, reply); break; case "blocklist": BlockList(player, reply); break; case "unblock": Unblock(player, args, reply); break; } } // The next word, or a "quoted name with spaces", taken off the front of text. private static string NextToken(ref string text) { text = text.TrimStart(); if (text.Length == 0) return ""; if (text[0] == '"') { var close = text.IndexOf('"', 1); var quoted = close < 0 ? text[1..] : text[1..close]; text = close < 0 ? "" : text[(close + 1)..]; return quoted; } var end = text.IndexOfAny([' ', '\t']); var token = end < 0 ? text : text[..end]; text = end < 0 ? "" : text[end..]; return token; } // --- Sending -------------------------------------------------------------------- private static string Notice(string text) => $" {ChatColors.Default}{text}"; private void SendCommand(CCSPlayerController sender, string args, Action reply) { var rest = args; var target = NextToken(ref rest); var message = rest.Trim(); if (target.Length == 0 || message.Length == 0) { reply(Notice("Usage: /pm ")); return; } var (recipient, result, problem) = Resolve(sender, target); if (recipient == null) { Log(sender, null, target, message, result); reply(Notice(problem)); return; } Deliver(sender, recipient, target, message, reply); } private void ReplyCommand(CCSPlayerController sender, string args, Action reply) { var message = args.Trim(); if (message.Length == 0) { reply(Notice("Usage: /r ")); return; } if (!_lastFrom.TryGetValue(sender.SteamID, out var from)) { reply(Notice("No one has sent you a private message yet.")); return; } var recipient = Humans().FirstOrDefault(p => p.SteamID == from && CanSee(sender, p)); if (recipient == null) { Log(sender, null, "/r", message, Result.NoTarget); reply(Notice("They're not on the server any more.")); return; } Deliver(sender, recipient, "/r", message, reply); } private void Deliver(CCSPlayerController sender, CCSPlayerController recipient, string target, string message, Action reply) { if (IsGagged(sender, out var permanent)) { Log(sender, recipient, target, message, Result.Gagged); reply(Notice(permanent ? "You can't send private messages: you're gagged permanently." : "You can't send private messages while you're gagged.")); return; } var now = DateTime.UtcNow; if (_nextSend.TryGetValue(sender.SteamID, out var next) && now < next) { Log(sender, recipient, target, message, Result.Cooldown); reply(Notice("You're sending messages too quickly.")); return; } if (message.Length > Config.MaxMessageLength) { Log(sender, recipient, target, message, Result.TooLong); reply(Notice($"That message is too long (at most {Config.MaxMessageLength} characters).")); return; } var self = recipient.SteamID == sender.SteamID; if (!self && !IsStaff(sender) && _blocks.TryGetValue(recipient.SteamID, out var blocks) && blocks?.Any(b => b.SteamId == sender.SteamID) == true) { Log(sender, recipient, target, message, Result.Blocked); reply(Notice($"{recipient.PlayerName} isn't accepting your messages.")); return; } _nextSend[sender.SteamID] = now.AddSeconds(Config.CooldownSeconds); var (from, color) = Speaker(sender); recipient.PrintToChat($" {Tags.Colors(Config.Open)}{from} {Tags.Colors(Config.Arrow)} {Tags.Colors(Config.Me)}{Tags.Colors(Config.Close)} {color}{message}"); if (!self) { sender.PrintToChat($" {Tags.Colors(Config.Open)}{from} {Tags.Colors(Config.Arrow)} {Nick(recipient)}{Tags.Colors(Config.Close)} {color}{message}"); _lastFrom[recipient.SteamID] = sender.SteamID; } Log(sender, recipient, target, message, Result.Sent); } // The sender as the line shows them, "[M] Kalus" in tags.json's colours, and their message colour. private (string From, string MessageColor) Speaker(CCSPlayerController player) { var tag = _tags?.Find(player); var team = (int)player.TeamNum; var prefix = tag is { Prefix: not "" } ? Tags.Colors(tag.Prefix, team).TrimEnd() + " " : ""; var color = tag is { MessageColor: not "" } ? Tags.Colors(tag.MessageColor, team) : ChatColors.Default.ToString(); return ($"{prefix}{Nick(player, tag)}", color); } // A name in its nick_color from tags.json, or the team colour when that's empty. private string Nick(CCSPlayerController player, Tags.Tag? tag = null) { tag ??= _tags?.Find(player); var team = (int)player.TeamNum; var color = tag is { NickColor: not "" } ? Tags.Colors(tag.NickColor, team) : Tags.TeamColor(team); return $"{ChatColors.Default}{color}{player.PlayerName}"; } private bool IsGagged(CCSPlayerController player, out bool permanent) { permanent = false; if (_simpleAdmin == null) return false; try { var status = _simpleAdmin.GetPlayerMuteStatus(player); var blocks = new[] { PenaltyType.Gag, PenaltyType.Silence } .SelectMany(t => status.TryGetValue(t, out var list) ? list : []).ToList(); permanent = blocks.Any(b => b.Duration == 0); return blocks.Count > 0; } catch (Exception) { return false; } } // --- Targets -------------------------------------------------------------------- // One player from a name, #userid (as css_players shows it), SteamID64 or STEAM_ ID, @me, @aim, // @random, @randomt or @randomct. Never a bot, and never a hidden admin unless staff are asking. private (CCSPlayerController? Player, string Result, string Problem) Resolve(CCSPlayerController sender, string target) { var humans = Humans().Where(p => CanSee(sender, p)).ToList(); if (GroupTargets.Contains(target)) return (null, Result.GroupTarget, "Private messages go to one player at a time."); if (target.StartsWith('@')) { switch (target.ToLowerInvariant()) { case "@me": return (sender, Result.Sent, ""); case "@aim": { var rules = Utilities.FindAllEntitiesByDesignerName("cs_gamerules").FirstOrDefault()?.GameRules; var aimed = rules?.GetClientAimTarget(sender); var found = aimed == null ? null : humans.FirstOrDefault(p => p.Slot == aimed.Slot); return found != null ? (found, Result.Sent, "") : (null, Result.NoTarget, "You're not looking at a player."); } case "@random": case "@randomt": case "@randomct": { var team = target.ToLowerInvariant() switch { "@randomt" => (int)CsTeam.Terrorist, "@randomct" => (int)CsTeam.CounterTerrorist, _ => -1, }; // Never a hidden admin, even for staff: a random pick shouldn't land on one. var pool = humans.Where(p => p.SteamID != sender.SteamID && !IsHidden(p) && (team < 0 || p.TeamNum == team)).ToList(); return pool.Count == 0 ? (null, Result.NoTarget, "There's no one to pick.") : (pool[Random.Shared.Next(pool.Count)], Result.Sent, ""); } default: return (null, Result.NoTarget, $"{target} isn't a target. Use a name, #userid, @aim, @random, @randomt, @randomct or @me."); } } if (TryId(target, out var userId, out var steamId)) { var byId = humans.FirstOrDefault(p => steamId != 0 ? p.SteamID == steamId : p.UserId == userId); return byId != null ? (byId, Result.Sent, "") : (null, Result.NoTarget, $"No player {target} on the server."); } var matches = humans.Where(p => p.PlayerName.Contains(target, StringComparison.OrdinalIgnoreCase)).ToList(); if (matches.Count > 1) { // A name that's exactly the pattern wins over names that only contain it. var exact = matches.Where(p => p.PlayerName.Equals(target, StringComparison.OrdinalIgnoreCase)).ToList(); if (exact.Count == 1) return (exact[0], Result.Sent, ""); return (null, Result.Ambiguous, $"More than one player matches \"{target}\": {string.Join(", ", matches.Select(p => $"{p.PlayerName} (#{p.UserId})"))}. Use their #userid."); } return matches.Count == 1 ? (matches[0], Result.Sent, "") : (null, Result.NoTarget, $"No player matches \"{target}\"."); } // "#5" (a userid), "#7656…"/"7656…" (a SteamID64) or "STEAM_0:1:…". steamId is 0 for a userid. private static bool TryId(string text, out int userId, out ulong steamId) { userId = 0; steamId = 0; var bare = text.TrimStart('#'); if (bare.StartsWith("STEAM_", StringComparison.OrdinalIgnoreCase)) { try { steamId = ((SteamID)bare).SteamId64; return steamId != 0; } catch (Exception) { return false; } } if (bare.Length == 17 && ulong.TryParse(bare, out steamId)) return true; steamId = 0; return text.StartsWith('#') && int.TryParse(bare, out userId); } // --- Blocks --------------------------------------------------------------------- private bool BlocksReady(CCSPlayerController player, Action reply, out List blocks) { if (_blocks.TryGetValue(player.SteamID, out var list) && list != null) { blocks = list; return true; } if (!_blocks.ContainsKey(player.SteamID)) LoadBlocks(player.SteamID); blocks = []; reply(Notice("Your block list is still loading. Try again in a moment.")); return false; } private void Block(CCSPlayerController player, string args, Action reply) { var target = args.Trim(); if (!TryId(target, out var userId, out var steamId)) { reply(Notice("Usage: /block <#userid|steamid64>. #userid is in css_players or status.")); return; } var online = Humans().FirstOrDefault(p => steamId != 0 ? p.SteamID == steamId : p.UserId == userId); if (online != null && !CanSee(player, online)) online = null; if (steamId == 0) { if (online == null) { reply(Notice($"No player {target} on the server. A SteamID64 works for players who are offline.")); return; } steamId = online.SteamID; } if (steamId == player.SteamID) { reply(Notice("You can't block yourself.")); return; } if (!BlocksReady(player, reply, out var blocks)) return; var name = online?.PlayerName ?? ""; var shown = name == "" ? steamId.ToString() : name; if (blocks.Any(b => b.SteamId == steamId)) { reply(Notice($"You've already blocked {shown}.")); return; } if (blocks.Count >= Config.MaxBlocks) { reply(Notice($"Your block list is full ({Config.MaxBlocks}). /unblock someone first.")); return; } var now = DateTime.UtcNow; blocks.Add(new Database.Block(steamId, name, now)); _writer?.Enqueue(new BlockAdd(player.SteamID, steamId, name, now)); reply(Notice($"Blocked {shown}. They can't send you private messages.")); } private void BlockList(CCSPlayerController player, Action reply) { if (!BlocksReady(player, reply, out var blocks)) return; if (blocks.Count == 0) { reply(Notice("You haven't blocked anyone.")); return; } reply(Notice($"You've blocked {blocks.Count} {(blocks.Count == 1 ? "player" : "players")}. /unblock to remove one:")); for (var i = 0; i < blocks.Count; i++) { var b = blocks[i]; reply(Notice(b.Name == "" ? $"{i + 1}. {b.SteamId}" : $"{i + 1}. {b.Name} ({b.SteamId})")); } } private void Unblock(CCSPlayerController player, string args, Action reply) { if (!BlocksReady(player, reply, out var blocks)) return; if (!int.TryParse(args.Trim(), out var number) || number < 1 || number > blocks.Count) { reply(Notice(blocks.Count == 0 ? "You haven't blocked anyone." : $"Usage: /unblock , from 1 to {blocks.Count}. /blocklist shows the numbers.")); return; } var removed = blocks[number - 1]; blocks.RemoveAt(number - 1); _writer?.Enqueue(new BlockRemove(player.SteamID, removed.SteamId)); reply(Notice($"Unblocked {(removed.Name == "" ? removed.SteamId.ToString() : removed.Name)}.")); } // --- History -------------------------------------------------------------------- private void Log(CCSPlayerController sender, CCSPlayerController? recipient, string target, string message, string result) => Log(ChatChannel.Pm, StateOf(sender), recipient, target, message, result); private void Log(string channel, PlayerState sender, CCSPlayerController? recipient, string? target, string message, string result) { if (!Config.History || _writer == null) return; int? serverId = null; try { serverId = _simpleAdmin?.GetServerId(); } catch (Exception) { } _writer.Enqueue(new MessageRecord(Guid.NewGuid().ToString("N"), DateTime.UtcNow, serverId, Server.MapName ?? "", channel, sender, recipient == null ? null : StateOf(recipient), target, message, result)); } [ConsoleCommand("css_whisper_history", "Turns keeping message history on or off")] [CommandHelper(usage: "[on|off]", whoCanExecute: CommandUsage.CLIENT_AND_SERVER)] [RequiresPermissions("@css/root")] public void OnHistoryCommand(CCSPlayerController? player, CommandInfo command) { var arg = command.GetArg(1).ToLowerInvariant(); if (arg is not ("on" or "off")) { command.ReplyToCommand($"[Whisper] History is {(Config.History ? "on" : "off")}. Usage: css_whisper_history "); return; } Config.History = arg == "on"; try { SaveConfig(); command.ReplyToCommand($"[Whisper] History is now {arg}."); } catch (Exception ex) { command.ReplyToCommand($"[Whisper] History is now {arg}, but saving Whisper.json failed: {ex.Message}"); } } private void SaveConfig() { var path = Path.GetFullPath(Path.Combine(ModuleDirectory, "..", "..", "configs", "plugins", ModuleName, $"{ModuleName}.json")); var json = JsonSerializer.Serialize(Config, new JsonSerializerOptions { WriteIndented = true, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, }); var temp = path + ".tmp"; File.WriteAllText(temp, json); File.Move(temp, path, overwrite: true); } }