Stop tripping the server's flood protection with query commands

channelinfo, clientinfo, channellist and clientlist are query commands
that count heavily against anti-flood; clicking through the tree got
every command refused within seconds. Use what the official client does:

- Channel descriptions via channelgetdescription, whose answer arrives as
  an invoker-less channel edit.
- Client version and platform via clientgetvariables, answered as a
  client update.
- Connection info takes version and platform from the model and idle
  time from notifyconnectioninfo instead of polling clientinfo.
- Drop the channellist/clientlist re-read after connecting: the events
  during connect already carry everything, and more (groups, unique ids,
  away state).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-24 18:32:05 +00:00
parent b01ed43d60
commit ee0f15fb3c
3 changed files with 56 additions and 98 deletions

View File

@@ -322,6 +322,12 @@ final class ConnectionEventHandler implements TS3Listener {
c.description = e.get("client_description");
conn.ui.onInfoUpdated();
}
// The answer to clientgetvariables.
if (has(e, "client_version") || has(e, "client_platform")) {
if (has(e, "client_version")) c.version = e.get("client_version");
if (has(e, "client_platform")) c.platform = e.get("client_platform");
conn.ui.onInfoUpdated();
}
if (has(e, "client_is_channel_commander"))
c.channelCommander = e.getBoolean("client_is_channel_commander");
if (has(e, "client_flag_avatar")) c.avatarFlag = TeamspeakConnection.orEmpty(e.get("client_flag_avatar"));
@@ -391,6 +397,14 @@ final class ConnectionEventHandler implements TS3Listener {
public void onChannelEdit(ChannelEditedEvent e) {
ChannelNode ch = conn.getModel().getChannel(safeInt(e, "cid"));
if (ch == null) return;
// The answer to channelgetdescription; an empty description comes as a bare key.
if (e.getMap().containsKey("channel_description")) {
ch.description = TeamspeakConnection.orEmpty(e.get("channel_description"));
ch.descriptionLoaded = true;
conn.ui.onInfoUpdated();
}
// That answer has no invoker: nothing was edited.
if (!e.getMap().containsKey("invokerid")) return;
applyChannelProperties(ch, e);
// Sorting among the same siblings arrives here too, and TS3 announces it as an edit.
if (has(e, "channel_order")) conn.getModel().relinkChannel(ch.id, ch.parentId, e.getInt("channel_order"));

View File

@@ -1,6 +1,5 @@
package com.ts3client.net;
import com.github.manevolent.ts3j.api.Client;
import com.github.manevolent.ts3j.command.SingleCommand;
import com.github.manevolent.ts3j.command.parameter.CommandSingleParameter;
import com.github.manevolent.ts3j.event.UnknownTeamspeakEvent;
@@ -124,33 +123,24 @@ final class ConnectionStatsCollector {
}
/**
* Requests a remote client's connection info. First loads {@code clientinfo}
* for the stable fields, then issues {@code getconnectioninfo} whose
* {@code notifyconnectioninfo} report arrives asynchronously via
* {@link #onReport}. If the report does not arrive shortly (e.g. the
* server withholds it), the clientinfo-only snapshot is delivered instead.
* Requests a remote client's connection info: {@code getconnectioninfo}, whose
* {@code notifyconnectioninfo} report arrives asynchronously via {@link #onReport}.
* Version and platform come from the model; {@code clientinfo} would have them too,
* but it counts heavily against the server's flood protection, and this is polled.
* If the report does not arrive shortly (e.g. the server withholds it), the
* model-only snapshot is delivered instead.
*/
private void requestRemote(int clientId, Consumer<ConnectionStats> callback) {
ConnectionStats s = new ConnectionStats();
s.clientId = clientId;
ClientEntry entry = conn.getModel().getClient(clientId);
if (entry != null) s.nickname = entry.nickname;
LocalTeamspeakClientSocket client = conn.client;
try {
Client c = client.getClientInfo(clientId);
if (c != null) {
s.version = TeamspeakConnection.orEmpty(c.getVersion());
s.platform = TeamspeakConnection.orEmpty(c.getPlatform());
s.ip = TeamspeakConnection.orEmpty(c.getIp());
s.idleTimeMs = c.getIdleTime();
if (entry == null) s.nickname = TeamspeakConnection.orEmpty(c.getNickname());
applyConnectionFields(s, c.getMap());
}
} catch (Exception ignored) {
// clientinfo may be permission-restricted; continue with what we have
if (entry != null) {
s.nickname = entry.nickname;
s.version = entry.version;
s.platform = entry.platform;
}
LocalTeamspeakClientSocket client = conn.client;
PendingConnInfo p = new PendingConnInfo(callback, s);
pending.put(clientId, p);
@@ -161,7 +151,7 @@ final class ConnectionStatsCollector {
client.executeCommand(cmd).complete();
sent = true;
} catch (Exception ignored) {
// command failed; fall back to the clientinfo snapshot below
// command failed; fall back to the model snapshot below
}
if (sent) {
@@ -189,6 +179,8 @@ final class ConnectionStatsCollector {
long connected = parseLong(m.get("connection_connected_time"));
if (connected >= 0) s.connectedTimeMs = connected;
long idle = parseLong(m.get("connection_idle_time"));
if (idle >= 0) s.idleTimeMs = idle;
String ip = m.get("connection_client_ip");
if (ip != null && !ip.isEmpty()) s.ip = ip;

View File

@@ -1,6 +1,5 @@
package com.ts3client.net;
import com.github.manevolent.ts3j.api.Channel;
import com.github.manevolent.ts3j.api.Client;
import com.github.manevolent.ts3j.command.MultiCommand;
import com.github.manevolent.ts3j.command.SingleCommand;
@@ -449,10 +448,11 @@ public final class TeamspeakConnection implements TS3Listener {
}
/**
* Populates the model from the server. Each step is best-effort: a restricted
* guest group may deny {@code channelsubscribeall} or the list commands without
* that being a fatal connection error, and channel data already arrives via
* events during {@code connect()}.
* Completes the model once connected. Channels and clients already arrived as events
* during {@code connect()}, complete, as the official client gets them. The
* {@code channellist}/{@code clientlist} query commands must not be used to re-read
* them: their rows lack groups, unique ids and away state, and they count heavily
* against the server's flood protection.
*/
private void syncAll() {
try {
@@ -464,45 +464,6 @@ public final class TeamspeakConnection implements TS3Listener {
model.setServerName(client.getServerName());
model.setDefaultChannelGroupId(
(int) safeLong(client.getServerProperties().get("virtualserver_default_channel_group")));
try {
for (Channel ch : client.listChannels()) {
model.putChannel(ch.getId(), ch.getName(), ch.getParentChannelId(), ch.getOrder());
ChannelNode node = model.getChannel(ch.getId());
if (node != null) {
node.hasPassword = ch.hasPassword();
node.permanent = ch.isPermanent();
node.topic = ch.getTopic() == null ? "" : ch.getTopic();
node.maxClients = ch.getMaxClients();
// The channellist command answers with a narrower field set than the
// events do, so a missing (or blank) icon here means "not reported",
// not "no icon" — never let it clear what the events delivered.
long icon = safeLong(ch.get("channel_icon_id"));
if (icon != 0) node.iconId = icon;
}
}
} catch (Exception e) {
ui.onStatus("Channel list unavailable: " + rootMessage(e));
}
try {
for (Client cl : client.listClients()) {
ClientEntry e = model.putClient(cl.getId(), cl.getNickname(), cl.getChannelId());
e.type = cl.getType();
e.talkPower = cl.getTalkPower();
e.inputMuted = cl.isInputMuted();
e.outputMuted = cl.isOutputMuted();
e.inputHardware = cl.isInputHardware();
e.outputHardware = cl.isOutputHardware();
e.away = cl.isAway();
e.awayMessage = orEmpty(cl.get("client_away_message"));
e.uniqueId = cl.getUniqueIdentifier();
e.avatarFlag = orEmpty(cl.get("client_flag_avatar"));
e.serverGroupIds = cl.getServerGroups();
e.channelGroupId = cl.getChannelGroupId();
e.self = (cl.getId() == selfClientId);
}
} catch (Exception e) {
ui.onStatus("Client list unavailable: " + rootMessage(e));
}
}
public void disconnect() {
@@ -919,46 +880,37 @@ public final class TeamspeakConnection implements TS3Listener {
}, "ts3j-rename").start();
}
/** Fetches a channel's description (and topic) on demand, then notifies the UI. */
/*
* Info lookups use the commands the official client does. channelinfo/clientinfo would
* answer directly, but they are query commands that count heavily against the server's
* flood protection: clicking through the tree gets every command refused within seconds.
*/
/**
* Asks for a channel's description. The answer arrives as a channel edit, see
* {@link ConnectionEventHandler#onChannelEdit}.
*/
public void requestChannelInfo(int channelId) {
new Thread(() -> {
try {
Channel ch = client.getChannelInfo(channelId);
ChannelNode node = model.getChannel(channelId);
if (ch != null && node != null) {
node.description = orEmpty(ch.get("channel_description"));
node.topic = orEmpty(ch.getTopic());
node.descriptionLoaded = true;
}
} catch (Exception ignored) {
// description may be permission-restricted; leave as-is
} finally {
ui.onInfoUpdated();
}
}, "ts3j-channelinfo").start();
requestInfo("channelgetdescription", "cid", channelId, "ts3j-channelinfo");
}
/** Fetches a client's extended info (groups, platform, version) on demand. */
/**
* Asks for a client's version and platform, the only details the enter-view event
* lacks. The answer arrives as a client update, see {@link ConnectionEventHandler#onClientChanged}.
*/
public void requestClientInfo(int clientId) {
requestInfo("clientgetvariables", "clid", clientId, "ts3j-clientinfo");
}
private void requestInfo(String command, String idParameter, int id, String threadName) {
new Thread(() -> {
try {
Client c = client.getClientInfo(clientId);
ClientEntry e = model.getClient(clientId);
if (c != null && e != null) {
e.platform = orEmpty(c.getPlatform());
e.version = orEmpty(c.getVersion());
e.idleTimeMs = c.getIdleTime();
e.description = orEmpty(c.get("client_description"));
e.avatarFlag = orEmpty(c.get("client_flag_avatar"));
int[] groups = c.getServerGroups();
if (groups != null && groups.length > 0) e.serverGroupIds = groups;
e.channelGroupId = c.getChannelGroupId();
}
client.executeCommand(new SingleCommand(command, ProtocolRole.CLIENT,
new CommandSingleParameter(idParameter, Integer.toString(id)))).complete();
} catch (Exception ignored) {
} finally {
ui.onInfoUpdated();
// may be permission-restricted; leave as-is
}
}, "ts3j-clientinfo").start();
}, threadName).start();
}
/**