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;
}
}
}