using axXez.Threading; using Renci.SshNet; using Renci.SshNet.Common; using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; using System.Net.Sockets; using System.Threading; using System.Threading.Tasks; namespace axXez.TS3.Protocol { /// Low-level TeamSpeak ServerQuery client: manages the TCP/SSH connection, fixed-rate command send loop, event dispatch, and keep-alive. public partial class QueryClient : LoopTaskHandler, ITeamspeakClient { private const int SendQueueWarnThreshold = 10; private TcpClient client; private SshClient _sshClient; private StreamWriter sendStream; private StreamReader inStream; private readonly List readBuffer = new(); private string readLine; private readonly LoopTask sendProcessor; private readonly ConcurrentQueue _sendQueue = new(); private volatile SendRequest _pendingRequest; private DateTime _pendingRequestSentTime; private DateTime _lastReceiveTime; private readonly Stopwatch _sendSlotTimer = new(); private int minSendCommandTime => 1000 / Math.Max(1, CommandsPerSecond); private sealed class SendRequest { public readonly string Command; public readonly TaskCompletionSource Response = new(TaskCreationOptions.RunContinuationsAsynchronously); public SendRequest(string command) => Command = command; } private readonly LoopTask eventProcessor; private readonly ConcurrentQueue notificationQueue = new(); private readonly string _host; private readonly int _port; private readonly QueryTransportType _transport; private string _loginName; private string _loginPassword; private bool _wasConnected = false; /// Maximum outgoing commands per second. Controls the fixed-rate send loop slot timing (slot = 1000 / CommandsPerSecond ms). public int CommandsPerSecond { get; set; } = 3; /// Interval in seconds of idle time before a query-level keep-alive is sent. Set to 0 to disable. public int KeepAliveInterval { get; set; } = 300; /// Milliseconds to wait for a server response before declaring the connection dead and forcing a reconnect. public int ResponseTimeout { get; set; } = 30000; /// Delay in milliseconds between automatic reconnect attempts. public int ReconnectDelay { get; set; } = 10000; /// Transport protocol used for the ServerQuery connection. public QueryTransportType Transport => _transport; /// Login name sent during authentication. public string LoginName { get => _loginName; set => _loginName = value; } /// Password sent during authentication. public string LoginPassword { set => _loginPassword = value; } /// Hostname or IP address of the TeamSpeak server. public string Host => _host; /// ServerQuery port of the TeamSpeak server. public int Port => _port; /// Whether the connection is currently active. public bool Connected => Transport == QueryTransportType.SSH ? (_sshClient?.IsConnected ?? false) : (client?.Connected ?? false); /// public bool IsConnected => Connected; /// Handler raised when the server returns a non-zero error code. public delegate void QueryErrorHandler(QueryClient sender, QueryResponse response); /// Handler raised when a typed notification event is received. public delegate void QueryEventHandler(QueryClient sender, T args); /// Handler raised when a channel description changes. public delegate void QueryChannelDescriptionHandler(QueryClient sender, int channelId); /// Raised when a client enters the bot's view. public event QueryEventHandler OnClientEnteredView; /// Raised when a client leaves the bot's view. public event QueryEventHandler OnClientLeftView; /// Raised when the virtual server's properties are edited. public event QueryEventHandler OnServerEdited; /// Raised when a channel is moved to a new parent or position. public event QueryEventHandler OnChannelMoved; /// Raised when a channel's properties are edited. public event QueryEventHandler OnChannelEdited; /// Raised when a channel's description changes. public event QueryChannelDescriptionHandler OnChannelDescriptionChanged; /// Raised when a channel is created. public event QueryEventHandler OnChannelCreated; /// Raised when a channel is deleted. public event QueryEventHandler OnChannelDeleted; /// Raised when a client is moved to a different channel. public event QueryEventHandler OnClientMoved; /// Raised when a text message is received. public event TeamspeakClientHandler OnTextMessage; /// Raised when a privilege key (token) is used. public event QueryEventHandler OnTokenUsed; /// Raised after the connection is established and the event loop starts. public event TeamspeakClientHandler OnConnected; /// Raised when the connection is lost. public event TeamspeakClientHandler OnDisconnected; /// Raised when the server returns a non-zero error response to a command. public event QueryErrorHandler OnError; /// Initializes a new targeting the given host and port. Call to begin connecting. public QueryClient(string host, int port, QueryTransportType transport = QueryTransportType.SSH) { _host = host; _port = port; _transport = transport; AutoRestart = true; sendProcessor = new(sendLoop, sendLoopStart, sendLoopStop, autoRestart: false); eventProcessor = new(eventLoop, eventLoopStart, eventLoopStop, autoRestart: true); } /// /// Sends quit to the server and stops the read loop. Does not trigger an automatic reconnect. /// public void Disconnect() { //AutoRestart = false; //not nessesary because autorestart is only on error if (Connected) SendCommand("quit"); Stop(wait: false); } /// /// Closes the underlying TCP socket, causing the read loop to throw and trigger an automatic reconnect. /// public void ForceReconnect() { try { //not the cleanest — closing the socket/session causes an IOException in the read loop, //which makes loopStop fire with a non-null exception, triggering OnDisconnected and AutoRestart. client?.Close(); _sshClient?.Disconnect(); inStream?.Close(); sendStream?.Close(); } catch (Exception ex) { #if DEBUG GlobalLogger.LogError("QueryClient_DEBUG", "Connection closing", ex); #endif } } #region connection/read handler /// Called by the loop task before the read loop starts. Establishes the TCP/SSH connection and authenticates. protected override async Task loopStart(CancellationToken ct, bool isAutoRestart) { if (isAutoRestart) { GlobalLogger.Log("QueryClient", $"Reconnecting in {ReconnectDelay / 1000}s..."); await Task.Delay(ReconnectDelay, ct); } GlobalLogger.Log("QueryClient", $"Connecting to {_host}:{_port}..."); readBuffer.Clear(); readLine = null; if (Transport == QueryTransportType.SSH) { _sshClient = new SshClient(_host, _port, _loginName, _loginPassword); if (KeepAliveInterval > 0) //activate tcp level keepalive as backup _sshClient.KeepAliveInterval = TimeSpan.FromSeconds(KeepAliveInterval + 30); _sshClient.ErrorOccurred += _sshClient_ErrorOccurred; await _sshClient.ConnectAsync(ct); var shellStream = _sshClient.CreateShellStreamNoTerminal(); sendStream = new StreamWriter(shellStream); inStream = new StreamReader(shellStream); } else { client = new TcpClient() { NoDelay = true, ReceiveBufferSize = 131072 }; if (KeepAliveInterval > 0) //activate tcp level keepalive as backup { client.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true); client.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.TcpKeepAliveTime, KeepAliveInterval + 30); client.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.TcpKeepAliveRetryCount, 1); } client.ReceiveTimeout = 0; client.SendTimeout = 0; await client.ConnectAsync(_host, _port, ct); var ioStream = client.GetStream(); sendStream = new StreamWriter(ioStream); //UTF8 not working... but why!?!? inStream = new StreamReader(ioStream); //Unicode not working... but why!?!? } sendStream.NewLine = "\n"; //necessary for the first command to succeed with unicode encoding... but why!?!? sendStream.WriteLine(string.Empty); sendProcessor.Start(); eventProcessor.Start(); //_wasConnected = true; //gets set after successfull on connect event GlobalLogger.Log("QueryClient", "Connected."); } private void _sshClient_ErrorOccurred(object sender, ExceptionEventArgs e) { try { _sshClient?.Disconnect(); inStream?.Close(); sendStream?.Close(); } catch { } #if DEBUG GlobalLogger.LogError("QueryClient_DEBUG", "SSH Error", e.Exception); #endif } /// Called repeatedly by the loop task. Reads one line from the server and routes it to the response or notification queue. protected override async Task loop(CancellationToken ct) { readLine = await inStream.ReadLineAsync(ct); if (readLine == null) throw new EndOfStreamException("Connection closed."); if (string.IsNullOrWhiteSpace(readLine)) return; //if (Transport == QueryTransportType.SSH) //{ // if (readLine.Contains('\x1B')) // return; // // strip prompt prefix (e.g. "serveradmin@9987(1):online> ") // int promptEnd = readLine.IndexOf("> "); // if (promptEnd >= 0) // readLine = readLine[(promptEnd + 2)..]; //} #if DEBUG_QUERY_ALL GlobalLogger.Log("QueryClient_DEBUG", $"Read: '{readLine}'", LogType.Verbose); #endif if (readLine.StartsWith("notify")) //process notification { #if DEBUG_QUERY_NOTIFY && !DEBUG_QUERY_ALL GlobalLogger.Log("QueryClient_DEBUG", $"Read: '{readLine}'", LogType.Verbose); #endif notificationQueue.Enqueue(readLine); return; } readBuffer.Add(readLine); if (readLine.StartsWith("error")) //process response { var response = QueryResponse.Parse(_pendingRequest?.Command?.Split(' ')[0], readBuffer); readBuffer.Clear(); _lastReceiveTime = DateTime.UtcNow; _pendingRequest?.Response.TrySetResult(response); _pendingRequest = null; } } /// Called by the loop task after the read loop stops. Closes the connection and fires if the stop was unexpected. protected override void loopStop(Exception exception) { eventProcessor.Stop(); sendProcessor.Stop(); try { client?.Close(); _sshClient?.Disconnect(); inStream?.Close(); sendStream?.Close(); } catch (Exception ex) { #if DEBUG GlobalLogger.LogError("QueryClient_DEBUG", "Connection closing", ex); #endif } if (exception != null) { GlobalLogger.LogError("QueryClient", "Connection lost", exception); if (_wasConnected) { _wasConnected = false; OnDisconnected?.Invoke(this); } } else { GlobalLogger.Log("QueryClient", "Disconnected."); } } #endregion #region send handler private Task sendLoopStart(CancellationToken ct, bool isAutoRestart) { _lastReceiveTime = DateTime.UtcNow; _pendingRequest = null; return Task.CompletedTask; } private async Task sendLoop(CancellationToken ct) { if (ct.IsCancellationRequested) return; if (_pendingRequest != null) { if ((DateTime.UtcNow - _pendingRequestSentTime).TotalMilliseconds >= ResponseTimeout) { _pendingRequest.Response.TrySetCanceled(); _pendingRequest = null; if (!ct.IsCancellationRequested) { GlobalLogger.Log("QueryClient", "Response timeout — forcing reconnect.", LogType.Warning); ForceReconnect(); } } await Task.Delay(1, ct); return; } if (KeepAliveInterval > 0 && _sendQueue.IsEmpty && (DateTime.UtcNow - _lastReceiveTime).TotalSeconds >= KeepAliveInterval) _sendQueue.Enqueue(new SendRequest("keepalive")); if (!_sendQueue.TryDequeue(out var req)) { await Task.Delay(1, ct); return; } _sendSlotTimer.Restart(); _pendingRequest = req; _pendingRequestSentTime = DateTime.UtcNow; try { sendStream.WriteLine(req.Command); sendStream.Flush(); } catch (Exception ex) { req.Response.TrySetException(ex); _pendingRequest = null; if (!ct.IsCancellationRequested) ForceReconnect(); return; } _sendSlotTimer.Stop(); int remaining = minSendCommandTime - (int)_sendSlotTimer.ElapsedMilliseconds; if (remaining > 0) await Task.Delay(remaining, ct); #if DEBUG_QUERY_SEND GlobalLogger.Log("QueryClient", $"Send command in {_sendSlotTimer.ElapsedMilliseconds}ms '{req.Command}'", LogType.Verbose); #endif } private void sendLoopStop(LoopTask sender, Exception exception) { if (exception != null) GlobalLogger.LogError("QueryClient", "Send loop error", exception); _pendingRequest?.Response.TrySetCanceled(); _pendingRequest = null; // drain any stale requests left from a previous connection while (_sendQueue.TryDequeue(out var queued)) queued.Response.TrySetCanceled(); } #endregion #region event handler private Task eventLoopStart(CancellationToken ct, bool isAutoRestart) { if (!isAutoRestart) { if (Transport == QueryTransportType.TCP && !string.IsNullOrEmpty(_loginName) && !string.IsNullOrEmpty(_loginPassword)) { if (!Login(_loginName, _loginPassword)) throw new Exception("ServerQuery login failed."); } OnConnected?.Invoke(this); _wasConnected = true; } return Task.CompletedTask; } private async Task eventLoop(CancellationToken ct) { if (!notificationQueue.TryDequeue(out string line)) { await Task.Delay(1, ct); return; } int spaceIdx = line.IndexOf(' '); var notifyType = line[..spaceIdx]; line = line[(spaceIdx + 1)..]; var data = new QueryResponse(notifyType, line.Trim(), "0", "ok"); switch (notifyType) { case "notifycliententerview": OnClientEnteredView?.Invoke(this, data.Get()); break; case "notifyclientleftview": OnClientLeftView?.Invoke(this, data.Get()); break; case "notifyserveredited": OnServerEdited?.Invoke(this, data.Get()); break; case "notifychannelmoved": OnChannelMoved?.Invoke(this, data.Get()); break; case "notifychanneledited": OnChannelEdited?.Invoke(this, data.Get()); break; case "notifychanneldescriptionchanged": OnChannelDescriptionChanged?.Invoke(this, data.Get("cid")); break; case "notifychannelcreated": OnChannelCreated?.Invoke(this, data.Get()); break; case "notifychanneldeleted": OnChannelDeleted?.Invoke(this, data.Get()); break; case "notifytextmessage": OnTextMessage?.Invoke(this, data.Get()); break; case "notifytokenused": OnTokenUsed?.Invoke(this, data.Get()); break; case "notifyclientmoved": OnClientMoved?.Invoke(this, data.Get()); break; default: break; } } private void eventLoopStop(LoopTask sender, Exception exception) { if (exception != null) { GlobalLogger.LogError("QueryClient", "Event loop error", exception); if (!_wasConnected) ForceReconnect(); // trigger main loop to reconnect if error was while first start } else notificationQueue.Clear(); } #endregion #region command handling /// /// Sends a raw ServerQuery command and returns whether it succeeded. /// /// The raw command string, e.g. "clientkick clid=5 reasonid=5". /// true if the server responded with error id=0, otherwise false. public bool SendCommand(string cmd) => SendCommand(cmd, out _); public bool SendCommand(string cmd, Dictionary parameters) => SendCommand(TsEncoding.Build(cmd, parameters), out _); public bool SendCommand(string cmd, Dictionary parameters, out QueryResponse response) => SendCommand(TsEncoding.Build(cmd, parameters), out response); public bool SendCommand(string cmd, Dictionary parameters) => SendCommand(TsEncoding.Build(cmd, parameters), out _); public bool SendCommand(string cmd, Dictionary parameters, out QueryResponse response) => SendCommand(TsEncoding.Build(cmd, parameters), out response); /// /// Enqueues a raw ServerQuery command and blocks until the send loop delivers the response. /// Fires on server-side errors. /// /// The raw command string, e.g. "clientkick clid=5 reasonid=5". /// The parsed , or null on failure. /// true if the server responded with error id=0, otherwise false. public bool SendCommand(string cmd, out QueryResponse response) { if (!Connected) { response = null; return false; } var req = new SendRequest(cmd); _sendQueue.Enqueue(req); int depth = _sendQueue.Count; if (depth >= SendQueueWarnThreshold) GlobalLogger.Log("QueryClient", $"Send queue growing: {depth} pending commands.", LogType.Warning); try { // GetAwaiter().GetResult() unwraps exceptions — Task.Result wraps them in AggregateException response = req.Response.Task.GetAwaiter().GetResult(); } catch (OperationCanceledException) { response = null; return false; } catch (Exception ex) { GlobalLogger.LogError("QueryClient", "Command failed", ex); response = null; return false; } if (response == null) return false; if (response.HasError) { response.SetStackTrace(new()); GlobalLogger.Log("QueryClient", GlobalLogger.Verbose ? $"Command error {response.ErrorCode}: {response.ErrorMsg}\n{response.StackTrace}" : $"Query Error {response.ErrorCode}: {response.ErrorMsg}", LogType.Error); OnError?.Invoke(this, response); return false; } return true; } #endregion #region Abstract API Calls // Note: ChannelInfo and ClientInfo query functions are internal only. // They create new object instances that are separate from the ones tracked in // BotContext.Channels and BotContext.Clients — having two instances of the // same channel or client in the program would cause state inconsistencies. /// /// Updates the server's ServerQuery flood-protection thresholds via instanceedit. /// Only parameters that differ from their defaults are sent. /// /// Max commands allowed before the flood counter triggers. Default: 10. /// Seconds between flood-counter resets. Set to 0 to effectively disable. Default: 3. /// Duration in seconds of the auto-ban issued on flood. Default: 600. /// true on success. public bool ChangeQueryFloodProtection(int commandThreshold = 10, int resetInterval = 3, int banTime = 600) { Dictionary instanceProperties = new(); if (commandThreshold != 10) instanceProperties["serverinstance_serverquery_flood_commands"] = $"{commandThreshold}"; if (resetInterval != 3) instanceProperties["serverinstance_serverquery_flood_time"] = $"{resetInterval}"; if (banTime != 600) instanceProperties["serverinstance_serverquery_ban_time"] = $"{banTime}"; return InstanceEdit(instanceProperties); } /// /// Disables the server's ServerQuery flood protection by setting the reset interval to 0. /// /// true on success. public bool DisableQueryFloodProtection() => ChangeQueryFloodProtection(resetInterval: 0); /// Sends two commands: clientlist + clientinfo to create the result. Prefer which caches this data. internal List ListClients(bool uid = false, bool away = false, bool times = false, bool groups = false, bool info = false, bool icon = false, bool country = false) { var res = ClientList(uid, away, times, groups, info, icon, country); if (res == null) return null; return GetClientInfo(res.Select(c => c.ID)); } /// /// Searches the client database by name or UID and returns full database records for each match. /// /// Name pattern or UID to search for. /// When true, is matched against UIDs instead of names. /// A list of records, or null on error. public List FindDBClient(string pattern, bool uid) { var clientIDs = ClientDBFind(pattern, uid); if (clientIDs == null) return null; return clientIDs.Select(x => GetClientDBInfo(x)).ToList(); } /// /// Returns a fully populated for the bot's own query client. /// /// /// Calls whoami to get the bot's client ID, then clientinfo to fetch full details, /// and merges the whoami fields (e.g. client_login_name) into the result. /// /// The bot's , or null on error. public ClientInfo GetMyself() { var res = WhoAmI(); if (res == null) return null; var client = GetClientInfo(res.ClientID); if (client == null) return null; client.Apply(res); return client; } #endregion } }