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:
@@ -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"));
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user