using axXez.Threading; using axXez.TS3.Protocol; using System; using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; using System.Threading; using System.Threading.Tasks; namespace axXez.TS3 { /// Manages the lifecycle of a TeamSpeak ServerQuery connection: authentication, event routing, channel/client cache, and watcher tasks. public class ServerConnection { readonly QueryClient query; readonly int serverID; readonly List _voiceConnections = new(); string nickname; int channelUpdateInterval; int clientUpdateInterval; string defaultChannelName; bool autoDisableQueryFloodProtection; VersionInfo versionInfo; HostInfo hostInfo; InstanceInfo instanceInfo; ServerInfo serverInfo; Client queryClient; Dictionary serverGroups; Dictionary channelGroups; Dictionary channels; Dictionary clients; ImmutableDictionary _serverGroupsSnapshot = ImmutableDictionary.Empty; ImmutableDictionary _channelGroupsSnapshot = ImmutableDictionary.Empty; ImmutableDictionary _channelsSnapshot = ImmutableDictionary.Empty; ImmutableDictionary _clientsSnapshot = ImmutableDictionary.Empty; //Dictionary> pendingChannelCreations = new(); LoopTask channelWatcherTask; LoopTask clientWatcherTask; /// The underlying used for all ServerQuery communication. public QueryClient Query => query; /// Whether the ServerQuery connection is currently active. public bool Connected => query.Connected; //todo: use own connected bool? set true after logged in /// Human-readable connection identifier in the form login@host:port. public string ConnectionString => $"{query.Transport}://{query.LoginName}@{query.Host}:{query.Port}"; /// Connect-time snapshot of server binary: version string, build number, and OS platform. public VersionInfo Binary => versionInfo; /// Connect-time snapshot of instance runtime stats: uptime, total clients/channels across all virtual servers, and bandwidth counters. public HostInfo Host => hostInfo; /// Connect-time snapshot of instance configuration: flood protection thresholds, file transfer port, template group IDs, and DB schema version. public InstanceInfo HostConfiguration => instanceInfo; /// Connect-time snapshot of virtual server: name, password, limits, group defaults, anti-flood settings, banners, log flags, and per-server bandwidth stats. public ServerInfo Configuration => serverInfo; /// The bot's own client entry on the server, representing the query session itself. public Client QueryClient => queryClient; /// The channel the bot moved to on connect, or null if no default channel was configured. public Channel QueryClientDefaultChannel { get; private set; } /// Live snapshot of all regular server groups, keyed by group ID. public IReadOnlyDictionary ServerGroups => _serverGroupsSnapshot; /// Live snapshot of all regular channel groups, keyed by group ID. public IReadOnlyDictionary ChannelGroups => _channelGroupsSnapshot; /// Live snapshot of all channels currently on the server, keyed by channel ID. public IReadOnlyDictionary Channels => _channelsSnapshot; /// Live snapshot of all voice clients currently on the server, keyed by client ID. public IReadOnlyDictionary Clients => _clientsSnapshot; /// Handler for connection-level events (connected / disconnected). public delegate void ServerConnectionHandler(ServerConnection sender); /// Handler raised when a channel is created. public delegate void ServerConnectionChannelCreateHandler(ServerConnection sender, Channel channel, Channel parent, Client invoker); /// Handler raised when a channel is deleted. public delegate void ServerConnectionChannelDeleteHandler(ServerConnection sender, Channel channel, Client invoker); /// Handler raised when a channel's properties are updated by the watcher poll. public delegate void ServerConnectionChannelUpdateHandler(ServerConnection sender, Channel channel, TsEntityChangeInfo changes); /// Handler raised when a channel's properties are edited by a client. public delegate void ServerConnectionChannelEditHandler(ServerConnection sender, Channel channel, TsEntityChangeInfo changes, Client invoker); /// Handler raised when a client connects and enters view. public delegate void ServerConnectionClientJoinHandler(ServerConnection sender, Client client, Channel source, Channel target, EventReason reason); /// Handler raised when a client disconnects or leaves view. public delegate void ServerConnectionClientLeaveHandler(ServerConnection sender, Client client, Channel source, Channel target, EventReason reason, Client invoker, string reasonMsg, TimeSpan banTime); /// Handler raised when a client's properties are updated by the watcher poll. public delegate void ServerConnectionClientUpdateHandler(ServerConnection sender, Client client, TsEntityChangeInfo changes); /// Handler raised when a client is moved to a different channel. public delegate void ServerConnectionClientMoveHandler(ServerConnection sender, Client client, Channel target, EventReason reason, Client invoker); /// Handler raised when a text message is received. public delegate void ServerConnectionTextMessageHandler(ServerConnection sender, ITeamspeakClient source, string message, Client invoker, MessageTargetMode targetMode, Client target); /// Handler raised when the virtual server's properties are edited. public delegate void ServerConnectionServerEditHandler(ServerConnection sender, ServerInfo serverInfo, TsEntityChangeInfo changes, EventReason reason, Client invoker); /// Raised after the ServerQuery connection is established and initial data is loaded. public event ServerConnectionHandler OnConnected; /// Raised when the ServerQuery connection is lost. public event ServerConnectionHandler OnDisconnected; /// Raised when a channel is created on the server. public event ServerConnectionChannelCreateHandler OnChannelCreated; /// Raised when a channel is deleted from the server. public event ServerConnectionChannelDeleteHandler OnChannelDeleted; /// Raised when a channel's properties change (watcher poll or description update). public event ServerConnectionChannelUpdateHandler OnChannelUpdated; /// Raised when a client explicitly edits a channel's properties. public event ServerConnectionChannelEditHandler OnChannelEdited; /// Raised when a client joins the server or enters view. public event ServerConnectionClientJoinHandler OnClientJoined; /// Raised when a client leaves the server or exits view. public event ServerConnectionClientLeaveHandler OnClientLeaved; /// Raised when a client's properties are updated by the watcher poll. public event ServerConnectionClientUpdateHandler OnClientUpdated; /// Raised when a client is moved to a different channel. public event ServerConnectionClientMoveHandler OnClientMoved; /// Raised when a text message is received in any registered scope. public event ServerConnectionTextMessageHandler OnTextMessage; /// Raised when the virtual server's properties are edited. public event ServerConnectionServerEditHandler OnServerEdited; /// The detected TeamSpeak server version (TS3 or TS6). Set on connect, before first connect. public ServerVersion Version { get; private set; } = ServerVersion.Unknown; /// Nickname the bot will use on the server. public string Nickname { get => nickname; set => SetNickname(value); } /// Name of the channel the bot will move to on connect. public string DefaultChannelName { get => defaultChannelName; set => MoveToChannel(value); } /// Whether to disable the server's query flood protection on connect. public bool AutoDisableQueryFloodProtection { get => autoDisableQueryFloodProtection; set => autoDisableQueryFloodProtection = value; } /// Interval in milliseconds between channel list refresh polls. Set to 0 to disable. public int ChannelUpdateInterval { get => channelUpdateInterval; set => SetChannelUpdateInterval(value); } /// Interval in milliseconds between client list refresh polls. Set to 0 to disable. public int ClientUpdateInterval { get => clientUpdateInterval; set => SetClientUpdateInterval(value); } /// Initializes a new with the given connection parameters. Call to begin connecting. public ServerConnection(string ip, int port, string loginName, string password, int serverID, QueryTransportType transport = QueryTransportType.SSH) { this.serverID = serverID; query = new QueryClient(ip, port, transport) { LoginName = loginName, LoginPassword = password }; query.OnConnected += Query_OnConnected; query.OnDisconnected += Query_OnDisconnected; query.OnError += Query_OnError; channelWatcherTask = new LoopTask(ChannelWatcher, stopCallback: ChannelWatcherTask_OnStop, autoRestart: true); clientWatcherTask = new LoopTask(ClientWatcher, stopCallback: ClientWatcherTask_OnStop, autoRestart: true); } /// Starts the underlying query client and begins the connection process. public void Start() { query.Start(); } /// Disconnects from the server, stops all watchers, stops all voice connections, and clears the channel/client cache. public void Stop() { StopChannelWatcher(); StopClientWatcher(); foreach (var conn in _voiceConnections.ToList()) RemoveVoiceConnection(conn); if (query.Connected) query.Disconnect(); lock (channels) { channels.Clear(); _channelsSnapshot = ImmutableDictionary.Empty; } lock (clients) { clients.Clear(); _clientsSnapshot = ImmutableDictionary.Empty; } } /// Forces the underlying query client to drop the current connection and reconnect. public void Reconnect() { query.ForceReconnect(); } /// Creates a new targeting this server, registers it for text-message routing, and tracks it for lifecycle management. /// If is null, a fresh identity is generated at the server's required security level. public VoiceConnection CreateVoiceConnection(string nickname, VoiceIdentity identity = null, VoiceClientOptions options = null) { identity ??= VoiceIdentity.Generate(serverInfo.NeededIdentitySecurityLevel); var conn = new VoiceConnection(query.Host, nickname: nickname, identity: identity, options: options); _voiceConnections.Add(conn); conn.OnTextMessage += Client_OnTextMessage; return conn; } /// Unregisters, stops, and disposes a previously created by . public void RemoveVoiceConnection(VoiceConnection conn) { _voiceConnections.Remove(conn); conn.OnTextMessage -= Client_OnTextMessage; conn.Dispose(); } /// Changes the bot's nickname on the server. Returns false if the rename command fails while connected. public bool SetNickname(string name) { if (query.Connected && !query.UpdateClient(new EditClientProperties() { Name = name })) return false; nickname = name; return true; } /// Moves the bot to the channel matching and saves it as the default. Returns false if the channel is not found or the move command fails. public bool MoveToChannel(string channelName) { if (query.Connected) { if (!FindChannel(channelName, out var channel)) return false; if (!query.MoveClient(queryClient.ID, channel.ID)) return false; QueryClientDefaultChannel = channel; } defaultChannelName = channelName; return true; } /// Changes the channel watcher poll interval. Pass 0 to stop the watcher entirely. public void SetChannelUpdateInterval(int intervalMs) { channelUpdateInterval = intervalMs; if (!query.Connected) return; if (intervalMs == 0) StopChannelWatcher(); else StartChannelWatcher(); } /// Changes the client watcher poll interval. Pass 0 to stop the watcher entirely. public void SetClientUpdateInterval(int intervalMs) { clientUpdateInterval = intervalMs; if (!query.Connected) return; if (intervalMs == 0) StopClientWatcher(); else StartClientWatcher(); } #region find methods /// Finds a server group by ID (numeric string) or by name (case-insensitive substring). Returns false if not found. public bool FindServerGroup(string input, out ServerGroupInfo serverGroup) { if (string.IsNullOrWhiteSpace(input)) { serverGroup = null; return false; } if (int.TryParse(input, out int id) && FindServerGroup(id, out serverGroup)) return true; serverGroup = _serverGroupsSnapshot.Values.FirstOrDefault(x => x.Name.Contains(input, StringComparison.InvariantCultureIgnoreCase)); return serverGroup != null; } /// Finds a server group by its numeric ID. Returns false if not found. public bool FindServerGroup(int id, out ServerGroupInfo serverGroup) => _serverGroupsSnapshot.TryGetValue(id, out serverGroup); /// Finds a channel group by ID (numeric string) or by name (case-insensitive substring). Returns false if not found. public bool FindChannelGroup(string input, out ChannelGroupInfo channelGroup) { if (string.IsNullOrWhiteSpace(input)) { channelGroup = null; return false; } if (int.TryParse(input, out int id) && FindChannelGroup(id, out channelGroup)) return true; channelGroup = _channelGroupsSnapshot.Values.FirstOrDefault(x => x.Name.Contains(input, StringComparison.InvariantCultureIgnoreCase)); return channelGroup != null; } /// Finds a channel group by its numeric ID. Returns false if not found. public bool FindChannelGroup(int id, out ChannelGroupInfo channelGroup) => _channelGroupsSnapshot.TryGetValue(id, out channelGroup); /// Finds a channel by ID (numeric string) or by name (case-insensitive substring). Returns false if not found. public bool FindChannel(string input, out Channel channel) { if (string.IsNullOrWhiteSpace(input)) { channel = null; return false; } if (int.TryParse(input, out int id) && FindChannel(id, out channel)) return true; channel = _channelsSnapshot.Values.FirstOrDefault(x => x.Name.Contains(input, StringComparison.InvariantCultureIgnoreCase)); return channel != null; } /// Finds a channel by its numeric ID. Returns false if not found. public bool FindChannel(int id, out Channel channel) => _channelsSnapshot.TryGetValue(id, out channel); /// Returns all direct sub-channels of the given parent channel ID. Returns false if none exist. public bool FindSubChannels(int parentId, out IEnumerable subChannels) { subChannels = _channelsSnapshot.Values.Where(c => parentId.Equals(c.ParentID)).ToImmutableList(); return subChannels.Any(); } /// Finds a client by ID (numeric string), UID, or name (case-insensitive substring). Returns false if not found. public bool FindClient(string input, out Client client) { if (string.IsNullOrWhiteSpace(input)) { client = null; return false; } if (int.TryParse(input, out int id) && FindClient(id, out client)) return true; client = _clientsSnapshot.Values.FirstOrDefault(x => x.UID == input || x.Name.Contains(input, StringComparison.InvariantCultureIgnoreCase)); return client != null; } /// Finds a client by their numeric client ID. Returns false if not found. public bool FindClient(int id, out Client client) => _clientsSnapshot.TryGetValue(id, out client); #endregion //#region channel creation //public Channel CreateChannel(string name, EditChannelProperties properties) //{ // int cid = query.CreateChannel(properties); // return WaitForChannelCreated(cid).Result; // deadlock when called from event handler //} //public Task WaitForChannelCreated(int channelID, int timeoutMs = 5000) //{ // var tcs = new TaskCompletionSource(); // pendingChannelCreations[channelID] = tcs; // _ = Task.Delay(timeoutMs).ContinueWith(_ => // { // channels.TryGetValue(channelID, out var channel); // if (pendingChannelCreations.Remove(channelID)) // tcs.TrySetResult(channel); // }); // return tcs.Task; //} //#endregion #region query connection handler private void Query_OnConnected(ITeamspeakClient sender) { if (!query.Use(serverID, clientNickname: nickname)) throw new Exception("Server instance not found."); //if (!query.UpdateClient(new EditClientProperties() { Name = string.IsNullOrWhiteSpace(Config.Name) ? BOT_DEFAULT_NAME : Config.Name })) // throw new Exception("ServerQuery client renaming failed."); //get actual server info versionInfo = query.Version() ?? throw new Exception("Failed to retrieve server version."); hostInfo = query.Hostinfo() ?? throw new Exception("Failed to retrieve host info."); instanceInfo = query.InstanceInfo() ?? throw new Exception("Failed to retrieve instance info."); serverInfo = query.ServerInfo() ?? throw new Exception("Failed to retrieve server info."); Version = versionInfo?.Version?.StartsWith("3.") == true ? ServerVersion.TS3 : versionInfo?.Version?.StartsWith("6.") == true ? ServerVersion.TS6 : ServerVersion.Unknown; queryClient = new Client(this, query.GetMyself()); // TODO: replace with tracked instance... track all clients!? GlobalLogger.Log("ServerConnection", $"Server running {versionInfo.Version} ({versionInfo.Build}) on {versionInfo.Platform}, Uptime: {hostInfo.HostTime}, DB: {instanceInfo.DatabaseVersion}"); if (autoDisableQueryFloodProtection && instanceInfo.IsQueryFloodProtectionEnabled) { query.DisableQueryFloodProtection(); instanceInfo = query.InstanceInfo(); } //else auto configurate flood protection in query client GlobalLogger.Log("ServerConnection", $"QueryFloodProtection: Local={(autoDisableQueryFloodProtection ? "enabled" : "disabled")} Server={(instanceInfo.IsQueryFloodProtectionEnabled ? "enabled" : "disabled")}"); //get actual server data serverGroups = query.ListServerGroups()?.Where(c => c.Type == GroupType.Regular).ToDictionary(g => g.ID); channelGroups = query.ListChannelGroups()?.Where(c => c.Type == GroupType.Regular).ToDictionary(g => g.ID); _serverGroupsSnapshot = serverGroups.ToImmutableDictionary(); _channelGroupsSnapshot = channelGroups.ToImmutableDictionary(); channels = query.ListChannels(true, true, true, true, true, true)?.Select(c => new Channel(this, c)).ToDictionary(c => c.ID); clients = query.ListClients()?.Where(c => c.Type == ClientType.Voice && c.UID != queryClient.UID).Select(c => new Client(this, c)).ToDictionary(c => c.ID); //todo: track every client //todo: add error handling _channelsSnapshot = channels.ToImmutableDictionary(); _clientsSnapshot = clients.ToImmutableDictionary(); GlobalLogger.Log("ServerConnection", $"ServerGroups={serverGroups.Count} ChannelGroups={channelGroups.Count} Channels={channels.Count}"); if (!string.IsNullOrWhiteSpace(defaultChannelName)) { if (FindChannel(defaultChannelName, out var defChannel)) { QueryClientDefaultChannel = defChannel; query.MoveClient(queryClient.ID, defChannel.ID); } else GlobalLogger.Log("ServerConnection", $"Default channel '{defaultChannelName}' not found.", LogType.Warning); } //register events to detect changes query.OnChannelCreated += Query_OnChannelCreated; query.OnChannelDeleted += Query_OnChannelDeleted; query.OnChannelEdited += Query_OnChannelEdited; query.OnChannelDescriptionChanged += Query_OnChannelDescriptionChanged; query.OnChannelMoved += Query_OnChannelMoved; query.OnClientEnteredView += Query_OnClientEnteredView; query.OnClientLeftView += Query_OnClientLeftView; query.OnClientMoved += Query_OnClientMoved; query.OnTextMessage += Client_OnTextMessage; query.OnServerEdited += Query_OnServerEdited; query.ServerNotifyRegister("server"); query.ServerNotifyRegister("channel", 0); // after channel/server events so channels created in OnStart are tracked via notify events // (safe because events aren't dispatched to modules until Running is set after OnStart) OnConnected?.Invoke(this); query.ServerNotifyRegister("textserver"); query.ServerNotifyRegister("textchannel"); query.ServerNotifyRegister("textprivate"); GlobalLogger.Log("ServerConnection", "ServerQuery events registered."); //start channel and client watcher to detect detailed variable updates StartChannelWatcher(); StartClientWatcher(); } private void Query_OnDisconnected(ITeamspeakClient sender) //actual its connection lost -> not called when disconnect intentionally { StopChannelWatcher(); StopClientWatcher(); query.OnChannelCreated -= Query_OnChannelCreated; query.OnChannelDeleted -= Query_OnChannelDeleted; query.OnChannelEdited -= Query_OnChannelEdited; query.OnChannelDescriptionChanged -= Query_OnChannelDescriptionChanged; query.OnChannelMoved -= Query_OnChannelMoved; query.OnClientEnteredView -= Query_OnClientEnteredView; query.OnClientLeftView -= Query_OnClientLeftView; query.OnClientMoved -= Query_OnClientMoved; query.OnTextMessage -= Client_OnTextMessage; query.OnServerEdited -= Query_OnServerEdited; GlobalLogger.Log("ServerConnection", "Connection lost.", LogType.Warning); //call disconnect event here so stop stuff can happen before cache is cleared OnDisconnected?.Invoke(this); lock (channels) { channels.Clear(); _channelsSnapshot = ImmutableDictionary.Empty; } lock (clients) { clients.Clear(); _clientsSnapshot = ImmutableDictionary.Empty; } } private void Query_OnError(QueryClient sender, QueryResponse response) { } #endregion #region channel handler private void StartChannelWatcher() { if (channelWatcherTask.Running || channelUpdateInterval <= 0) return; channelWatcherTask.Start(); GlobalLogger.Log("ServerConnection", $"ChannelWatcher started. (Interval: {channelUpdateInterval})"); } private void StopChannelWatcher() { if (!channelWatcherTask.Running) return; channelWatcherTask.Stop(); GlobalLogger.Log("ServerConnection", $"ChannelWatcher stopped."); } private async Task ChannelWatcher(CancellationToken ct) { if (!query.Connected) return; await Task.Delay(channelUpdateInterval, ct); if (ct.IsCancellationRequested) return; List channelUpdates = query.ListChannels(true, true, true, true, true, true); if (channelUpdates == null) return; lock (channels) { foreach (ChannelInfo channelUpdate in channelUpdates) { if (!channels.TryGetValue(channelUpdate.ID, out var channel)) continue; //update existing channel var changes = channel.Apply(channelUpdate); if (!changes.Any) continue; OnChannelUpdated?.Invoke(this, channel, changes); } } } private void ChannelWatcherTask_OnStop(LoopTask sender, Exception exception) { if (exception == null) return; GlobalLogger.Log("ServerConnection", $"Error in channelWatcherTask: {exception.Message}\n{exception}", LogType.Error); } private void Query_OnChannelCreated(object sender, ChannelCreatedArgs e) { Channel channel; lock (channels) { if (channels.ContainsKey(e.ChannelID)) { GlobalLogger.Log("ServerConnection", $"OnChannelCreated: channel [{e.ChannelID}] already in cache.", LogType.Warning); return; } var channelInfo = query.GetChannelInfo(e.ChannelID); //needed for description and more if (channelInfo == null) { GlobalLogger.Log("ServerConnection", $"OnChannelCreated: GetChannelInfo [{e.ChannelID}] failed, falling back to event data.", LogType.Warning); channelInfo = e.ChannelInfo; } channel = new Channel(this, channelInfo); channels.Add(channel.ID, channel); _channelsSnapshot = channels.ToImmutableDictionary(); } //if (pendingChannelCreations.Remove(e.ChannelID, out var tcs)) // tcs.TrySetResult(channel); channels.TryGetValue(e.ParentChannelID, out var parent); clients.TryGetValue(e.InvokerInfo.InvokerID, out var invoker); OnChannelCreated?.Invoke(this, channel, parent, invoker); } private void Query_OnChannelDeleted(object sender, ChannelDeletedArgs e) { Channel channel; lock (channels) { if (!channels.TryGetValue(e.ChannelID, out channel)) { GlobalLogger.Log("ServerConnection", $"OnChannelDeleted: channel [{e.ChannelID}] not found in cache.", LogType.Warning); return; } channels.Remove(e.ChannelID); _channelsSnapshot = channels.ToImmutableDictionary(); } clients.TryGetValue(e.InvokerInfo.InvokerID, out Client invoker); OnChannelDeleted?.Invoke(this, channel, invoker); } private void Query_OnChannelEdited(object sender, ChannelEditedArgs e) { if (!channels.TryGetValue(e.ChannelID, out var channel)) { GlobalLogger.Log("ServerConnection", $"OnChannelEdited: channel [{e.ChannelID}] not found in cache.", LogType.Warning); return; } //update channel object var changes = channel.Apply(e); //invoke events only if changes (on description edit -> no changes) if (!changes.Any) return; OnChannelUpdated?.Invoke(this, channel, changes); clients.TryGetValue(e.InvokerInfo.InvokerID, out var invoker); OnChannelEdited?.Invoke(this, channel, changes, invoker); } private void Query_OnChannelDescriptionChanged(QueryClient sender, int channelId) { //update description if (!channels.TryGetValue(channelId, out var channel)) { GlobalLogger.Log("ServerConnection", $"OnChannelDescriptionChanged: channel [{channelId}] not found in cache.", LogType.Warning); return; } var channelInfo = query.GetChannelInfo(channelId); if (channelInfo == null) { GlobalLogger.Log("ServerConnection", $"OnChannelDescriptionChanged: GetChannelInfo [{channelId}] failed.", LogType.Warning); return; } //update channel object var changes = channel.Apply(channelInfo); if (!changes.Any) return; OnChannelUpdated?.Invoke(this, channel, changes); //todo: need to get invoker from edit event //clients.TryGetValue(e.Invoker.InvokerID, out var invoker); //OnChannelEdited?.Invoke(this, channel, invoker, changes); } private void Query_OnChannelMoved(object sender, ChannelMovedArgs e) //note: not triggered on just a order change { if (!channels.TryGetValue(e.ChannelID, out var channel)) { GlobalLogger.Log("ServerConnection", $"OnChannelMoved: channel [{e.ChannelID}] not found in cache.", LogType.Warning); return; } //update channel object var changes = channel.Apply(e); if (!changes.Any) return; OnChannelUpdated?.Invoke(this, channel, changes); clients.TryGetValue(e.InvokerInfo.InvokerID, out var invoker); OnChannelEdited?.Invoke(this, channel, changes, invoker); } #endregion #region client handler private void StartClientWatcher() { if (clientWatcherTask.Running || clientUpdateInterval <= 0) return; clientWatcherTask.Start(); GlobalLogger.Log("ServerConnection", $"ClientWatcher started. (Interval: {clientUpdateInterval})"); } private void StopClientWatcher() { if (!clientWatcherTask.Running) return; clientWatcherTask.Stop(); GlobalLogger.Log("ServerConnection", $"ClientWatcher stopped."); } private async Task ClientWatcher(CancellationToken ct) { if (!query.Connected) return; await Task.Delay(clientUpdateInterval, ct); if (ct.IsCancellationRequested) return; if (clients.Count == 0) //no need to update lul return; List clientUpdates = query.GetClientInfo(clients.Select(c => c.Key).ToArray()); if (clientUpdates == null) return; lock (clients) { foreach (ClientInfo clientUpdate in clientUpdates) { if (!clients.TryGetValue(clientUpdate.ID, out var client)) continue; //update existing client var changes = client.Apply(clientUpdate); if (!changes.Any) continue; #if DEBUG //if (!changes.Entries.All(p => p == "IdleTime" || p == "ConnectedTime" || p == "PacketsSent" || p == "PacketsReceived" || p == "BytesSent" || p == "BytesReceived")) //{ // GlobalLogger.Log("ServerConnection", $"clientWatcher: client updated [{client.ID}] {client.Name} ({client.UID}), changes: {string.Join(", ", changes.Values.Select(c => $"{c.Property.Name}: {c.OldValue} -> {c.NewValue}"))}"); //} #endif OnClientUpdated?.Invoke(this, client, changes); } } } private void ClientWatcherTask_OnStop(LoopTask sender, Exception exception) { if (exception == null) return; GlobalLogger.Log("ServerConnection", $"Error in clientWatcherTask: {exception.Message}\n{exception}", LogType.Error); } private void Query_OnClientEnteredView(object sender, ClientEnteredViewArgs e) { if (e.ClientInfo.Type == ClientType.Query) return; //todo: maybe remove? -> track all clients? Client client; lock (clients) { if (clients.ContainsKey(e.ClientInfo.ID)) { GlobalLogger.Log("ServerConnection", $"OnClientEnteredView: client [{e.ClientInfo.ID}] {e.ClientInfo.Name} already in cache.", LogType.Warning); return; } //collect full client info before adding to clients list var clientInfo = query.GetClientInfo(e.ClientInfo.ID); if (clientInfo == null) { GlobalLogger.Log("ServerConnection", $"OnClientEnteredView: GetClientInfo [{e.ClientInfo.ID}] failed, falling back to event data.", LogType.Warning); clientInfo = e.ClientInfo; } client = new Client(this, clientInfo); clients.Add(client.ID, client); _clientsSnapshot = clients.ToImmutableDictionary(); } GlobalLogger.Log("ServerConnection", $"client joined [{e.ClientInfo.ID}] {e.ClientInfo.Name} ({e.ClientInfo.UID})"); channels.TryGetValue(e.SourceChannelID, out var source); channels.TryGetValue(e.TargetChannelID, out var target); OnClientJoined?.Invoke(this, client, source, target, e.ReasonID); } private void Query_OnClientLeftView(object sender, ClientLeftViewArgs e) { Client client; lock (clients) { if (!clients.TryGetValue(e.ClientID, out client)) { GlobalLogger.Log("ServerConnection", $"client left [{e.ClientID}] (NO CLIENTINFO), Reason: [{e.ReasonID}] {e.ReasonMessage}, Invoker: {e.InvokerInfo.InvokerName}", LogType.Warning); return; } clients.Remove(e.ClientID); _clientsSnapshot = clients.ToImmutableDictionary(); } GlobalLogger.Log("ServerConnection", $"client left [{e.ClientID}] {client.Name} ({client.UID}), Reason: [{e.ReasonID}] {e.ReasonMessage}, Invoker: {e.InvokerInfo.InvokerName}"); channels.TryGetValue(e.SourceChannelID, out var source); channels.TryGetValue(e.TargetChannelID, out var target); clients.TryGetValue(e.InvokerInfo.InvokerID, out var invoker); OnClientLeaved?.Invoke(this, client, source, target, e.ReasonID, invoker, e.ReasonMessage, e.BanTime); } private void Query_OnClientMoved(object sender, ClientMovedArgs e) { if (!clients.TryGetValue(e.ClientID, out var client)) { GlobalLogger.Log("ServerConnection", $"OnClientMoved: client [{e.ClientID}] not found in cache.", LogType.Warning); return; } //update client object var changes = client.Apply(e); if (!changes.Any) return; OnClientUpdated?.Invoke(this, client, changes); channels.TryGetValue(e.TargetChannelID, out var target); clients.TryGetValue(e.InvokerInfo.InvokerID, out var invoker); OnClientMoved?.Invoke(this, client, target, e.ReasonID, invoker); } #endregion #region query event handler private void Client_OnTextMessage(ITeamspeakClient sender, TextMessageArgs args) { Client target = null; if (args.TargetMode == MessageTargetMode.Private) clients.TryGetValue(args.Target, out target); clients.TryGetValue(args.InvokerInfo.InvokerID, out Client invoker); if (invoker == null && args.InvokerInfo.InvokerID == QueryClient.ID) invoker = QueryClient; OnTextMessage?.Invoke(this, sender, args.Message, invoker, args.TargetMode, target); } private void Query_OnServerEdited(QueryClient sender, ServerEditedArgs e) { var changes = serverInfo.Apply(e.ServerInfo); if (!changes.Any) return; clients.TryGetValue(e.InvokerInfo.InvokerID, out var invoker); OnServerEdited?.Invoke(this, serverInfo, changes, e.ReasonID, invoker); } #endregion /// public override string ToString() => $"Connection[{ConnectionString}] {(Connected ? $"connected ({versionInfo.Version} on {versionInfo.Platform})" : "disconnected")}"; /// public override int GetHashCode() => ConnectionString.GetHashCode(); /// public override bool Equals(object obj) => obj is ServerConnection && ConnectionString.Equals((obj as ServerConnection).ConnectionString); } }