using axXez.TS3.Protocol;
using axXez.Utils;
using System;
using System.Collections.Generic;
using System.Reflection;
using System.Text.Json.Serialization;
namespace axXez.TS3.Bot
{
/// Base class for all bot modules. Provides lifecycle hooks, event callbacks, and access to the server connection.
public abstract class Module : ConfigFile
{
private List _commands;
private readonly List _voiceConnections = new();
/// Short name of the module type (class name).
[JsonIgnore]
public string Name => Type.Name;
/// Fully qualified name of the module type (namespace + class name).
[JsonIgnore]
public string FullName => Type.FullName;
/// Human-readable display name of the module, as set in the .
[JsonIgnore]
public string DisplayName => Type.DisplayName;
/// Whether the module is currently running.
[JsonIgnore]
public bool Running { get; private set; } = false;
/// Whether the module has stopped due to an unhandled exception.
[JsonIgnore]
public bool HasError => LastException != null;
/// The last unhandled exception that caused the module to stop, or null if no error has occurred.
[JsonIgnore]
public Exception LastException { get; private set; }
/// Metadata descriptor for this module's type.
[JsonIgnore]
public ModuleType Type { get; private set; }
/// The bot context this module is loaded into.
protected BotContext Bot { get; private set; }
/// The active server connection.
protected ServerConnection Server => Bot.Server;
/// The raw ServerQuery API client.
protected QueryClient Query => Server.Query;
/// Snapshot of all server groups currently tracked by the connection.
protected IReadOnlyDictionary ServerGroups => Server.ServerGroups;
/// Snapshot of all channel groups currently tracked by the connection.
protected IReadOnlyDictionary ChannelGroups => Server.ChannelGroups;
/// Snapshot of all channels currently tracked by the connection.
protected IReadOnlyDictionary Channels => Server.Channels;
/// Snapshot of all connected clients currently tracked by the connection.
protected IReadOnlyDictionary Clients => Server.Clients;
/// All voice connections currently owned by this module.
protected IReadOnlyList VoiceConnections => _voiceConnections.AsReadOnly();
/// Writes a message to the global logger using this module's name as the source.
protected void Log(string message, LogType type = LogType.Info)
=> GlobalLogger.Log(this.Name, message, type);
internal List GetCommands() => _commands;
internal void Initialize(ModuleType type, BotContext bot)
{
Type = type;
Bot = bot;
_commands = new List();
foreach (var x in type.CommandMethods)
{
try
{
var methodParams = x.Method.GetParameters();
if (methodParams.Length == 0 || methodParams[0].ParameterType != typeof(CommandContext))
throw new InvalidOperationException("First parameter must be CommandContext.");
if (x.Method.ReturnType != typeof(CommandResponse))
throw new InvalidOperationException("Return type must be CommandResponse.");
CommandLineParser parser = null;
Func callback;
if (methodParams.Length == 1)
{
// no extra params — fast delegate binding
callback = (Func)Delegate.CreateDelegate(
typeof(Func), this, x.Method);
}
else
{
// typed params — build parser and binding callback
parser = new CommandLineParser(p =>
{
for (int j = 1; j < methodParams.Length; j++)
{
var param = methodParams[j];
p.Register(
param.GetCustomAttribute()?.Name ?? param.Name,
j - 1, param.ParameterType, !param.HasDefaultValue,
param.HasDefaultValue ? param.DefaultValue : null);
}
});
callback = ctx =>
{
var result = parser.Process(ctx.Args);
if (!result.IsValid)
return ctx.Error();
var invokeArgs = new object[result.Values.Count + 1];
invokeArgs[0] = ctx;
result.Values.CopyTo(invokeArgs, 1);
return (CommandResponse)x.Method.Invoke(this, invokeArgs);
};
}
_commands.Add(new Command(x.Attr, x.Method, this, callback, parser));
}
catch (Exception ex)
{
GlobalLogger.Log(type.Name, $"Command '{x.Attr.Keyword}' on method '{x.Method.Name}' could not be registered: {ex.Message}", LogType.Warning);
}
}
OnLoad();
}
internal void Start()
{
if (Running)
return;
if (!Type.IsCompatibleWith(Server.Version))
throw new InvalidOperationException($"Module is not compatible with server version {Server.Version}.");
LastException = null;
OnStart();
Running = true; // after OnStart so events aren't dispatched before the module is fully started
}
internal void Stop()
{
if (!Running)
return;
Running = false;
OnStop(Query.Connected);
}
internal void ThrowException(Exception exception)
{
Running = false;
LastException = exception;
OnException(exception);
}
internal void Unload()
{
if (HasError)
return;
OnUnload();
}
internal void InternalCleanup()
{
foreach (var conn in _voiceConnections)
Bot.Server.RemoveVoiceConnection(conn);
_voiceConnections.Clear();
}
/// Overrides the minimum role required to invoke a command registered by this module.
protected void SetCommandRole(string keyword, CommandRole role)
{
var cmd = _commands?.Find(c => c.Keyword.Equals(keyword, StringComparison.OrdinalIgnoreCase));
if (cmd != null) cmd.Role = role;
}
/// Overrides the scope in which a command registered by this module responds.
protected void SetCommandScope(string keyword, CommandScope scope)
{
var cmd = _commands?.Find(c => c.Keyword.Equals(keyword, StringComparison.OrdinalIgnoreCase));
if (cmd != null) cmd.Scope = scope;
}
/// Creates a new targeting the active server and registers it under this module.
protected VoiceConnection CreateVoiceConnection(string nickname, VoiceIdentity identity = null, VoiceClientOptions options = null)
{
var conn = Bot.Server.CreateVoiceConnection(nickname, identity, options);
_voiceConnections.Add(conn);
return conn;
}
/// Disposes and deregisters a previously created by this module.
protected void RemoveVoiceConnection(VoiceConnection conn)
{
_voiceConnections.Remove(conn);
Bot.Server.RemoveVoiceConnection(conn);
}
/// Called once when the module is loaded, either on bot start or while the bot is already running. Override to perform one-time initialization such as dependency checks.
protected virtual void OnLoad() { }
/// Called when the server connection is established. Raised on initial load if already connected, and on every subsequent reconnect. Override to set up connection-dependent state.
protected virtual void OnStart() { }
/// Called when the server connection is lost. Override to react to disconnects — do not perform full teardown here, as the connection may reconnect and will be called again.
protected virtual void OnStop(bool queryConnected) { }
/// Called when the module is unloaded, either on bot stop or while the bot is still running. Override to release resources and perform full teardown. Base implementation saves the module config — call base.OnUnload() to preserve this behavior.
protected virtual void OnUnload()
{
Save();
}
/// Called when the module throws an unhandled exception inside an event handler. The module is stopped before this is called. Override to react to crashes (e.g. notify admins or reset state).
/// The exception that was thrown.
protected virtual void OnException(Exception exception) { }
/// Called when a new channel is created on the server.
/// The newly created channel.
/// The parent channel, or null if it has no parent.
/// The client who created the channel, or null if unknown.
public virtual void OnChannelCreated(Channel channel, Channel parent, Client invoker) { }
/// Called when a channel is deleted from the server.
/// The channel that was deleted.
/// The client who deleted the channel, or null if unknown.
public virtual void OnChannelDeleted(Channel channel, Client invoker) { }
/// Called when a channel's properties are edited by a client.
/// The channel that was edited, already updated with the new values.
/// The set of properties that changed.
/// The client who edited the channel, or null if unknown.
public virtual void OnChannelEdited(Channel channel, TsEntityChangeInfo changes, Client invoker) { }
/// Called when a channel's properties are updated by the periodic channel watcher or a description change.
/// The channel that was updated, already updated with the new values.
/// The set of properties that changed.
public virtual void OnChannelUpdated(Channel channel, TsEntityChangeInfo changes) { }
/// Called when a client connects to the server and enters view.
/// The client who joined.
/// The channel the client came from, or null if connecting fresh.
/// The channel the client joined.
/// The reason ID for the join.
public virtual void OnClientJoined(Client client, Channel source, Channel target, EventReason reason) { }
/// Called when a client disconnects or leaves view.
/// The client who left.
/// The channel the client was in.
/// The target channel (relevant for kicks/bans).
/// The reason ID (e.g. 3 = timeout, 5 = kicked, 6 = banned).
/// The client who performed the kick/ban, or null if not applicable.
/// The reason message provided by the invoker.
/// The ban duration; if not a ban.
public virtual void OnClientLeaved(Client client, Channel source, Channel target, EventReason reason, Client invoker, string reasonMsg, TimeSpan banTime) { }
/// Called when a client is moved to a different channel.
/// The client who was moved, already updated with the new channel.
/// The channel the client was moved into.
/// The reason ID for the move.
/// The client who initiated the move, or null if the client moved themselves.
public virtual void OnClientMoved(Client client, Channel target, EventReason reason, Client invoker) { }
/// Called when a client's properties are updated by the periodic client watcher.
/// The client that was updated, already updated with the new values.
/// The set of properties that changed.
public virtual void OnClientUpdated(Client client, TsEntityChangeInfo changes) { }
/// Called when a text message is received in any registered scope (server, channel, or private).
/// The connection the message arrived on — cast to for voice channel messages or for server-query messages.
/// The message text.
/// The client who sent the message.
/// Scope of the message: 1 = private, 2 = channel, 3 = server.
/// The recipient client when is 1; otherwise null.
public virtual void OnTextMessage(ITeamspeakClient source, string message, Client invoker, MessageTargetMode targetMode, Client target) { }
/// Called when a registered command keyword is received in a text message.
/// The command keyword without the leading ! or ?.
/// The full text message args that triggered the command.
public virtual void OnCommandReceived(string keyword, TextMessageArgs command) { }
/// Called when the virtual server's properties are edited.
/// The server info object, already updated with the new values.
/// The set of properties that changed.
/// The reason ID for the edit.
/// The client who edited the server, or null if unknown.
public virtual void OnServerEdited(ServerInfo serverInfo, TsEntityChangeInfo changes, EventReason reason, Client invoker) { }
///
public override string ToString() => Name;
///
public override int GetHashCode() => Type.GetHashCode();
///
public override bool Equals(object obj)
=> typeof(Module).IsAssignableFrom(obj.GetType()) && (obj as Module).Type.Equals(Type);
}
}