From 21e0a3f81aee63a82941a784fd9be9b64ec79417 Mon Sep 17 00:00:00 2001 From: Hipposgrumm Date: Sat, 31 May 2025 18:13:35 -0600 Subject: [PATCH] Moved Discord bot configuration into appsettings and added moderation commands. --- .gitignore | 2 +- src/CommandHandlers/ChatMessageHandler.cs | 2 +- src/CommandHandlers/LoginHandler.cs | 2 + src/Core/Client.cs | 57 ++- src/Core/Configuration.cs | 35 +- src/Core/Room.cs | 22 +- src/Management/Commands/TempMuteCommand.cs | 4 +- src/Management/DiscordManager.cs | 559 ++++++++++++++++----- src/appsettings.json | 44 ++ src/emote_data.json | 8 + src/sodoffmmo.csproj | 5 +- 11 files changed, 574 insertions(+), 166 deletions(-) diff --git a/.gitignore b/.gitignore index f474457..bb9d8c9 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,4 @@ src/bin src/obj src/Properties src/sodoffmmo.csproj.user -/src/discord_bot.json \ No newline at end of file +/src/discord_bot_token diff --git a/src/CommandHandlers/ChatMessageHandler.cs b/src/CommandHandlers/ChatMessageHandler.cs index 9801f51..044e3e2 100644 --- a/src/CommandHandlers/ChatMessageHandler.cs +++ b/src/CommandHandlers/ChatMessageHandler.cs @@ -12,7 +12,7 @@ class ChatMessageHandler : CommandHandler { if (ManagementCommandProcessor.ProcessCommand(message, client)) return Task.CompletedTask; if (CheckIllegalName(client)) return Task.CompletedTask; - if (client.TempMuted) { + if (client.Muted) { ClientMuted(client); return Task.CompletedTask; } diff --git a/src/CommandHandlers/LoginHandler.cs b/src/CommandHandlers/LoginHandler.cs index 89d95d6..16a2437 100644 --- a/src/CommandHandlers/LoginHandler.cs +++ b/src/CommandHandlers/LoginHandler.cs @@ -88,6 +88,8 @@ class LoginHandler : CommandHandler client.PlayerData.Role = info.Role; client.PlayerData.VikingId = info.Id; client.GameVersion = info.Version; + client.Muted = Client.MutedList.ContainsKey(info.Id); + client.Banned = Client.BannedList.ContainsKey(info.Id); return true; } } catch (Exception ex) { diff --git a/src/Core/Client.cs b/src/Core/Client.cs index 911ea00..99c6a5f 100644 --- a/src/Core/Client.cs +++ b/src/Core/Client.cs @@ -9,17 +9,23 @@ public class Client { static int id; static object lck = new(); + // VikingId, Reason (nullable) + public static readonly Dictionary MutedList = new(); + public static readonly Dictionary BannedList = new(); + public int ClientID { get; private set; } public PlayerData PlayerData { get; set; } = new(); public Room? Room { get; private set; } + public Room? ReturnRoomOnPardon { get; private set; } = null; public bool OldApi { get; set; } = false; - public bool TempMuted { get; set; } = false; + public uint GameVersion { get; set; } + public bool Muted { get; set; } = false; + public bool Banned { get; set; } = false; private readonly Socket socket; SocketBuffer socketBuffer = new(); private volatile bool scheduledDisconnect = false; private readonly object clientLock = new(); - public uint GameVersion { get; set; } public Client(Socket clientSocket) { socket = clientSocket; @@ -53,7 +59,12 @@ public class Client { // set variable player data as not valid, but do not reset all player data PlayerData.IsValid = false; - if (Room != null) { + bool transfer = Configuration.DiscordBotConfig != null && Room != null && room != null && + Configuration.DiscordBotConfig.Merges.Values + .Any(rooms => rooms.Contains(Room.Name) && rooms.Contains(room.Name)); + + ReturnRoomOnPardon = room; // This is needed so that users are put where they're supposed to when they're unbanned. + if (Room != null && (!Banned || !Room.Name.StartsWith("BannedUserRoom_"))) { Console.WriteLine($"Leave room: {Room.Name} (id={Room.Id}, size={Room.ClientsCount}) IID: {ClientID}"); Room.RemoveClient(this); @@ -61,19 +72,41 @@ public class Client { data.Add("r", Room.Id); data.Add("u", ClientID); Room.Send(NetworkObject.WrapObject(0, 1004, data).Serialize()); - DiscordManager.SendPlayerBasedMessage("", ":red_square: {1} has left the room.", Room, PlayerData); + if (!transfer) DiscordManager.SendPlayerBasedMessage("", ":red_square: {1} has left the room.", Room, PlayerData); } - // set new room (null when SetRoom is used as LeaveRoom) - Room = room; + if (Banned) { + if (room != null) { + Room = Room.GetOrAdd("BannedUserRoom_" + ClientID, autoRemove: true); + if (Room.Clients.Contains(this)) { + // Run all addclient things but no duplicate. + Room.AddClient(this); // Appends to end. + Room.RemoveClient(this); // Removes first instance. + } else { + Room.AddClient(this); + } + Send(Room.SubscribeRoom()); + } else { + Room?.RemoveClient(this); + Room = null; + } + } else { + // set new room (null when SetRoom is used as LeaveRoom) + Room? oldRoom = Room; + Room = room; - if (Room != null) { - Console.WriteLine($"Join room: {Room.Name} RoomID (id={Room.Id}, size={Room.ClientsCount}) IID: {ClientID}"); - Room.AddClient(this); + if (Room != null) { + Console.WriteLine($"Join room: {Room.Name} RoomID (id={Room.Id}, size={Room.ClientsCount}) IID: {ClientID}"); + Room.AddClient(this); - Send(Room.SubscribeRoom()); - if (Room.Name != "LIMBO") UpdatePlayerUserVariables(); // do not update user vars if room is limbo - DiscordManager.SendPlayerBasedMessage("", ":green_square: {1} has joined the room.", Room, PlayerData); + Send(Room.SubscribeRoom()); + if (Room.Name != "LIMBO") UpdatePlayerUserVariables(); // do not update user vars if room is limbo + if (transfer) { + DiscordManager.SendPlayerBasedMessage("", ":cyclone: {1} transferred from "+$"{oldRoom!.Name} to {room!.Name}.", Room, PlayerData); + } else { + DiscordManager.SendPlayerBasedMessage("", ":green_square: {1} has joined the room.", Room, PlayerData); + } + } } } } diff --git a/src/Core/Configuration.cs b/src/Core/Configuration.cs index 653a053..5481938 100644 --- a/src/Core/Configuration.cs +++ b/src/Core/Configuration.cs @@ -5,6 +5,7 @@ using System.Collections.Specialized; using System.Globalization; using System.Linq; using System.Text; +using System.Text.Json.Serialization; using System.Threading.Tasks; using Microsoft.Extensions.Configuration; @@ -12,6 +13,7 @@ namespace sodoffmmo.Core; internal static class Configuration { public static ServerConfiguration ServerConfiguration { get; private set; } = new ServerConfiguration(); + public static DiscordBotConfig? DiscordBotConfig { get; private set; } = null; public static SortedDictionary Emoticons = new(); public static SortedDictionary Actions = new(); @@ -33,15 +35,21 @@ internal static class Configuration { ServerConfiguration.Authentication = AuthenticationMode.Disabled; } - // Emoticons (the emojis that float above your head) - SortAndVersion(config.GetSection("Emoticons"), Emoticons); - // Actions (such as dances) - SortAndVersion(config.GetSection("Actions"), Actions); - // In EMD, there are seperate actions when in car that share IDs with normal actions. - // Since this is easy to check MMO-side (SPM uservar), we can just store these seperate - SortAndVersion(config.GetSection("EMDCarActions"), EMDCarActions); - // Canned Chat options (which can vary game to game) - SortAndVersion(config.GetSection("CannedChat"), CannedChat); + DiscordBotConfig = config.GetSection("DiscordBot").Get(); + if (DiscordBotConfig != null) { + // Emoticons (the emojis that float above your head) + SortAndVersion(config.GetSection("Emoticons"), Emoticons); + + // Actions (such as dances) + SortAndVersion(config.GetSection("Actions"), Actions); + + // In EMD, there are seperate actions when in car that share IDs with normal actions. + // Since this is easy to check MMO-side (SPM uservar), we can just store these seperate + SortAndVersion(config.GetSection("EMDCarActions"), EMDCarActions); + + // Canned Chat options (which can vary game to game) + SortAndVersion(config.GetSection("CannedChat"), CannedChat); + } } // While version checking isn't exactly necessary for Actions when using vanilla files, it's nice to support it anyway. @@ -97,6 +105,15 @@ internal sealed class ServerConfiguration { public string BypassToken { get; set; } = ""; } +internal sealed class DiscordBotConfig { + public bool Disabled { get; set; } + public ulong Server { get; set; } + public ulong Channel { get; set; } + public ulong RoleModerator { get; set; } + public ulong RoleAdmin { get; set; } + public Dictionary Merges { get; set; } +} + public enum AuthenticationMode { Disabled, Optional, RequiredForChat, Required } diff --git a/src/Core/Room.cs b/src/Core/Room.cs index 5e59735..75140aa 100644 --- a/src/Core/Room.cs +++ b/src/Core/Room.cs @@ -79,10 +79,10 @@ public class Room { public static bool Exists(string name) => rooms.ContainsKey(name); public static Room Get(string name) => rooms[name]; - + public static Room GetOrAdd(string name, bool autoRemove = false) { lock(RoomsListLock) { - if (!Room.Exists(name)) + if (!Room.Exists(name)) return new Room(name, autoRemove: autoRemove); return rooms[name]; } @@ -104,8 +104,13 @@ public class Room { NetworkObject obj = new(); NetworkArray roomInfo = new(); roomInfo.Add(Id); - roomInfo.Add(Name); // Room Name - roomInfo.Add(Group); // Group Name + if (Name.StartsWith("BannedUserRoom_")) { + roomInfo.Add("BannedUserRoom"); // Room Name + roomInfo.Add("BannedUserRoom"); // Group Name + } else { + roomInfo.Add(Name); // Room Name + roomInfo.Add(Group); // Group Name + } roomInfo.Add(true); // is game roomInfo.Add(false); // is hidden roomInfo.Add(false); // is password protected @@ -136,8 +141,13 @@ public class Room { NetworkArray r1 = new(); r1.Add(Id); - r1.Add(Name); // Room Name - r1.Add(Group); // Group Name + if (Name.StartsWith("BannedUserRoom_")) { + r1.Add("BannedUserRoom"); // Room Name + r1.Add("BannedUserRoom"); // Group Name + } else { + r1.Add(Name); // Room Name + r1.Add(Group); // Group Name + } r1.Add(true); r1.Add(false); r1.Add(false); diff --git a/src/Management/Commands/TempMuteCommand.cs b/src/Management/Commands/TempMuteCommand.cs index 6479173..097ed15 100644 --- a/src/Management/Commands/TempMuteCommand.cs +++ b/src/Management/Commands/TempMuteCommand.cs @@ -15,8 +15,8 @@ class TempMuteCommand : IManagementCommand { client.Send(Utils.BuildServerSideMessage($"TempMute: user {arguments[0]} not found", "Server")); return; } - target.TempMuted = !target.TempMuted; - if (target.TempMuted) + target.Muted = !target.Muted; + if (target.Muted) client.Send(Utils.BuildServerSideMessage($"TempMute: {arguments[0]} has been temporarily muted", "Server")); else client.Send(Utils.BuildServerSideMessage($"TempMute: {arguments[0]} has been unmuted", "Server")); diff --git a/src/Management/DiscordManager.cs b/src/Management/DiscordManager.cs index ce0c24e..8126968 100644 --- a/src/Management/DiscordManager.cs +++ b/src/Management/DiscordManager.cs @@ -6,87 +6,77 @@ using System.Collections.Concurrent; using sodoffmmo.Core; using Discord.Rest; using sodoffmmo.Data; +using System.Runtime.CompilerServices; +using System.Numerics; namespace sodoffmmo.Management; class DiscordManager { - private static readonly string token_file; - - private static readonly ConcurrentQueue queue = new(); - private static DiscordSocketClient client; - private static BotConfig config; - private static SocketTextChannel channel; - - static DiscordManager() { - DirectoryInfo dir = new DirectoryInfo(Path.Combine(Directory.GetCurrentDirectory(), "src")); - while (dir.Parent != null) { - if (dir.Name == "src" && dir.Exists) break; - dir = dir.Parent; - } - if (dir.Parent == null) dir = new DirectoryInfo(Directory.GetCurrentDirectory()); - token_file = Path.Combine(dir.FullName, "discord_bot.json"); - } + private static readonly ConcurrentQueue MessageQueue = new(); + private static DiscordSocketClient BotClient; + private static SocketTextChannel BotChannel; public static void Initialize() { - try { - // This approach is taken because I don't want to accidentally - // push my bot token because it was in appsettings. The - // json handled here has been added to gitignore. - if (File.Exists(token_file)) { - Task.Run(() => RunBot(JsonSerializer.Deserialize(File.ReadAllText(token_file)))); - } else { - Log("Discord Integration not started because the file containing the token was not found.\nPut the token in \""+token_file+"\" and restart the server to use Discord Integration.", ConsoleColor.Yellow); - File.WriteAllText(token_file, JsonSerializer.Serialize(new BotConfig { - Token = "" - }, new JsonSerializerOptions { - WriteIndented = true - })); + if (Configuration.DiscordBotConfig?.Disabled == false) { // Bot config isn't null or disabled. + try { + string botTokenPath = Path.Combine(Path.GetDirectoryName(typeof(Configuration).Assembly.Location), "discord_bot_token"); + if (File.Exists(botTokenPath)) { + string? token = new StreamReader(botTokenPath).ReadLine(); + if (token != null) { + if (Configuration.DiscordBotConfig.Server != 0) { + if (Configuration.DiscordBotConfig.Channel != 0) { + Task.Run(() => RunBot(token)); + return; + } + Log("Discord bot was not started because the ServerID isn't set. Configure it in appsettings.json or disable it altogether.", ConsoleColor.Yellow); + return; + } + Log("Discord bot was not started because the ChannelID isn't set. Configure it in appsettings.json or disable it altogether.", ConsoleColor.Yellow); + return; + } + } + Log("Discord bot was not started because the token wasn't found. Create the file containing the bot token (see instructions src/appsettings.json) or disable it altogether in appsettings.json.", ConsoleColor.Yellow); + } catch (Exception e) { + Log(e.ToString(), ConsoleColor.Red); } - } catch (Exception e) { - Log(e.ToString(), ConsoleColor.Red); } } - private static async Task RunBot(BotConfig? config) { - if (config == null) { - Log("Bot config didn't deserialize correctly. Discord Integration is not active.", ConsoleColor.Yellow); - return; - } - client = new DiscordSocketClient(new DiscordSocketConfig { + private static async Task RunBot(string token) { + BotClient = new DiscordSocketClient(new DiscordSocketConfig { GatewayIntents = GatewayIntents.AllUnprivileged | GatewayIntents.GuildMembers | GatewayIntents.MessageContent, AlwaysDownloadUsers = true }); - client.Ready += async () => { - channel = client.GetGuild(config.Server).GetTextChannel(config.Channel); + BotClient.Ready += async () => { + BotChannel = BotClient.GetGuild(Configuration.DiscordBotConfig!.Server).GetTextChannel(Configuration.DiscordBotConfig!.Channel); }; - client.MessageReceived += OnDiscordMessage; - await client.LoginAsync(TokenType.Bot, config.Token); - DiscordManager.config = config; - Log("Loaded bot config from "+token_file+" and bot is running."); - await client.StartAsync(); + BotClient.MessageReceived += OnDiscordMessage; + await BotClient.LoginAsync(TokenType.Bot, token); + await BotClient.StartAsync(); + Log("Discord bot is running."); while (true) { try { - if (!queue.IsEmpty && queue.TryDequeue(out QueuedMessage message)) { - SocketTextChannel? destination = channel; + if (!MessageQueue.IsEmpty && MessageQueue.TryDequeue(out QueuedMessage message)) { + SocketTextChannel? destination = BotChannel; if (message.room != null) { - if (message.room.Name == "LIMBO") continue; - string room = message.room.Name; - destination = channel.Threads.SingleOrDefault(x => x.Name == room); + if (message.room.Name == "LIMBO" || message.room.Name.StartsWith("BannedUserRoom_")) continue; // Don't log stuff in LIMBO or Banned User. + string room = message.roomName!; + destination = BotChannel.Threads.SingleOrDefault(x => x.Name == room); if (destination == null) { // A discord channel can have up to 1000 active threads // and an unlimited number of archived threads. if (message.room.AutoRemove) { // Set temporary rooms (such as user rooms) to shorter archive times. - destination = await channel.CreateThreadAsync(room, autoArchiveDuration: ThreadArchiveDuration.OneHour); + destination = await BotChannel.CreateThreadAsync(room, autoArchiveDuration: ThreadArchiveDuration.OneHour); } else { // Persistent rooms should stay around for longer. - destination = await channel.CreateThreadAsync(room, autoArchiveDuration: ThreadArchiveDuration.OneWeek); + destination = await BotChannel.CreateThreadAsync(room, autoArchiveDuration: ThreadArchiveDuration.OneWeek); } } } await destination.SendMessageAsync(message.msg); } - } catch (Exception e) { + } catch (Exception e) { // In case something goes wrong for any reason, don't "kill" the bot. Log(e.ToString(), ConsoleColor.Red); } } @@ -95,65 +85,331 @@ class DiscordManager { // For handling commands sent directly in the channel. private static async Task OnDiscordMessage(SocketMessage arg) { // Check that message is from a user in the correct place. - bool roomChannel = arg.Channel is SocketThreadChannel thr && thr.ParentChannel.Id == config.Channel; + bool roomChannel = arg.Channel is SocketThreadChannel thr && thr.ParentChannel.Id == BotChannel.Id; if (!( - arg.Channel.Id == config.Channel || - roomChannel + roomChannel || + arg.Channel.Id == BotChannel.Id ) || arg is not SocketUserMessage socketMessage || socketMessage.Author.IsBot ) return; - + // Get the room if it exists. + Room? room = (roomChannel && Room.Exists(arg.Channel.Name)) ? Room.Get(arg.Channel.Name) : null; + + // Perms + Role role = Role.User; + if (arg.Author is IGuildUser user) { + if (user.RoleIds.Contains(Configuration.DiscordBotConfig!.RoleAdmin)) { + role = Role.Admin; + } else if (user.RoleIds.Contains(Configuration.DiscordBotConfig!.RoleModerator)) { + role = Role.Admin; + } + } + string[] message = socketMessage.Content.Split(' '); if (message.Length >= 1) { string[] everywhereCommands = new string[] { - // Denotes commands that work identically regardless - // of whether you're in a room thread. - "!tempmute [string... reason] - Mute someone until they reconnect." + // Denotes commands that work the same or similarly + // regardless of whether you're in a room thread. + "!freechat - Enable/Disable free chat in all rooms.", + "!tempmute [string... reason] - Mute someone (until they reconnect or go elsewhere).", + "!untempmute - Revoke a tempmute.", + "!mute [string... reason] - Mute a vikingid (currently forgotten after server restart due to technical restrictions, but this will probably be fixed in the future).", + "!unmute - Revoke a mute.", + "!ban [string... reason] - Ban a vikingid from MMO (currently forgotten after server restart due to technical restrictions, but this will probably be fixed in the future).", + "!unban - Revoke a ban.", + "!pardon - Alias for !unban", + "!mutelist - Get a list of muted users.", + "!banlist - Get a list of banned users.", }; - bool un_command = message[0].StartsWith("!un"); - if (message[0] == "!tempmute" || message[0] == "!untempmute") { - if (message.Length > 1 && int.TryParse(message[1], out int id)) { - foreach (Room room in Room.AllRooms()) { - foreach (Client player in room.Clients) { - if (player.PlayerData.VikingId == id) { - if (player.TempMuted != un_command) { - await arg.Channel.SendMessageAsync($"This player is {(un_command ? "not" : "already")} tempmuted.", messageReference: arg.Reference); - } else { - player.TempMuted = !un_command; - string reason = string.Empty; - string msg = un_command ? "Un-tempmuted" : "Tempmuted"; - msg += $" {player.PlayerData.DiplayName}."; - if (message.Length >= 2) { - reason = string.Join(' ', message, 2, message.Length - 2); - msg += " Reason: "+reason; - } - await arg.Channel.SendMessageAsync(msg, messageReference: arg.Reference); - } - goto tempmute_search_loop_end; - } + if (message[0] == "!freechat") { + if (role < Role.Moderator) { + await arg.Channel.SendMessageAsync("You don't have permission to use this!", messageReference: arg.Reference); + return; + } + // freechat command + if (message.Length > 1) { + bool enable; + if (!bool.TryParse(message[1], out enable)) { + if ((message[1][0] & 0b11011111) == 'Y') { + enable = true; + } else if ((message[1][0] & 0b11011111) == (int)'N') { + enable = false; + } else { + await arg.Channel.SendMessageAsync("Input a boolean or yes/no value!", messageReference: arg.Reference); + return; } } - tempmute_search_loop_end:;// StackOverflow said to do it like this (no labeled break like in Java). + Configuration.ServerConfiguration.EnableChat = enable; + await arg.Channel.SendMessageAsync($"Chat {(enable?"en":"dis")}abled!", messageReference: arg.Reference); + } else { + await arg.Channel.SendMessageAsync("Input a value!", messageReference: arg.Reference); + } + } else if (message[0] == "!tempmute") { + if (role < Role.Moderator) { + await arg.Channel.SendMessageAsync("You don't have permission to use this!", messageReference: arg.Reference); + return; + } + // tempmute command + if (message.Length > 1 && int.TryParse(message[1], out int id)) { + IEnumerable vikings = FindViking(id, room); + if (room != null && !vikings.Any()) vikings = FindViking(id); // Plan B + if (vikings.Any()) { + bool sent = false; + string? reason = null; + if (message.Length > 2) { + reason = string.Join(' ', message, 2, message.Length - 2); + } + foreach (Client player in vikings) { + if (player.Muted) { + if (Client.MutedList.TryGetValue(id, out string? rreason)) { + string msg = "This user is already muted."; + if (rreason != null && rreason.Length > 0) msg += " Reason: "+rreason; + if (!sent) await arg.Channel.SendMessageAsync(msg, messageReference: arg.Reference); + } else { + if (!sent) await arg.Channel.SendMessageAsync("This user is already tempmuted.", messageReference: arg.Reference); + } + } else { + player.Muted = true; + string msg = $"Tempmuted {player.PlayerData.DiplayName}"; + if (reason != null) { + msg += ". Reason: "+reason; + } + if (!sent) await arg.Channel.SendMessageAsync(msg, messageReference: arg.Reference); + } + sent = true; + } + } else { + await arg.Channel.SendMessageAsync("User with that ID is not online.", messageReference: arg.Reference); + } } else { await arg.Channel.SendMessageAsync("Input a valid vikingid!", messageReference: arg.Reference); } - } else if (roomChannel) { - if (message[0] == "!help") { - await arg.Channel.SendMessageAsync(string.Join(Environment.NewLine, new string[] { - "### List of commands:", - "!help - Prints the help message. That's this message right here!", - "!list - Lists all players in this room.", - "!say - Say something in this room." - }.Concat(everywhereCommands) - )); - } else if (message[0] == "!list") { - if (!Room.Exists(arg.Channel.Name)) { - await arg.Channel.SendMessageAsync("This room is currently unloaded.", messageReference: arg.Reference); + } else if (message[0] == "!untempmute") { + if (role < Role.Moderator) { + await arg.Channel.SendMessageAsync("You don't have permission to use this!", messageReference: arg.Reference); + return; + } + // un-tempmute command + if (message.Length > 1 && int.TryParse(message[1], out int id)) { + IEnumerable vikings = FindViking(id, room); + if (room != null && !vikings.Any()) vikings = FindViking(id); // Plan B + if (vikings.Any()) { + bool sent = false; + foreach (Client player in vikings) { + if (player.Muted) { + if (Client.MutedList.ContainsKey(id)) { + if (!sent) await arg.Channel.SendMessageAsync("This user is true muted. To unmute them, use !unmute", messageReference: arg.Reference); + } else { + player.Muted = false; + if (!sent) await arg.Channel.SendMessageAsync($"Un-tempmuted {player.PlayerData.DiplayName}.", messageReference: arg.Reference); + } + } else { + if (!sent) await arg.Channel.SendMessageAsync("This user is not muted.", messageReference: arg.Reference); + } + sent = true; + } } else { - Room room = Room.Get(arg.Channel.Name); + await arg.Channel.SendMessageAsync("User with that ID is not online.", messageReference: arg.Reference); + } + } else { + await arg.Channel.SendMessageAsync("Input a valid vikingid!", messageReference: arg.Reference); + } + } else if (message[0] == "!mute") { + if (role < Role.Moderator) { + await arg.Channel.SendMessageAsync("You don't have permission to use this!", messageReference: arg.Reference); + return; + } + // true mute command + if (message.Length > 1 && int.TryParse(message[1], out int id)) { + string? name = null; + IEnumerable vikings = FindViking(id, room); + if (room != null && !vikings.Any()) vikings = FindViking(id); // Plan B + foreach (Client player in vikings) { + name = player.PlayerData.DiplayName; + player.Muted = true; + } + if (Client.MutedList.TryGetValue(id, out string? rreason)) { + string msg = "This user is already muted."; + if (rreason != null && rreason.Length > 0) msg += " Reason: "+rreason; + await arg.Channel.SendMessageAsync(msg, messageReference: arg.Reference); + } else { + string? reason = null; + if (message.Length > 2) { + reason = string.Join(' ', message, 2, message.Length - 2); + } + Client.MutedList.Add(id, reason); + string msg = $"Muted *VikingID {id}*"; + if (name != null) msg = $"Muted {name}"; + if (reason != null) { + msg += ". Reason: " + reason; + } + await arg.Channel.SendMessageAsync(msg, messageReference: arg.Reference); + } + } else { + await arg.Channel.SendMessageAsync("Input a valid vikingid!", messageReference: arg.Reference); + } + } else if (message[0] == "!unmute") { + if (role < Role.Moderator) { + await arg.Channel.SendMessageAsync("You don't have permission to use this!", messageReference: arg.Reference); + return; + } + // unmute command + if (message.Length > 1 && int.TryParse(message[1], out int id)) { + string? name = null; + IEnumerable vikings = FindViking(id, room); + if (room != null && !vikings.Any()) vikings = FindViking(id); // Plan B + if (vikings.Any()) { + foreach (Client player in vikings) { + if (player.Muted) { + name = player.PlayerData.DiplayName; + player.Muted = false; + } + } + } + if (Client.MutedList.Remove(id) || name != null) { // name is only set if there is an unmute + if (name != null) { + await arg.Channel.SendMessageAsync($"Unmuted {name}.", messageReference: arg.Reference); + } else { + await arg.Channel.SendMessageAsync($"Unmuted *VikingID {id}*.", messageReference: arg.Reference); + } + } else { + await arg.Channel.SendMessageAsync("This user is not muted.", messageReference: arg.Reference); + } + } else { + await arg.Channel.SendMessageAsync("Input a valid vikingid!", messageReference: arg.Reference); + } + } else if (message[0] == "!ban") { + if (role < Role.Moderator) { + await arg.Channel.SendMessageAsync("You don't have permission to use this!", messageReference: arg.Reference); + return; + } + // ban command + if (message.Length > 1 && int.TryParse(message[1], out int id)) { + string? name = null; + foreach (Client player in FindViking(id)) { + name = player.PlayerData.DiplayName; + player.Banned = true; + player.SetRoom(player.Room); // This will run the client through the 'if Banned' conditions. + } + if (Client.BannedList.TryGetValue(id, out string? rreason)) { + string msg = "This user is already banned."; + if (rreason != null && rreason.Length > 0) msg += " Reason: "+rreason; + await arg.Channel.SendMessageAsync(msg, messageReference: arg.Reference); + } else { + string? reason = null; + if (message.Length > 2) { + reason = string.Join(' ', message, 2, message.Length - 2); + } + Client.BannedList.Add(id, reason); + string msg = $"Banned *VikingID {id}*"; + if (name != null) msg = $"Banned {name}"; + if (reason != null) { + msg += ". Reason: " + reason; + } + await arg.Channel.SendMessageAsync(msg, messageReference: arg.Reference); + } + } else { + await arg.Channel.SendMessageAsync("Input a valid vikingid!", messageReference: arg.Reference); + } + } else if (message[0] == "!unban" || message[0] == "!pardon") { + if (role < Role.Moderator) { + await arg.Channel.SendMessageAsync("You don't have permission to use this!", messageReference: arg.Reference); + return; + } + // unban command + if (message.Length > 1 && int.TryParse(message[1], out int id)) { + if (Client.BannedList.Remove(id)) { + string? playername = null; + foreach (Room rooom in Room.AllRooms()) { + foreach (Client player in rooom.Clients) { + if (player.PlayerData.VikingId == id) { + playername = player.PlayerData.DiplayName; + player.Banned = false; + player.SetRoom(player.ReturnRoomOnPardon); // Put the player back. + } + } + } + if (playername != null) { + await arg.Channel.SendMessageAsync($"Unbanned {playername}.", messageReference: arg.Reference); + } else { + await arg.Channel.SendMessageAsync($"Unbanned *VikingID {id}*.", messageReference: arg.Reference); + } + } else { + await arg.Channel.SendMessageAsync("This user is not banned.", messageReference: arg.Reference); + } + } else { + await arg.Channel.SendMessageAsync("Input a valid vikingid!", messageReference: arg.Reference); + } + } else if (message[0] == "!mutelist") { + // mutelist command + if (Client.MutedList.Count > 0) { + Dictionary clients = new(); + foreach (Room rooom in Room.AllRooms()) { + foreach (Client player in rooom.Clients) { + int id = player.PlayerData.VikingId; + if (Client.MutedList.ContainsKey(id)) { + clients.TryAdd(id, player.PlayerData.DiplayName); + } + } + } + string msg = "Muted Players:"; + foreach (KeyValuePair play in Client.MutedList) { + if (clients.TryGetValue(play.Key, out string? name)) { + msg += $"\n* {SanitizeString(name ?? "")} ||VikingId: {play.Key}||"; + } else { + msg += $"\n* *Offline Viking {play.Key}*"; + } + if (play.Value != null) { + msg += $"; Reason: {play.Value}"; + } + } + await arg.Channel.SendMessageAsync(msg, messageReference: arg.Reference); + } else { + await arg.Channel.SendMessageAsync("No-one is muted.", messageReference: arg.Reference); + } + } else if (message[0] == "!banlist") { + // mutelist command + if (Client.BannedList.Count > 0) { + Dictionary clients = new(); + foreach (Room rooom in Room.AllRooms()) { + foreach (Client player in rooom.Clients) { + int id = player.PlayerData.VikingId; + if (Client.BannedList.ContainsKey(id)) { + clients.TryAdd(id, player.PlayerData.DiplayName); + } + } + } + string msg = "Banned Players:"; + foreach (KeyValuePair play in Client.BannedList) { + if (clients.TryGetValue(play.Key, out string? name)) { + msg += $"\n* {SanitizeString(name ?? "")} ||VikingId: {play.Key}||"; + } else { + msg += $"\n* *Offline Viking {play.Key}*"; + } + if (play.Value != null) { + msg += $"; Reason: {play.Value}"; + } + } + await arg.Channel.SendMessageAsync(msg, messageReference: arg.Reference); + } else { + await arg.Channel.SendMessageAsync("No-one is banned.", messageReference: arg.Reference); + } + } else if (roomChannel) { + if (room != null) { + if (message[0] == "!help") { + // help command (room ver.) + await arg.Channel.SendMessageAsync(string.Join(Environment.NewLine+"* ", new string[] { + "### List of commands:", + "!help - Prints the help message. That's this message right here!", + "!list - Lists all players in this room.", + "!say - Say something in this room." + }.Concat(everywhereCommands) + )); + } else if (message[0] == "!list") { + // list command (room ver.) if (room.ClientsCount > 0) { - string msg = "Players in Room:\n"; + string msg = "Players in Room:"; foreach (Client player in room.Clients) { string vikingid; if (player.PlayerData.VikingId != 0) { @@ -161,19 +417,21 @@ class DiscordManager { } else { vikingid = "||(Not Authenticated!)||"; } - msg += $"* {SanitizeString(player.PlayerData.DiplayName)} {vikingid}\n"; + msg += $"\n* {SanitizeString(player.PlayerData.DiplayName)} {vikingid}"; + if (player.Muted) { + msg += " (:mute: Muted)"; + } } SendMessage(msg, room); // Sent like this in case it's longer than 2000 characters (handled by this method). } else { await arg.Channel.SendMessageAsync("This room is empty.", messageReference: arg.Reference); } - } - } else if (message[0] == "!say") { - if (message.Length > 1) { - // Due to client-based restrictions, this probably only works in SoD and maybe MaM. - // Unless we want to create an MMOAvatar instance for the server "user". - if (Room.Exists(arg.Channel.Name)) { - Room room = Room.Get(arg.Channel.Name); + } else if (message[0] == "!say") { + // say command + if (message.Length > 1) { + // Due to client-based restrictions, this probably only works in SoD and maybe MaM. + // Unless we want to create an MMOAvatar instance for the server "user". + // This is because the code for chatlogging is tied to ChatBubble and only ChatBubble. if (room.ClientsCount > 0) { room.Send(Utils.BuildChatMessage(Guid.Empty.ToString(), string.Join(' ', message, 1, message.Length-1), "Server")); await arg.AddReactionAsync(Emote.Parse(":white_check_mark:")); @@ -181,14 +439,15 @@ class DiscordManager { await arg.Channel.SendMessageAsync("There is no-one in this room to see your message.", messageReference: arg.Reference); } } else { - await arg.Channel.SendMessageAsync("This room is currently unloaded.", messageReference: arg.Reference); + await arg.Channel.SendMessageAsync("You didn't include any message content!", messageReference: arg.Reference); } - } else { - await arg.Channel.SendMessageAsync("You didn't include any message content!", messageReference: arg.Reference); } + } else { + await arg.Channel.SendMessageAsync("This room is currently unloaded.", messageReference: arg.Reference); } } else { if (message[0] == "!help") { + // help command (global) await arg.Channel.SendMessageAsync(string.Join(Environment.NewLine, new string[] { "### List of commands:", "!help - Prints the help message. That's this message right here!", @@ -199,7 +458,8 @@ class DiscordManager { .Append("*Using !help in a room thread yields a different set of commands. Try using !help in a room thread!*") )); } else if (message[0] == "!rooms") { - IEnumerable threads = await channel.GetActiveThreadsAsync(); + // rooms command + IEnumerable threads = await BotChannel.GetActiveThreadsAsync(); Console.WriteLine(threads.Count()); ulong uid = socketMessage.Author.Id; foreach (RestThreadChannel thread in threads) { @@ -211,10 +471,11 @@ class DiscordManager { await ping.DeleteAsync(); } } else if (message[0] == "!list") { + // list command (global) string msg = string.Empty; - foreach (Room room in Room.AllRooms()) { - foreach (Client player in room.Clients) { - if (msg == string.Empty) msg = "Players Online:\n"; + foreach (Room rooom in Room.AllRooms()) { + foreach (Client player in rooom.Clients) { + if (msg == string.Empty) msg = "Players Online:"; string vikingid; if (player.PlayerData.VikingId != 0) { @@ -222,7 +483,10 @@ class DiscordManager { } else { vikingid = "||(Not Authenticated!)||"; } - msg += $"* {SanitizeString(player.PlayerData.DiplayName)} {vikingid} ({room.Name})\n"; + msg += $"\n* {SanitizeString(player.PlayerData.DiplayName)} {vikingid} ({rooom.Name})"; + if (player.Muted) { + msg += " (:mute: Muted)"; + } } } if (msg == string.Empty) { @@ -241,13 +505,14 @@ class DiscordManager { /// /// Message to send /// Room to send message in - can be null - public static void SendMessage(string msg, Room? room=null) { - if (client != null) { + public static void SendMessage(string msg, Room? room=null, string? roomName=null) { + if (BotClient != null) { while (msg.Length > 0) { string piece = msg; if (msg.Length > 2000) { // Discord character limit is 2000. - // Find a good place to break the message, if possible. - int breakIndex = msg.LastIndexOfAny(new char[2] {' ','\n'}, 2000); + // Find a good place to break the message, if possible. Otherwise revert to 2000. + int breakIndex = msg.LastIndexOf('\n', 2000); + if (breakIndex <= 0) breakIndex = msg.LastIndexOf(' ', 2000); if (breakIndex <= 0) breakIndex = 2000; // Split the first part of the message and recycle the rest. piece = msg.Substring(0, breakIndex); @@ -256,9 +521,10 @@ class DiscordManager { // Exit the loop when the time comes. msg = string.Empty; } - queue.Enqueue(new QueuedMessage { + MessageQueue.Enqueue(new QueuedMessage { msg = piece, - room = room + room = room, + roomName = roomName ?? room?.Name }); } } @@ -277,29 +543,41 @@ class DiscordManager { /// PlayerData to associate the message with - can be null public static void SendPlayerBasedMessage(string msg, string surround="{1} **-->** {0}", Room? room=null, PlayerData? player=null) { try { - if (client != null) { + if (BotClient != null) { // We need to sanitize things first so that people can't // use Discord's formatting features inside a chat message. msg = SanitizeString(msg); string name = SanitizeString(player?.DiplayName ?? ""); + // Define for merged rooms. + string? roomName = room?.Name; + if (roomName != null) { + foreach (KeyValuePair pair in Configuration.DiscordBotConfig!.Merges) { + if (pair.Value.Contains(roomName)) { + roomName = pair.Key; + if (roomName != room!.Name) surround = $"({room!.Name}) "+surround; + break; + } + } + } + if (player != null) { if (player.VikingId != 0) { - surround = "||VikingId: "+player.VikingId+"||\n"+surround; + surround = $"||VikingId: {player.VikingId}||\n{surround}"; } else { - surround = "||(Not Authenticated!)||\n"+surround; + surround = $"||(Not Authenticated!)||\n{surround}"; } } // Send the sanitized message, headed with the viking id (if authenticated). - SendMessage(string.Format(surround, msg, name), room); + SendMessage(string.Format(surround, msg, name), room, roomName); } } catch (Exception e) { Log(e.ToString(), ConsoleColor.Red); } } - public static string SanitizeString(string str) { + private static string SanitizeString(string str) { string sanitizedStr = ""; for (int i=0;i + /// Search all rooms for a vikingid, or null if not found. + /// This method currently returns a list because multiple clients can log in with the same viking at once. This should get fixed properly in the future. + /// + /// Viking ID + /// Clients for ID. If there are none then return an empty list. + private static IEnumerable FindViking(int id, Room? room=null) { + List clients = new(); + if (room != null) { + foreach (Client player in room.Clients) { + if (player.PlayerData.VikingId == id) { + clients.Add(player); + } + } + return clients; + } + foreach (Room rooom in Room.AllRooms()) { + foreach (Client player in rooom.Clients) { + if (player.PlayerData.VikingId == id) { + clients.Add(player); + } + } + } + return clients; } private class QueuedMessage { public string msg; public Room? room; + public string? roomName; } private static void Log(string msg, ConsoleColor? color=null) { diff --git a/src/appsettings.json b/src/appsettings.json index aacbd03..8d4203e 100644 --- a/src/appsettings.json +++ b/src/appsettings.json @@ -68,5 +68,49 @@ "// BypassToken": "Token allowed to connect without authentication", "BypassToken": "" + }, + "DiscordBot": { + "// DiscordBot": "This allows you to set up a Discord bot that logs all MMO interactions to a Discord channel.", + "// DiscordBot_": "You are not required to set this up. The server will run without it. Set the value below to true to disable the warning (and the bot).", + "Disabled": false, + + "// DiscordBot+": "In the bot settings, enable the 'Server Members' and 'Message Content' intents. Otherwise, (some/all) commands may not work properly/at all.", + + "// BotToken": "DO NOT PUT YOUR BOT TOKEN HERE! If you push your bot token to GitHub it may endanger your Discord account!", + "// BotToken_": "Place your token in a file named 'discord_bot_token' in this directory. Git is configured here to not include that file. DO NOT SHARE YOUR BOT TOKEN!", + + "// How To Get IDs": "The below options require IDs used by Discord's API. You can find these by enabling developer mode (User Settings > Advanced > Developer Mode) and then right clicking the thing and select 'Copy ____ ID' (bottom-most option).", + + "// Server": "This is the ID of the server the bot will operate in. Make sure the bot is in this server.", + "Server": 846864309454897214, + "// Channel": "This is the ID of the channel that the bot logs activity and accepts moderation commands. Make sure the bot has access to this channel.", + "Channel": 1130548490041303070, + + "// RoleModerator": "ID of the role that will be moderator. This role can perform moderative actions such as muting and banning players.", + "RoleModerator": 1378174063599550554, + + "// RoleAdmin": "ID of the role that will be admin. This role can perform dangerous actions and moderative actions.", + "RoleAdmin": 1378174063599550554, + + "// Merges": "Merge multi-zone rooms into one channel.", + "Merges": { + "MainStreet": [ + "MainStreet", + "MainStreetFunZone" + ], + "LoungeInt": [ + "LoungeInt", + "LoungeIntUpper" + ], + "DWMadEurope": [ + "DWMadEuropeItaly", + "DWMadEuropeParis", + "DWMadEuropeAlps" + ], + "DWMadNYCentralPark": [ + "DWMadNYCentralPark", + "DWMadNYCentralParkFunZone" + ] + } } } diff --git a/src/emote_data.json b/src/emote_data.json index c410bbb..3de5a5c 100644 --- a/src/emote_data.json +++ b/src/emote_data.json @@ -1,4 +1,12 @@ { + "// Usage": { "_": "(This example section isn't read. It can therefore be used for comments/documentation.)", + "// Desc": "Actions, emoticons, and canned chat messages are sent as IDs. This file maps these IDs to meaningful representations.", + "// Desc2": "If adding/changing any of these emotes, you should update this file accordingly.", + "450": { "_ID": "ID of the emote; this should be unique to the emote and must be a 32-bit integer.", + "a0000000": "I'm in School of Dragons!", "_Version": "Override for game with ClientVersion greater than or equal to this. For instance, this targets Min_SoD. Representation is in hexadecimal and is NOT case-sensitive. There can be multiple of these.", + "0": "I'm in a JumpStart Game!", "_Zero": "There should be something at 0 to handle all lower ClientVersions, lest it show up as unknown." + } + }, "Emoticons": { "1": { "04000001": ":grimacing:", diff --git a/src/sodoffmmo.csproj b/src/sodoffmmo.csproj index 7824dd8..3e64ac4 100644 --- a/src/sodoffmmo.csproj +++ b/src/sodoffmmo.csproj @@ -1,4 +1,4 @@ - + Exe @@ -24,5 +24,8 @@ PreserveNewest + + PreserveNewest +