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