using axXez.TS3.Bot.Modules; using axXez.TS3.Protocol; using axXez.Utils; using System; using System.Collections.Generic; using System.Collections.Immutable; using System.IO; using System.Linq; using System.Reflection; using System.Text; using System.Text.RegularExpressions; namespace axXez.TS3.Bot { /// Top-level bot runtime: owns the , module lifecycle, command dispatch, and role resolution. public class BotContext { private const string BOT_SETTINGS_FOLDER = "settings"; private const string BOT_SETTINGS_FILE = "bot"; private const string BOT_PLUGINS_FOLDER = "plugins"; private const string BOT_DEFAULT_NAME = "axXezBot"; private static readonly Regex CommandRegex = new(@"^\s*(?

[\/!?.])(?[^\s]+) ?(?[^\x00]*)", RegexOptions.Compiled); readonly HashSet domainAssemblies; List loadedPugins = new(); List availableBotModules; List activeBotModules = new(); ServerConnection server; Dictionary activeCommands = new(); Dictionary _lastCommandTime = new(); ///

Whether the bot has been started and is currently running. public bool Running { get; private set; } /// The active bot configuration loaded from settings/bot.json. public Settings Config => Settings.Global; /// Assemblies that have been loaded as plugins from the plugins folder. public ImmutableList LoadedPlugins => loadedPugins.ToImmutableList(); /// All module types discovered from built-in assemblies and loaded plugins. public ImmutableList AvailableBotModules => availableBotModules.ToImmutableList(); /// Modules that are currently instantiated and active. public ImmutableList ActiveBotModules => activeBotModules.ToImmutableList(); /// All commands currently registered across all active modules. public ImmutableList RegisteredCommands => activeCommands.Values.ToImmutableList(); internal ServerConnection Server => server; /// Raised after the bot starts and the server connection begins. public event Action OnStart; /// Raised after the bot stops and all modules are torn down. public event Action OnStop; internal BotContext() { if (!Directory.Exists(BOT_SETTINGS_FOLDER)) Directory.CreateDirectory(BOT_SETTINGS_FOLDER); try { if (!Settings.Load($"{BOT_SETTINGS_FOLDER}/{BOT_SETTINGS_FILE}.json")) GlobalLogger.Log("BOT", "No settings file present. Default file was written.", LogType.Warning); } catch (Exception ex) { GlobalLogger.LogError("BOT", "Failed to load settings", ex); Environment.Exit(1); } Config.ApplyEnvironmentVariables(); if (string.IsNullOrWhiteSpace(Config.Password)) { GlobalLogger.Log("BOT", "No password configured. Set 'Password' in bot.json or via TS_PASSWORD environment variable.", LogType.Error); Environment.Exit(1); } GlobalLogger.EnableFileLogging = Config.EnableLogs; GlobalLogger.Verbose = Config.VerboseLogs; //get available modules from current assembly availableBotModules = ModuleType.FromAssembly(Assembly.GetExecutingAssembly()).ToList(); domainAssemblies = new(AppDomain.CurrentDomain.GetAssemblies()); LoadPlugins(); //remove non-existing and always-on modules from settings var botControlFullName = ModuleType.FromType().FullName; var invalidModules = Config.ActiveModules.Where(m => m == botControlFullName || !availableBotModules.Any(t => t.FullName == m)).ToArray(); if (invalidModules.Length > 0) { foreach (var moduleName in invalidModules) Config.ActiveModules.Remove(moduleName); Config.Save(); } //load active modules var activeModules = availableBotModules.Where(x => x.Type == typeof(BotControl) || Config.ActiveModules.Contains(x.FullName)); foreach (var module in activeModules) LoadModule(module, false); //initialize server connection server = new ServerConnection(Config.IP, Config.Port, Config.LoginName, Config.Password, Config.ServerID, Config.Transport) { Nickname = string.IsNullOrWhiteSpace(Config.Name) ? BOT_DEFAULT_NAME : Config.Name, DefaultChannelName = Config.DefaultChannel, AutoDisableQueryFloodProtection = Config.AutoDisableFloodProtection, ChannelUpdateInterval = Config.ChannelUpdateInterval, ClientUpdateInterval = Config.ClientUpdateInterval }; server.Query.KeepAliveInterval = Config.KeepAliveInterval; server.Query.CommandsPerSecond = Config.CommandsPerSecond; server.Query.ResponseTimeout = Config.ResponseTimeout; server.Query.ReconnectDelay = Config.ReconnectDelay; } internal void Start() { if (Running) return; Running = true; GlobalLogger.Log("BOT", "Bot started."); server.OnConnected += Server_OnConnected; server.OnDisconnected += Server_OnDisconnected; server.OnChannelCreated += Server_OnChannelCreated; server.OnChannelDeleted += Server_OnChannelDeleted; server.OnChannelUpdated += Server_OnChannelUpdated; server.OnChannelEdited += Server_OnChannelEdited; server.OnClientJoined += Server_OnClientJoined; server.OnClientLeaved += Server_OnClientLeaved; server.OnClientUpdated += Server_OnClientUpdated; server.OnClientMoved += Server_OnClientMoved; server.OnTextMessage += Server_OnTextMessage; server.OnServerEdited += Server_OnServerEdited; server.Start(); OnStart?.Invoke(); } /// Stops all modules, disconnects from the server, and raises . public void Stop() { if (!Running) return; server.OnConnected -= Server_OnConnected; server.OnDisconnected -= Server_OnDisconnected; server.OnChannelCreated -= Server_OnChannelCreated; server.OnChannelDeleted -= Server_OnChannelDeleted; server.OnChannelUpdated -= Server_OnChannelUpdated; server.OnChannelEdited -= Server_OnChannelEdited; server.OnClientJoined -= Server_OnClientJoined; server.OnClientLeaved -= Server_OnClientLeaved; server.OnClientUpdated -= Server_OnClientUpdated; server.OnClientMoved -= Server_OnClientMoved; server.OnTextMessage -= Server_OnTextMessage; server.OnServerEdited -= Server_OnServerEdited; //stop and unload all modules foreach (var module in activeBotModules) { if (ExecuteModuleAction(module, x => { x.Stop(); x.Unload(); })) InternalUnloadModule(module); } server.Stop(); Running = false; OnStop?.Invoke(); } #region plugin/module functions internal void LoadPlugins() { if (!Config.EnablePlugins && loadedPugins.Count == 0) return; //nothing to do string[] files; if (!Directory.Exists(BOT_PLUGINS_FOLDER)) { files = Array.Empty(); if (Config.EnablePlugins) Directory.CreateDirectory(BOT_PLUGINS_FOLDER); } else files = Directory.GetFiles(BOT_PLUGINS_FOLDER, "*.dll", SearchOption.AllDirectories); //build map of file -> assembly once, reused for both removal detection and loading var fileAssemblies = new Dictionary(); foreach (var f in files) { try { fileAssemblies[f] = Assembly.LoadFrom(f); } catch (Exception ex) { GlobalLogger.LogError("BOT", $"Couldn't read plugin \"{Path.GetFileName(f)}\"", ex); } } //unload removed plugins if (loadedPugins.Count > 0) { //note: Assembly.LoadFrom does not support true unloading — the assembly stays in memory until process restart. // modules are stopped and removed from available/active lists, but the CLR still holds the assembly. // full unloading would require AssemblyLoadContext with isCollectible: true. var presentAssemblies = new HashSet(fileAssemblies.Values); foreach (var plugin in loadedPugins.ToList()) { if (Config.EnablePlugins && presentAssemblies.Contains(plugin)) continue; //stop and unload all active modules from this assembly foreach (var module in activeBotModules.Where(m => m.Type.Type.Assembly == plugin).ToList()) UnloadModule(module); //remove from available modules availableBotModules.RemoveAll(m => m.Type.Assembly == plugin); loadedPugins.Remove(plugin); GlobalLogger.Log("BOT", $"Plugin \"{plugin.GetName().Name}\" removed."); } } if (!Config.EnablePlugins || fileAssemblies.Count == 0) return; //no plugins to load //load new plugins foreach (var (file, pluginAssembly) in fileAssemblies) { //exclude already loaded assemblies if (loadedPugins.Contains(pluginAssembly) || domainAssemblies.Contains(pluginAssembly)) continue; try { //check if assembly has modules var pluginModules = ModuleType.FromAssembly(pluginAssembly); if (pluginModules.Count() == 0) throw new FileLoadException("Library doesn't contain any modules."); availableBotModules.AddRange(pluginModules); loadedPugins.Add(pluginAssembly); GlobalLogger.Log("BOT", $"Plugin \"{pluginAssembly.GetName().Name}\" loaded. ({pluginModules.Count()} module(s): {string.Join(", ", pluginModules.Select(m => m.Name))})."); } catch (Exception ex) { GlobalLogger.LogError("BOT", $"Couldn't load plugin \"{Path.GetFileName(file)}\"", ex); } } } /// Returns true if a module of the given type is currently active. public bool IsModuleLoaded(ModuleType moduleType) => activeBotModules.Any(m => m.Type == moduleType); /// Loads and optionally starts the module for the given type. Returns false if the type is unknown or the module is already running. public bool LoadModule(ModuleType moduleType, bool autoStart = true) { if (!availableBotModules.Contains(moduleType)) return false; Module module = activeBotModules.FirstOrDefault(m => m.Type == moduleType); if (module == null) { try { if (!ConfigFile.Load($"{BOT_SETTINGS_FOLDER}/module_{moduleType.FullName}.json", moduleType.Type, out ConfigFile config)) GlobalLogger.Log("BOT", $"Module '{moduleType.Name}': no settings file present. Default file was written.", LogType.Warning); module = (Module)config; } catch (Exception ex) { GlobalLogger.LogError("BOT", $"Module '{moduleType.Name}': failed to load config", ex); return false; } if (!ExecuteModuleAction(module, x => x.Initialize(moduleType, this), force: true)) return false; activeBotModules.Add(module); if (module is not BotControl && !Config.ActiveModules.Contains(module.FullName)) { Config.ActiveModules.Add(module.FullName); Config.Save(); } LoadModuleCommands(module); } else if (module.Running) return false; // already loaded and running if (autoStart && server.Connected) ExecuteModuleAction(module, x => x.Start(), force: true); return true; } /// Stops and removes the module for the given type. Returns false if it is not currently active. public bool UnloadModule(ModuleType moduleType) { var module = activeBotModules.FirstOrDefault(m => m.Type == moduleType); if (module != null) return UnloadModule(module); return false; } /// Stops and removes the given module instance. The built-in module cannot be unloaded. Returns false if the module is not active. public bool UnloadModule(Module module) { if (activeBotModules.Contains(module) && module is not BotControl) { if (ExecuteModuleAction(module, x => { x.Stop(); x.Unload(); })) InternalUnloadModule(module); activeBotModules.Remove(module); if (Config.ActiveModules.Contains(module.FullName)) { Config.ActiveModules.Remove(module.FullName); Config.Save(); } return true; } return false; } private void InternalUnloadModule(Module module) { module.InternalCleanup(); UnloadModuleCommands(module); } private void ForEachModule(Action action, bool force = false) { foreach (var module in activeBotModules) ExecuteModuleAction(module, action, force); } private bool ExecuteModuleAction(Module module, Action action, bool force = false) { if (!force && !module.Running) return false; //skip event execution lock (module) { try { action(module); } catch (Exception ex) { GlobalLogger.LogError("BOT", $"Unhandled exception in module '{module.FullName}'", ex); try { module.ThrowException(ex); } catch (Exception innerEx) { GlobalLogger.LogError("BOT", $"Exception in OnException of module '{module.FullName}'", innerEx); } InternalUnloadModule(module); return false; } } return true; } #endregion #region role / permission functions /// /// Resolves the effective flags for a client. /// Hierarchy: Admin > Moderator > User > Guest — each higher role includes all lower-tier flags. /// public CommandRole GetClientRole(Client client) { if (client == server.QueryClient) return CommandRole.Admin | CommandRole.Moderator | CommandRole.User | CommandRole.Guest; if (IsInRoleList(client, Config.Admins)) return CommandRole.Admin | CommandRole.Moderator | CommandRole.User | CommandRole.Guest; if (IsInRoleList(client, Config.Moderators)) return CommandRole.Moderator | CommandRole.User | CommandRole.Guest; if (IsInRoleList(client, Config.Users)) return CommandRole.User | CommandRole.Guest; bool isGuest = client.IsInDefaultGroup(); if (Config.NonGuestsAreUsers && !isGuest) return CommandRole.User | CommandRole.Guest; if (Config.AllowGuests && isGuest) return CommandRole.Guest; return 0; } private bool IsInRoleList(Client client, List entries) { if (entries.Contains(client.UID)) return true; foreach (var part in client.ServerGroups.Split(',', StringSplitOptions.RemoveEmptyEntries)) { if (entries.Contains(part)) return true; if (int.TryParse(part, out int groupId) && server.ServerGroups.TryGetValue(groupId, out var group) && entries.Contains(group.Name)) return true; } return false; } /// Returns true if the client has the flag. public bool IsAdmin(Client client) => GetClientRole(client).HasFlag(CommandRole.Admin); /// Returns true if the client has the flag. public bool IsModerator(Client client) => GetClientRole(client).HasFlag(CommandRole.Moderator); /// Returns true if the client has the flag. public bool IsUser(Client client) => GetClientRole(client).HasFlag(CommandRole.User); /// Returns true if the client has the flag. public bool IsGuest(Client client) => GetClientRole(client).HasFlag(CommandRole.Guest); /// Returns all currently connected clients that have the role. public Client[] GetConnectedAdmins() => server.Clients.Values.Where(c => IsAdmin(c)).ToArray(); #endregion #region module events private void Server_OnConnected(ServerConnection sender) { ForEachModule(x => x.Start(), force: true); } private void Server_OnDisconnected(ServerConnection sender) //actual its connection lost -> not called when disconnect intentionally { ForEachModule(x => x.Stop()); } private void Server_OnChannelCreated(ServerConnection sender, Channel channel, Channel parent, Client invoker) { ForEachModule(m => m.OnChannelCreated(channel, parent, invoker)); } private void Server_OnChannelDeleted(ServerConnection sender, Channel channel, Client invoker) { ForEachModule(m => m.OnChannelDeleted(channel, invoker)); } private void Server_OnChannelUpdated(ServerConnection sender, Channel channel, TsEntityChangeInfo changes) { ForEachModule(m => m.OnChannelUpdated(channel, changes)); } private void Server_OnChannelEdited(ServerConnection sender, Channel channel, TsEntityChangeInfo changes, Client invoker) { ForEachModule(m => m.OnChannelEdited(channel, changes, invoker)); } private void Server_OnClientJoined(ServerConnection sender, Client client, Channel source, Channel target, EventReason reason) { ForEachModule(m => m.OnClientJoined(client, source, target, reason)); } private void Server_OnClientLeaved(ServerConnection sender, Client client, Channel source, Channel target, EventReason reason, Client invoker, string reasonMsg, TimeSpan banTime) { ForEachModule(m => m.OnClientLeaved(client, source, target, reason, invoker, reasonMsg, banTime)); } private void Server_OnClientUpdated(ServerConnection sender, Client client, TsEntityChangeInfo changes) { ForEachModule(m => m.OnClientUpdated(client, changes)); } private void Server_OnClientMoved(ServerConnection sender, Client client, Channel target, EventReason reason, Client invoker) { ForEachModule(m => m.OnClientMoved(client, target, reason, invoker)); } private void Server_OnServerEdited(ServerConnection sender, ServerInfo serverInfo, TsEntityChangeInfo changes, EventReason reason, Client invoker) { ForEachModule(m => m.OnServerEdited(serverInfo, changes, reason, invoker)); } #endregion #region command handling /// /// Parses and resolves it to the best-matching registered command. /// contains all commands whose keyword starts with the same root word. /// Returns true if a specific command was matched; false if the input does not look like a command or no matching keyword was found. /// public bool FindCommand(string commandLine, out List candidates, out Command command, out string parameterString) { var match = CommandRegex.Match(commandLine); if (!match.Success) { candidates = null; command = null; parameterString = string.Empty; return false; } string prefix = match.Groups["p"].Value; string commandName = match.Groups["cmd"].Value; string commandMessage = match.Groups["msg"].Value; // ? is a shortcut for !help [rest] if (prefix == "?") { commandMessage = (commandName + " " + commandMessage).Trim(); commandName = "help"; } var fullInput = (commandName + " " + commandMessage).TrimEnd(); // subset: all keys whose first word matches commandName candidates = activeCommands.Values .Where(c => c.Keyword == commandName || c.Keyword.StartsWith(commandName + " ")) .ToList(); // longest key that is a full prefix of fullInput command = candidates .Where(c => fullInput == c.Keyword || fullInput.StartsWith(c.Keyword + " ")) .OrderByDescending(c => c.Keyword.Length) .FirstOrDefault(); if (command == null) { parameterString = string.Empty; return false; } parameterString = fullInput[command.Keyword.Length..].TrimStart(); return true; } /// /// Executes a command by its full command line (e.g. "bot status") on behalf of . /// When is null the bot's own query client is used, granting admin access and suppressing replies. /// Pass to skip sending buffered replies and the response message, regardless of invoker. /// Returns the context with and buffered reply text, or null if the command was not found. /// public CommandContext ExecuteCommand(string commandLine, Client invoker = null, MessageTargetMode targetMode = MessageTargetMode.Private, bool suppressResponse = false) { invoker ??= server.QueryClient; if (!FindCommand("!" + commandLine, out _, out var command, out var parameterString)) return null; return ExecuteCommand(command, server.Query, invoker, targetMode, commandLine, parameterString, suppressResponse); } //check permissions -> execute command -> flush replies -> send response message internal CommandContext ExecuteCommand(Command command, ITeamspeakClient source, Client invoker, MessageTargetMode targetMode, string message, string parameterString, bool suppressResponse = false) { var args = CommandLineParser.SplitToArgs(parameterString); var ctx = new CommandContext(this, source, invoker, targetMode, message, parameterString, args); var isInternal = ctx.IsInternalInvocation; if (!isInternal && (GetClientRole(invoker) & command.Role) == 0) ctx.ErrorNoPermission(); else ExecuteCommand(command, ctx); if (!isInternal && !suppressResponse) { if (ctx.Response?.Success == true) ctx.Flush(); else if (ctx.Response?.Message != null) server.Query.SendTextMessage(MessageTargetMode.Private, invoker.ID, $"[COLOR=red]{ctx.Response.Message}[/COLOR]"); else if (ctx.Response?.Success == false) server.Query.SendTextMessage(MessageTargetMode.Private, invoker.ID, $"[COLOR=red]Unknown error.[/COLOR] Usage: [B]{command.Usage}[/B]"); } return ctx; } //raw execute: runs the callback, sets ctx.Response on module failure internal void ExecuteCommand(Command command, CommandContext context) { #if DEBUG_COMMAND Stopwatch sw = Stopwatch.StartNew(); #endif if (!ExecuteModuleAction(command.Module, m => { command.Execute(context); })) context.Error("Command execution failed."); #if DEBUG_COMMAND sw.Stop(); GlobalLogger.Log("COMMAND", $"Exec: {sw.ElapsedMilliseconds}ms, {sw.ElapsedTicks} Ticks"); #endif } private void Server_OnTextMessage(ServerConnection sender, ITeamspeakClient source, string message, Client invoker, MessageTargetMode targetMode, Client target) { if (invoker == null || invoker.Type == ClientType.Query) //don't process input from query clients return; if (!FindCommand(message, out var candidates, out var command, out var parameterString)) { if (candidates == null) //don't match command pattern at all { //no command -> pass to the modules as text ForEachModule(m => m.OnTextMessage(source, message, invoker, targetMode, target)); return; } if (IsUserOnCooldown(invoker)) return; //only respond in private chat — ignore unmatched commands in server/channel scope if (targetMode != MessageTargetMode.Private) return; if (candidates.Count == 0) { server.Query.SendTextMessage(MessageTargetMode.Private, invoker.ID, "[COLOR=red]Unknown command. Use [B]!help[/B] to see available commands.[/COLOR]"); return; } //synthesized parent: show accessible sub-commands var invokerRole = GetClientRole(invoker); var accessible = candidates .Where(c => (invokerRole & c.Role) != 0 && (c.Scope & (CommandScope)(1 << ((int)targetMode - 1))) != 0) .ToList(); if (accessible.Count == 0) { server.Query.SendTextMessage(MessageTargetMode.Private, invoker.ID, "[COLOR=red]You don't have permission to use that command![/COLOR]"); return; } var sb = new StringBuilder(); sb.AppendLine("[COLOR=red]Unknown command. Available commands:[/COLOR]"); foreach (var sub in accessible) sb.AppendLine(sub.Help); server.Query.SendTextMessage(MessageTargetMode.Private, invoker.ID, sb.ToString()); return; } //check scope if ((command.Scope & (CommandScope)(1 << ((int)targetMode - 1))) == 0) return; //command not available in this scope — silently ignore if (IsUserOnCooldown(invoker)) return; var ctx = ExecuteCommand(command, source, invoker, targetMode, message, parameterString); } private bool LoadModuleCommands(Module module) { var commands = module.GetCommands(); bool success = true; foreach (var command in commands) { if (activeCommands.ContainsKey(command.Keyword)) { GlobalLogger.Log("BOT", $"Module '{module.Name}': command '{command.Keyword}' already registered by '{activeCommands[command.Keyword].Module.FullName}', skipping.", LogType.Warning); success = false; continue; } activeCommands[command.Keyword] = command; } return success; } private void UnloadModuleCommands(Module module) { foreach (var command in module.GetCommands()) if (activeCommands.TryGetValue(command.Keyword, out var registered) && registered.Module == module) activeCommands.Remove(command.Keyword); } #endregion private bool IsUserOnCooldown(Client invoker) { if (Config.UserCommandCooldown <= 0) return false; var now = DateTime.UtcNow; if (_lastCommandTime.TryGetValue(invoker.UID, out var last) && (now - last).TotalMilliseconds < Config.UserCommandCooldown) return true; _lastCommandTime[invoker.UID] = now; return false; } } }