using axXez.TS3.Protocol; using System; using System.Collections.Generic; using System.Linq; using System.Text; namespace axXez.TS3.Bot.Modules { // This module is always loaded and uses Bot.Config directly — do not add any module settings here. [Module(Namespace = "axXez", DisplayName = "Bot Commands", Compatibility = ModuleCompatibility.Any)] internal class BotControl : Module { [Command("help", Description = "Lists all available commands", Role = CommandRole.Guest, Scope = CommandScope.Any)] public CommandResponse PrintHelp(CommandContext ctx, string command = null) { var invokerRole = Bot.GetClientRole(ctx.Invoker); var allCommands = Bot.RegisteredCommands; // extended help for a specific command or sub-command group if (ctx.Args.Length > 0) { var keyword = string.Join(" ", ctx.Args).ToLower(); // exact match — flat command or specific sub-command var exact = allCommands.FirstOrDefault(c => c.Keyword.Equals(keyword, StringComparison.OrdinalIgnoreCase)); if (exact != null) { return ctx.Ok(exact.Help, MessageTargetMode.Private); } // prefix match — show all sub-commands of that group var subs = allCommands .Where(c => c.Keyword.StartsWith(keyword + " ", StringComparison.OrdinalIgnoreCase) && (invokerRole & c.Role) != 0) .ToList(); if (subs.Count > 0) { ctx.Reply($"[U][B]!{keyword}[/B][/U]", MessageTargetMode.Private); foreach (var sub in subs) ctx.Reply(sub.Help, MessageTargetMode.Private); return ctx.Ok(); } return ctx.Error($"Unknown command '[B]{keyword}[/B]'."); } // top-level listing: flat commands + one entry per sub-command group var seenGroups = new HashSet(); foreach (var mod in allCommands.GroupBy(x => x.Module)) { var modLines = new List(); foreach (var cmd in mod) { if ((invokerRole & cmd.Role) == 0) continue; if (cmd.Keyword.Contains(' ')) { // sub-command — show group header once var group = cmd.Keyword.Split(' ')[0]; if (seenGroups.Add(group)) modLines.Add($"[COLOR=red][B]!{group}[/B][/COLOR] - use [B]!help {group}[/B] for sub-commands"); } else { modLines.Add($"[COLOR=red][B]!{cmd.Keyword}[/B][/COLOR] - {cmd.Description}"); } } if (modLines.Count > 0) { ctx.Reply($"[U][B]{mod.Key.DisplayName}[/B][/U]", MessageTargetMode.Private); foreach (var line in modLines) ctx.Reply(line, MessageTargetMode.Private); } } return ctx.Ok(); } [Command("bot status", Description = "Shows the current bot status", Role = CommandRole.Admin)] public CommandResponse Status(CommandContext ctx) { var version = System.Reflection.Assembly.GetExecutingAssembly().GetName().Version; ctx.Reply($"[B]axXezBot v{version}[/B] — {(Bot.Running ? "[COLOR=green]Running[/COLOR]" : "[COLOR=red]Stopped[/COLOR]")} | Connected: {(Server.Connected ? "[COLOR=green]Yes[/COLOR]" : "[COLOR=red]No[/COLOR]")}"); ctx.Reply($"Clients: {Server.Clients.Count} | Channels: {Server.Channels.Count} | ServerGroups: {Server.ServerGroups.Count} | ChannelGroups: {Server.ChannelGroups.Count}"); ctx.Reply($"Modules: {Bot.ActiveBotModules.Count} active | Plugins: {Bot.LoadedPlugins.Count} loaded"); return ctx.Ok($"Server: {Server.Binary?.Version} ({Server.Binary?.Platform})"); } [Command("bot reconnect", Description = "Force-reconnects the ServerQuery connection", Role = CommandRole.Admin)] public CommandResponse Reconnect(CommandContext ctx) { ctx.Reply("Reconnecting...", flush: true); Server.Reconnect(); return ctx.Ok(); } [Command("bot move", Description = "Move the bot to a channel", Role = CommandRole.Admin)] public CommandResponse Move(CommandContext ctx, [CommandParam("channel")] string channelName, bool save = false) { if (string.IsNullOrWhiteSpace(channelName)) return ctx.Error("No channel name provided."); //todo: find channel here to log and save channel name if (!Server.MoveToChannel(channelName)) return ctx.Error($"Channel '[B]{channelName}[/B]' not found or move failed."); if (save) { Bot.Config.DefaultChannel = channelName; Bot.Config.Save(); } return ctx.Ok($"Moved to '[B]{channelName}[/B]'{(save ? " and saved to config." : ".")}"); } [Command("bot nick", Description = "Change the bot's nickname", Role = CommandRole.Admin)] public CommandResponse Nick(CommandContext ctx, string name, bool save = false) { if (string.IsNullOrWhiteSpace(name)) return ctx.Error("No name provided."); if (!Server.SetNickname(name)) return ctx.Error("Failed to change nickname."); if (save) { Bot.Config.Name = name; Bot.Config.Save(); } return ctx.Ok($"Nickname changed to '[B]{name}[/B]'{(save ? " and saved to config." : ".")}"); } [Command("bot updaterate", Description = "Get or set the channel/client update interval", Role = CommandRole.Admin)] public CommandResponse UpdateRate(CommandContext ctx, string target = null, int ms = -1, bool save = false) { if (target == null) { return ctx.Ok($"Channel update: [B]{Bot.Config.ChannelUpdateInterval}ms[/B] | Client update: [B]{Bot.Config.ClientUpdateInterval}ms[/B] (0 = disabled)"); } if (ms < 0) return ctx.Error(); if (target.Equals("channel", StringComparison.OrdinalIgnoreCase)) { Server.SetChannelUpdateInterval(ms); if (save) { Bot.Config.ChannelUpdateInterval = ms; Bot.Config.Save(); } ctx.Reply($"Channel update interval set to [B]{ms}ms[/B]{(save ? " and saved." : ".")}"); } else if (target.Equals("client", StringComparison.OrdinalIgnoreCase)) { Server.SetClientUpdateInterval(ms); if (save) { Bot.Config.ClientUpdateInterval = ms; Bot.Config.Save(); } ctx.Reply($"Client update interval set to [B]{ms}ms[/B]{(save ? " and saved." : ".")}"); } else return ctx.Error("Unknown target. Use [B]channel[/B] or [B]client[/B]."); return ctx.Ok(); } [Command("bot stop", Description = "Stops the bot/process immediately", Role = CommandRole.Admin)] public CommandResponse StopBot(CommandContext ctx) { //Environment.Exit(0); Bot.Stop(); return ctx.Ok(); } [Command("plugin enable", Description = "Enable plugin loading", Role = CommandRole.Admin)] public CommandResponse EnablePlugins(CommandContext ctx) { Bot.Config.EnablePlugins = true; Bot.Config.Save(); Bot.LoadPlugins(); return ctx.Ok("Plugins [COLOR=green]enabled[/COLOR]."); } [Command("plugin disable", Description = "Disable plugin loading", Role = CommandRole.Admin)] public CommandResponse DisablePlugins(CommandContext ctx) { Bot.Config.EnablePlugins = false; Bot.Config.Save(); Bot.LoadPlugins(); return ctx.Ok("Plugins [COLOR=red]disabled[/COLOR]."); } [Command("plugin reload", Description = "Reload plugin DLLs from the plugins folder", Role = CommandRole.Admin)] public CommandResponse ReloadPlugins(CommandContext ctx) { if (!Bot.Config.EnablePlugins) return ctx.Error("Plugins are disabled. Use [B]!plugin enable[/B] first."); Bot.LoadPlugins(); return ctx.Ok("Plugins reloaded. Use [B]!module list[/B] to see newly available modules."); } [Command("module list", Description = "Lists all available modules", Role = CommandRole.Admin)] public CommandResponse ListModules(CommandContext ctx) { ctx.Reply("[U][B]Available modules[/B][/U]"); foreach (var moduleType in Bot.AvailableBotModules) { var active = Bot.ActiveBotModules.FirstOrDefault(m => m.Type == moduleType); if (active == null) ctx.Reply($"[COLOR=black][B]{moduleType.Name}[/B][/COLOR] ({moduleType.DisplayName}){(!moduleType.IsCompatibleWith(Server.Version) ? " - [COLOR=orange]incompatible[/COLOR]" : "")}"); else if (active.HasError) ctx.Reply($"[COLOR=black][B]{moduleType.Name}[/B][/COLOR] ({moduleType.DisplayName}) - [COLOR=red]ERROR: {active.LastException.Message}[/COLOR]"); else ctx.Reply($"[COLOR=black][B]{moduleType.Name}[/B][/COLOR] ({moduleType.DisplayName}) - [COLOR=green]LOADED[/COLOR]"); } return ctx.Ok(); } [Command("module error", Description = "Show the full error of a faulted module", Parameters = "[moduleName]", Role = CommandRole.Admin)] public CommandResponse ModuleError(CommandContext ctx) { var moduleName = string.Join(" ", ctx.Args).ToLower(); if (string.IsNullOrEmpty(moduleName)) return ctx.Error(); var active = Bot.ActiveBotModules.FirstOrDefault(m => m.Name.ToLower() == moduleName || m.FullName.ToLower() == moduleName); if (active == null) return ctx.Error($"Module '[B]{moduleName}[/B]' not found or not loaded."); if (!active.HasError) { return ctx.Ok($"Module '[B]{active.Name}[/B]' has no error."); } return ctx.Ok($"[B]{active.Name}[/B] error:\n{active.LastException}"); } [Command("module load", Description = "Load a module by name", Parameters = "[moduleName]", Role = CommandRole.Admin)] public CommandResponse LoadModule(CommandContext ctx) { var moduleName = string.Join(" ", ctx.Args).ToLower(); if (string.IsNullOrEmpty(moduleName)) return ctx.Error(); var modules = Bot.AvailableBotModules.Where(x => x.Name.ToLower() == moduleName || x.FullName.ToLower() == moduleName); if (modules.Count() > 1) { var sb = new StringBuilder("Multiple modules found. Please use full name:\n"); foreach (var module in modules) sb.AppendLine($"[COLOR=black][B]{module.FullName}[/B][/COLOR] ({module.DisplayName}) - {module.Description}"); return ctx.Error(sb.ToString()); } if (modules.Count() == 0) return ctx.Error("Module not found."); var mod = modules.First(); try { if (Bot.LoadModule(mod)) return ctx.Ok($"Module [B]{mod.Name} ({mod.FullName})[/B] loaded."); else return ctx.Error("Module already loaded. To unload use '!module unload'."); } catch (Exception ex) { return ctx.Error($"Loading module [B]{mod.Name}[/B] failed: {ex.Message}"); } } [Command("module unload", Description = "Unload a module by name", Parameters = "[moduleName]", Role = CommandRole.Admin)] public CommandResponse UnloadModule(CommandContext ctx) { var moduleName = string.Join(" ", ctx.Args).ToLower(); if (string.IsNullOrEmpty(moduleName)) return ctx.Error(); var modules = Bot.AvailableBotModules.Where(x => x.Name.ToLower() == moduleName || x.FullName.ToLower() == moduleName); if (modules.Count() > 1) { var sb = new StringBuilder("Multiple modules found. Please use full name:\n"); foreach (var module in modules) sb.AppendLine($"[COLOR=black][B]{module.FullName} ({module.DisplayName})[/B][/COLOR]"); return ctx.Error(sb.ToString()); } if (modules.Count() == 0) return ctx.Error("Module not found."); var mod = modules.First(); if (mod.Type == typeof(BotControl)) return ctx.Error($"Module [B]{mod.Name}[/B] cannot be unloaded."); if (Bot.UnloadModule(mod)) return ctx.Ok($"Module [B]{mod.Name} ({mod.FullName})[/B] unloaded."); return ctx.Error("Module not loaded. To load use '!module load'."); } [Command("admin list", Description = "Lists all entries in the Admin role", Role = CommandRole.Admin)] public CommandResponse ListAdmins(CommandContext ctx) { ctx.Reply($"[U][B]Bot Admins ({Bot.Config.Admins.Count})[/B][/U]"); foreach (var entry in Bot.Config.Admins) { if (Server.Clients.Values.FirstOrDefault(c => c.UID == entry) is ClientInfo online) ctx.Reply($"[COLOR=green]{online.Name}[/COLOR] ([COLOR=gray]{entry}[/COLOR]) — online"); else ctx.Reply($"[COLOR=gray]{entry}[/COLOR] — offline"); } return ctx.Ok(); } [Command("admin add", Description = "Add an entry to the Admin role", Role = CommandRole.Admin)] public CommandResponse AddAdmin(CommandContext ctx, string entry) { var (key, display) = ResolveEntry(entry); if (Bot.Config.Admins.Contains(key)) { return ctx.Ok($"{display} is already in the Admin role."); } Bot.Config.Admins.Add(key); Bot.Config.Save(); return ctx.Ok($"{display} added to admins."); } [Command("admin remove", Description = "Remove an entry from the Admin role", Role = CommandRole.Admin)] public CommandResponse RemoveAdmin(CommandContext ctx, string entry) { var (key, display) = ResolveEntry(entry); if (!Bot.Config.Admins.Contains(key)) { return ctx.Ok($"{display} is not in the Admin role."); } Bot.Config.Admins.Remove(key); Bot.Config.Save(); return ctx.Ok($"{display} removed from admins."); } [Command("mod list", Description = "Lists all entries in the Moderator role", Role = CommandRole.Admin)] public CommandResponse ListMods(CommandContext ctx) { ctx.Reply($"[U][B]Moderators ({Bot.Config.Moderators.Count})[/B][/U]"); foreach (var entry in Bot.Config.Moderators) ctx.Reply(FormatRoleEntry(entry)); return ctx.Ok(); } [Command("mod add", Description = "Add an entry to the Moderator role", Role = CommandRole.Admin)] public CommandResponse AddMod(CommandContext ctx, string entry) { var (key, display) = ResolveEntry(entry); if (Bot.Config.Moderators.Contains(key)) { return ctx.Ok($"{display} is already in the Moderator role."); } Bot.Config.Moderators.Add(key); Bot.Config.Save(); return ctx.Ok($"{display} added to moderators."); } [Command("mod remove", Description = "Remove an entry from the Moderator role", Role = CommandRole.Admin)] public CommandResponse RemoveMod(CommandContext ctx, string entry) { var (key, display) = ResolveEntry(entry); if (!Bot.Config.Moderators.Contains(key)) return ctx.Ok($"{display} is not in the Moderator role."); Bot.Config.Moderators.Remove(key); Bot.Config.Save(); return ctx.Ok($"{display} removed from moderators."); } [Command("user list", Description = "Lists all entries in the User role", Role = CommandRole.Admin)] public CommandResponse ListUsers(CommandContext ctx) { ctx.Reply($"[U][B]Users ({Bot.Config.Users.Count})[/B][/U]"); foreach (var entry in Bot.Config.Users) ctx.Reply(FormatRoleEntry(entry)); return ctx.Ok(); } [Command("user add", Description = "Add an entry to the User role", Role = CommandRole.Admin)] public CommandResponse AddUser(CommandContext ctx, string entry) { var (key, display) = ResolveEntry(entry); if (Bot.Config.Users.Contains(key)) return ctx.Ok($"{display} is already in the User role."); Bot.Config.Users.Add(key); Bot.Config.Save(); return ctx.Ok($"{display} added to users."); } [Command("user remove", Description = "Remove an entry from the User role", Role = CommandRole.Admin)] public CommandResponse RemoveUser(CommandContext ctx, string entry) { var (key, display) = ResolveEntry(entry); if (!Bot.Config.Users.Contains(key)) return ctx.Ok($"{display} is not in the User role."); Bot.Config.Users.Remove(key); Bot.Config.Save(); return ctx.Ok($"{display} removed from users."); } /// Resolves a user input string to a role list key and a display string. /// Tries online client by name first, then falls back to the raw value (UID, group ID, or group name). private (string key, string display) ResolveEntry(string input) { if (Server.FindClient(input, out Client client)) return (client.UID, $"[B]{client.Name}[/B] ([COLOR=gray]{client.UID}[/COLOR])"); if (Server.FindServerGroup(input, out ServerGroupInfo group)) return (group.ID.ToString(), $"group [B]{group.Name}[/B] ([COLOR=gray]{group.ID}[/COLOR])"); return (input, $"[COLOR=gray]{input}[/COLOR]"); } /// Formats a stored role entry for display — resolves UIDs to online client names and group IDs to group names where possible. private string FormatRoleEntry(string entry) { if (Server.Clients.Values.FirstOrDefault(c => c.UID == entry) is ClientInfo online) return $"[COLOR=green]{online.Name}[/COLOR] ([COLOR=gray]{entry}[/COLOR]) — online"; if (int.TryParse(entry, out int groupId) && Server.ServerGroups.TryGetValue(groupId, out var group)) return $"[COLOR=gray]{entry}[/COLOR] — group [B]{group.Name}[/B]"; return $"[COLOR=gray]{entry}[/COLOR]"; } } }