Split TeamspeakConnection into focused collaborator classes

Extract notify-event handling and the native-style server log/sound
announcements into ConnectionEventHandler, and getconnectioninfo
request/response + stats-snapshot building into ConnectionStatsCollector.
TeamspeakConnection remains the public facade (1828 -> 1037 lines); its
external API is unchanged. Pure refactor, no protocol/timing changes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 13:37:43 +00:00
parent cf5ba94092
commit b694845381
4 changed files with 928 additions and 829 deletions

View File

@@ -0,0 +1,590 @@
package com.ts3client.net;
import com.github.manevolent.ts3j.event.*;
import com.ts3client.sound.SoundEvent;
import com.ts3client.sound.SoundNotifier;
import java.util.Map;
/**
* The {@link TS3Listener} side of a connection: keeps {@link ServerModel} in sync with
* incoming notify events, mirrors native TeamSpeak's server-tab log lines, and picks the
* matching {@link SoundEvent} for each one. Registered alongside {@link TeamspeakConnection}
* itself (which keeps only the lifecycle-critical {@code onDisconnected} callback) so this
* class can stay focused on "event arrived, update state and tell the user" and nothing else.
*/
final class ConnectionEventHandler implements TS3Listener {
// ---- who went where: TeamSpeak's reason ids ----
/** {@code reasonid} of a client view/move notification. */
private static final int REASON_SWITCHED = 0;
private static final int REASON_MOVED = 1;
private static final int REASON_TIMEOUT = 3;
private static final int REASON_CHANNEL_KICK = 4;
private static final int REASON_SERVER_KICK = 5;
private static final int REASON_BAN = 6;
private final TeamspeakConnection conn;
ConnectionEventHandler(TeamspeakConnection conn) {
this.conn = conn;
}
@Override
public void onClientJoin(ClientJoinEvent e) {
ClientEntry c = conn.getModel().putClient(e.getClientId(), e.getClientNickname(), e.getClientTargetId());
c.type = safeInt(e, "client_type");
c.talkPower = e.getClientTalkPower();
c.inputMuted = e.isClientInputMuted();
c.outputMuted = e.isClientOutputMuted();
c.inputHardware = e.isClientUsingHardwareInput();
c.outputHardware = e.isClientUsingHardwareOutput();
c.away = e.isClientAway();
c.awayMessage = TeamspeakConnection.orEmpty(e.get("client_away_message"));
c.uniqueId = TeamspeakConnection.orEmpty(e.getUniqueClientIdentifier());
c.serverGroupIds = parseIntList(e.getClientServerGroups());
c.channelGroupId = e.getClientChannelGroupId();
c.self = (e.getClientId() == conn.getSelfClientId());
if (e.getClientId() != conn.getSelfClientId()) {
announceClientEntered(e);
logClientEntered(e);
}
conn.ui.onModelChanged();
}
@Override
public void onClientLeave(ClientLeaveEvent e) {
if (e.getClientId() == conn.getSelfClientId()) {
announceOwnRemoval(safeInt(e, "reasonid"), e);
} else {
ClientEntry leaving = conn.getModel().getClient(e.getClientId());
String name = leaving != null ? leaving.nickname : "Client " + e.getClientId();
announceClientLeft(e);
logClientLeft(e, name);
}
conn.getModel().removeClient(e.getClientId());
if (conn.getPlayback() != null) conn.getPlayback().removeClient(e.getClientId());
conn.ui.onModelChanged();
}
@Override
public void onClientMoved(ClientMovedEvent e) {
ClientEntry c = conn.getModel().getClient(e.getClientId());
if (c != null) {
int from = c.channelId;
c.channelId = e.getTargetChannelId();
if (e.getClientId() == conn.getSelfClientId()) {
announceOwnMove(safeInt(e, "reasonid"), e);
} else {
announceClientMoved(safeInt(e, "reasonid"), e.getClientId(), from, e.getTargetChannelId());
logClientMoved(e, c.nickname, from, e.getTargetChannelId());
}
conn.ui.onModelChanged();
}
}
/** A client became visible to us, logged the way native TS3's server tab does. */
private void logClientEntered(ClientJoinEvent e) {
String name = e.getClientNickname();
switch (safeInt(e, "reasonid")) {
case REASON_MOVED:
conn.log(name + " appears, coming from channel \"" + conn.channelName(e.getClientFromId()) + "\"");
break;
case REASON_CHANNEL_KICK:
conn.log(name + " appears, was kicked from channel \"" + conn.channelName(e.getClientFromId())
+ "\" by " + invokerName(e));
break;
case REASON_SWITCHED:
conn.log(name + " switched to channel \"" + conn.channelName(e.getClientTargetId())
+ "\", coming from channel \"" + conn.channelName(e.getClientFromId()) + "\"");
break;
default:
conn.log(name + " connected to channel \"" + conn.channelName(e.getClientTargetId()) + "\"");
}
}
/** A client stopped being visible to us. */
private void logClientLeft(ClientLeaveEvent e, String name) {
String reasonMsg = TeamspeakConnection.orEmpty(e.get("reasonmsg"));
String suffix = reasonMsg.isEmpty() ? "" : " (" + reasonMsg + ")";
switch (safeInt(e, "reasonid")) {
case REASON_TIMEOUT:
conn.log(name + " dropped (ping timeout)");
break;
case REASON_SERVER_KICK:
conn.log(name + " was kicked from the server by " + invokerName(e) + suffix);
break;
case REASON_BAN:
conn.log(name + " was banned from the server by " + invokerName(e) + suffix);
break;
case REASON_CHANNEL_KICK:
conn.log(name + " left: was kicked to channel \"" + conn.channelName(e.getClientTargetId())
+ "\" by " + invokerName(e) + suffix);
break;
case REASON_MOVED:
conn.log(name + " left, heading to channel \"" + conn.channelName(e.getClientTargetId()) + "\"");
break;
case REASON_SWITCHED:
conn.log(name + " left, switched to channel \"" + conn.channelName(e.getClientTargetId()) + "\"");
break;
default:
conn.log(name + " disconnected");
}
}
/** A client we can see moved between two channels we can see. */
private void logClientMoved(ClientMovedEvent e, String name, int fromChannel, int toChannel) {
String reasonMsg = TeamspeakConnection.orEmpty(e.get("reasonmsg"));
String suffix = reasonMsg.isEmpty() ? "" : " (" + reasonMsg + ")";
switch (safeInt(e, "reasonid")) {
case REASON_MOVED:
conn.log(name + " was moved from channel \"" + conn.channelName(fromChannel) + "\" to \""
+ conn.channelName(toChannel) + "\" by " + invokerName(e));
break;
case REASON_CHANNEL_KICK:
conn.log(name + " was kicked from channel \"" + conn.channelName(fromChannel) + "\" to \""
+ conn.channelName(toChannel) + "\" by " + invokerName(e) + suffix);
break;
default:
conn.log(name + " switched from channel \"" + conn.channelName(fromChannel) + "\" to \""
+ conn.channelName(toChannel) + "\"");
}
}
/**
* A client became visible: either they just connected, or they moved in from a
* channel we could not see — TeamSpeak's "appears" case.
*/
private void announceClientEntered(ClientJoinEvent e) {
if (!conn.isConnected()) return;
int clientId = e.getClientId();
boolean current = conn.inOwnChannel(e.getClientTargetId());
Map<String, String> vars = conn.clientVars(clientId, e.getClientNickname());
switch (safeInt(e, "reasonid")) {
case REASON_MOVED:
conn.sound(current ? SoundEvent.CLIENT_MOVED_TO_CURRENT_CHANNEL_APPEARS
: SoundEvent.CLIENT_MOVED_TO_OTHER_CHANNEL_APPEARS, vars);
break;
case REASON_CHANNEL_KICK:
conn.sound(current ? SoundEvent.CLIENT_WAS_KICKED_FROM_CHANNEL_TO_CURRENT_CHANNEL_APPEARS
: SoundEvent.CLIENT_WAS_KICKED_FROM_CHANNEL_TO_OTHER_CHANNEL_APPEARS, vars);
break;
case REASON_SWITCHED:
conn.sound(current ? SoundEvent.CLIENT_SWITCHED_TO_CURRENT_CHANNEL_APPEARS
: SoundEvent.CLIENT_SWITCHED_TO_OTHER_CHANNEL_APPEARS, vars);
break;
default:
conn.sound(current ? SoundEvent.CLIENT_CONNECTION_CONNECTED_CURRENT_CHANNEL
: SoundEvent.CLIENT_CONNECTION_CONNECTED_SERVER, vars);
}
}
/** A client stopped being visible: they left the server, or moved out of sight. */
private void announceClientLeft(ClientLeaveEvent e) {
if (!conn.isConnected()) return;
int clientId = e.getClientId();
boolean current = conn.inOwnChannel(e.getClientFromId());
Map<String, String> vars = conn.clientVars(clientId, null);
switch (safeInt(e, "reasonid")) {
case REASON_TIMEOUT:
conn.sound(current ? SoundEvent.CLIENT_CONNECTION_LOST_CONNECTION_CURRENT_CHANNEL
: SoundEvent.CLIENT_CONNECTION_LOST_CONNECTION_SERVER, vars);
break;
case REASON_SERVER_KICK:
conn.sound(current ? SoundEvent.CLIENT_WAS_KICKED_FROM_SERVER_CURRENT_CHANNEL
: SoundEvent.CLIENT_WAS_KICKED_FROM_SERVER_SERVER, vars);
break;
case REASON_BAN:
conn.sound(current ? SoundEvent.CLIENT_WAS_BANNED_CURRENT_CHANNEL
: SoundEvent.CLIENT_WAS_BANNED_SERVER, vars);
break;
case REASON_CHANNEL_KICK:
conn.sound(current ? SoundEvent.CLIENT_WAS_KICKED_FROM_CHANNEL_FROM_CURRENT_CHANNEL_DISAPPEARS
: SoundEvent.CLIENT_WAS_KICKED_FROM_CHANNEL_TO_OTHER_CHANNEL_DISAPPEARS, vars);
break;
case REASON_MOVED:
conn.sound(current ? SoundEvent.CLIENT_MOVED_FROM_CURRENT_CHANNEL_DISAPPEARS
: SoundEvent.CLIENT_MOVED_TO_OTHER_CHANNEL_DISAPPEARS, vars);
break;
case REASON_SWITCHED:
conn.sound(current ? SoundEvent.CLIENT_SWITCHED_FROM_CURRENT_CHANNEL_DISAPPEARS
: SoundEvent.CLIENT_SWITCHED_TO_OTHER_CHANNEL_DISAPPEARS, vars);
break;
default:
conn.sound(current ? SoundEvent.CLIENT_CONNECTION_DISCONNECTED_CURRENT_CHANNEL
: SoundEvent.CLIENT_CONNECTION_DISCONNECTED_SERVER, vars);
}
}
/** A client we can see moved between two channels we can see ("stays"). */
private void announceClientMoved(int reason, int clientId, int fromChannel, int toChannel) {
if (!conn.isConnected()) return;
boolean toCurrent = conn.inOwnChannel(toChannel);
boolean fromCurrent = conn.inOwnChannel(fromChannel);
Map<String, String> vars = conn.clientVars(clientId, null);
switch (reason) {
case REASON_MOVED:
conn.sound(toCurrent ? SoundEvent.CLIENT_MOVED_TO_CURRENT_CHANNEL_STAYS
: fromCurrent ? SoundEvent.CLIENT_MOVED_FROM_CURRENT_CHANNEL_STAYS
: SoundEvent.CLIENT_MOVED_TO_OTHER_CHANNEL_STAYS, vars);
break;
case REASON_CHANNEL_KICK:
conn.sound(toCurrent ? SoundEvent.CLIENT_WAS_KICKED_FROM_CHANNEL_TO_CURRENT_CHANNEL_STAYS
: fromCurrent ? SoundEvent.CLIENT_WAS_KICKED_FROM_CHANNEL_FROM_CURRENT_CHANNEL_STAYS
: SoundEvent.CLIENT_WAS_KICKED_FROM_CHANNEL_TO_OTHER_CHANNEL_STAYS, vars);
break;
default:
conn.sound(toCurrent ? SoundEvent.CLIENT_SWITCHED_TO_CURRENT_CHANNEL_STAYS
: fromCurrent ? SoundEvent.CLIENT_SWITCHED_FROM_CURRENT_CHANNEL_STAYS
: SoundEvent.CLIENT_SWITCHED_TO_OTHER_CHANNEL_STAYS, vars);
}
}
/** We changed channel ourselves, or somebody changed it for us. */
private void announceOwnMove(int reason, ClientMovedEvent e) {
if (!conn.isConnected()) return;
Map<String, String> vars = conn.channelVars(e.getTargetChannelId(), e.get("invokername"));
switch (reason) {
case REASON_MOVED:
conn.sound(SoundEvent.YOU_WERE_MOVED_TO_DIFFERENT_CHANNEL, vars);
break;
case REASON_CHANNEL_KICK:
conn.sound(SoundEvent.YOU_WERE_KICKED_FROM_CHANNEL, vars);
break;
default:
conn.sound(SoundEvent.YOU_SWITCHED_CHANNEL, vars);
}
}
/** We were removed from the server (kick or ban); the disconnect follows. */
private void announceOwnRemoval(int reason, ClientLeaveEvent e) {
if (!conn.isConnected()) return;
Map<String, String> vars = SoundNotifier.vars(
"servername", conn.getModel().getServerName(),
"clientname", TeamspeakConnection.orEmpty(e.get("invokername")),
"reason", TeamspeakConnection.orEmpty(e.get("reasonmsg")));
if (reason == REASON_SERVER_KICK) {
conn.sound(SoundEvent.YOU_WERE_KICKED_FROM_SERVER, vars);
} else if (reason == REASON_BAN) {
conn.sound(SoundEvent.YOU_WERE_BANNED, vars);
}
}
@Override
public void onClientChanged(ClientUpdatedEvent e) {
ClientEntry c = conn.getModel().getClient(e.getClientId());
if (c == null) return;
boolean renamed = has(e, "client_nickname") && !e.get("client_nickname").equals(c.nickname);
String oldName = c.nickname;
if (renamed) c.nickname = e.get("client_nickname");
if (has(e, "client_input_muted")) c.inputMuted = e.getBoolean("client_input_muted");
if (has(e, "client_output_muted")) c.outputMuted = e.getBoolean("client_output_muted");
if (has(e, "client_input_hardware")) c.inputHardware = e.getBoolean("client_input_hardware");
if (has(e, "client_output_hardware")) c.outputHardware = e.getBoolean("client_output_hardware");
if (has(e, "client_away")) {
c.away = e.getBoolean("client_away");
// Both fields travel together, so an absent message here means "no message"
// — which `has` cannot tell from "not reported".
c.awayMessage = c.away ? TeamspeakConnection.orEmpty(e.get("client_away_message")) : "";
} else if (has(e, "client_away_message")) {
c.awayMessage = e.get("client_away_message");
}
if (has(e, "client_talk_power")) c.talkPower = e.getInt("client_talk_power");
if (has(e, "client_is_channel_commander"))
c.channelCommander = e.getBoolean("client_is_channel_commander");
announceClientUpdate(e, c, renamed, oldName);
conn.ui.onModelChanged();
}
/** Renames, talk-power changes and recording flags all arrive as a client update. */
private void announceClientUpdate(ClientUpdatedEvent e, ClientEntry c, boolean renamed, String oldName) {
if (!conn.isConnected()) return;
boolean self = c.id == conn.getSelfClientId();
Map<String, String> vars = conn.clientVars(c.id, null);
if (renamed) {
conn.sound(safeInt(e, "invokerid") == conn.getSelfClientId()
? SoundEvent.CLIENT_RENAMED_BY_YOU : SoundEvent.CLIENT_RENAMED_BY_OTHER, vars);
conn.log(oldName + " is now known as " + c.nickname);
}
if (safeInt(e, "client_talk_request") > 0 && !self) {
conn.sound(SoundEvent.CLIENT_REQUESTED_TALK_POWER, vars);
}
if (self && has(e, "client_is_talker")) {
conn.sound(e.getBoolean("client_is_talker")
? SoundEvent.YOU_WERE_GRANTED_TALK_POWER : SoundEvent.YOU_WERE_REVOKED_TALK_POWER, vars);
}
if (!self && has(e, "client_is_recording")) {
boolean recording = e.getBoolean("client_is_recording");
if (!recording) {
conn.sound(SoundEvent.CLIENT_RECORDING_STOP, vars);
} else {
conn.sound(conn.inOwnChannel(c.channelId)
? SoundEvent.CLIENT_RECORDING_IN_CHANNEL : SoundEvent.CLIENT_RECORDING_START, vars);
}
}
}
@Override
public void onChannelCreate(ChannelCreateEvent e) {
int cid = e.getChannelId();
String name = e.get("channel_name");
int pid = safeInt(e, "cpid");
if (pid == 0) pid = safeInt(e, "pid");
int order = safeInt(e, "channel_order");
ChannelNode node = conn.getModel().putChannel(cid, name, pid, order);
long icon = TeamspeakConnection.safeLong(e, "channel_icon_id");
if (icon != 0) node.iconId = icon;
conn.getModel().relinkChannel(cid, pid, order);
if (conn.isConnected()) {
conn.sound(byInvoker(e, SoundEvent.CHANNEL_CREATED_BY_YOU, SoundEvent.CHANNEL_CREATED_BY_OTHER,
SoundEvent.CHANNEL_CREATED_BY_OTHER), conn.channelVars(cid, e.get("invokername")));
conn.log("Channel \"" + name + "\" was created by " + invokerName(e));
}
conn.ui.onModelChanged();
}
@Override
public void onChannelDeleted(ChannelDeletedEvent e) {
int cid = e.getChannelId();
if (conn.isConnected()) {
conn.sound(byInvoker(e, SoundEvent.CHANNEL_DELETED_BY_YOU, SoundEvent.CHANNEL_DELETED_BY_OTHER,
SoundEvent.CHANNEL_DELETED_BY_SERVER), conn.channelVars(cid, e.get("invokername")));
conn.log("Channel \"" + conn.channelName(cid) + "\" was deleted by " + invokerName(e));
}
conn.getModel().removeChannel(cid);
conn.ui.onModelChanged();
}
@Override
public void onChannelEdit(ChannelEditedEvent e) {
ChannelNode ch = conn.getModel().getChannel(safeInt(e, "cid"));
if (ch != null) {
if (has(e, "channel_name")) ch.name = e.get("channel_name");
if (has(e, "channel_order")) ch.order = e.getInt("channel_order");
if (has(e, "channel_icon_id")) ch.iconId = TeamspeakConnection.safeLong(e, "channel_icon_id");
if (conn.isConnected()) {
boolean current = conn.inOwnChannel(ch.id);
conn.sound(current
? byInvoker(e, SoundEvent.CHANNEL_EDITED_CURRENT_BY_YOU,
SoundEvent.CHANNEL_EDITED_CURRENT_BY_OTHER, SoundEvent.CHANNEL_EDITED_CURRENT_BY_OTHER)
: byInvoker(e, SoundEvent.CHANNEL_EDITED_OTHER_BY_YOU,
SoundEvent.CHANNEL_EDITED_OTHER_BY_OTHER, SoundEvent.CHANNEL_EDITED_OTHER_BY_SERVER),
conn.channelVars(ch.id, e.get("invokername")));
conn.log("Channel \"" + ch.name + "\" was edited by " + invokerName(e));
}
conn.ui.onModelChanged();
}
}
@Override
public void onChannelMoved(ChannelMovedEvent e) {
ChannelNode ch = conn.getModel().getChannel(safeInt(e, "cid"));
if (ch != null) {
int parent = has(e, "cpid") ? e.getInt("cpid") : ch.parentId;
int order = has(e, "order") ? e.getInt("order") : ch.order;
conn.getModel().relinkChannel(ch.id, parent, order);
if (conn.isConnected()) {
conn.sound(byInvoker(e, SoundEvent.CHANNEL_MOVED_BY_YOU, SoundEvent.CHANNEL_MOVED_BY_OTHER,
SoundEvent.CHANNEL_MOVED_BY_OTHER), conn.channelVars(ch.id, e.get("invokername")));
conn.log("Channel \"" + ch.name + "\" was moved by " + invokerName(e));
}
conn.ui.onModelChanged();
}
}
@Override
public void onChannelSubscribed(ChannelSubscribedEvent e) {
setSubscribed(safeInt(e, "cid"), true);
}
@Override
public void onChannelUnsubscribed(ChannelUnsubscribedEvent e) {
setSubscribed(safeInt(e, "cid"), false);
}
private void setSubscribed(int cid, boolean subscribed) {
ChannelNode ch = conn.getModel().getChannel(cid);
if (ch == null || ch.subscribed == subscribed) return;
ch.subscribed = subscribed;
conn.ui.onModelChanged();
}
@Override
public void onServerEdit(ServerEditedEvent e) {
if (has(e, "virtualserver_name")) conn.getModel().setServerName(e.get("virtualserver_name"));
if (conn.isConnected()) {
conn.sound(byInvoker(e, SoundEvent.SERVER_EDITED_BY_YOU, SoundEvent.SERVER_EDITED_BY_OTHER,
SoundEvent.SERVER_EDITED_BY_OTHER), conn.serverVars());
}
conn.ui.onModelChanged();
}
@Override
public void onServerGroupClientAdded(ServerGroupClientAddedEvent e) {
boolean self = e.getClientId() == conn.getSelfClientId();
conn.sound(self
? byInvoker(e, SoundEvent.YOU_SERVERGROUP_ADDED_BY_USER, SoundEvent.YOU_SERVERGROUP_ADDED_BY_USER,
SoundEvent.YOU_SERVERGROUP_ADDED_BY_SERVER)
: byInvoker(e, SoundEvent.CLIENT_SERVERGROUP_ADDED_BY_USER,
SoundEvent.CLIENT_SERVERGROUP_ADDED_BY_USER, SoundEvent.CLIENT_SERVERGROUP_ADDED_BY_SERVER),
groupVars(e.getClientId(), e.getName()));
conn.log(clientLogName(e.getClientId()) + " was added to server group \"" + e.getName()
+ "\" by " + invokerName(e) + ".");
}
@Override
public void onServerGroupClientDeleted(ServerGroupClientDeletedEvent e) {
int clientId = safeInt(e, "clid");
boolean self = clientId == conn.getSelfClientId();
conn.sound(self
? byInvoker(e, SoundEvent.YOU_SERVERGROUP_REMOVED_BY_USER,
SoundEvent.YOU_SERVERGROUP_REMOVED_BY_USER, SoundEvent.YOU_SERVERGROUP_REMOVED_BY_SERVER)
: byInvoker(e, SoundEvent.CLIENT_SERVERGROUP_REMOVED_BY_USER,
SoundEvent.CLIENT_SERVERGROUP_REMOVED_BY_USER, SoundEvent.CLIENT_SERVERGROUP_REMOVED_BY_SERVER),
groupVars(clientId, e.get("name")));
conn.log(clientLogName(clientId) + " was removed from server group \"" + TeamspeakConnection.orEmpty(e.get("name"))
+ "\" by " + invokerName(e) + ".");
}
@Override
public void onClientChannelGroupChanged(ClientChannelGroupChangedEvent e) {
ClientEntry c = conn.getModel().getClient(e.getClientId());
if (c != null) c.channelGroupId = e.getChannelGroupId();
boolean self = e.getClientId() == conn.getSelfClientId();
String groupName = conn.getModel().channelGroupName(e.getChannelGroupId());
conn.sound(self
? byInvoker(e, SoundEvent.YOU_CHANNELGROUP_CHANGED_BY_USER,
SoundEvent.YOU_CHANNELGROUP_CHANGED_BY_USER, SoundEvent.YOU_CHANNELGROUP_CHANGED_BY_SERVER)
: byInvoker(e, SoundEvent.CLIENT_CHANNELGROUP_CHANGED_BY_USER,
SoundEvent.CLIENT_CHANNELGROUP_CHANGED_BY_USER, SoundEvent.CLIENT_CHANNELGROUP_CHANGED_BY_SERVER),
groupVars(e.getClientId(), groupName));
conn.log("Channel group \"" + TeamspeakConnection.orEmpty(groupName) + "\" was assigned to "
+ clientLogName(e.getClientId()) + " by " + invokerName(e) + ".");
conn.ui.onModelChanged();
}
/** A client's nickname for a log line, falling back to its id once it has left. */
private String clientLogName(int clientId) {
ClientEntry c = conn.getModel().getClient(clientId);
return c != null ? c.nickname : "Client " + clientId;
}
/** Picks the event variant matching who caused the change: us, another client, or the server. */
private SoundEvent byInvoker(BaseEvent e, SoundEvent byYou, SoundEvent byOther, SoundEvent byServer) {
int invoker = safeInt(e, "invokerid");
if (invoker == conn.getSelfClientId() && invoker != 0) return byYou;
return invoker == 0 ? byServer : byOther;
}
private Map<String, String> groupVars(int clientId, String groupName) {
Map<String, String> vars = conn.clientVars(clientId, null);
vars.put("groupname", TeamspeakConnection.orEmpty(groupName));
return vars;
}
@Override
public void onChannelList(ChannelListEvent e) {
// Incremental channel arriving during connect.
int cid = e.getChannelId();
String name = e.get("channel_name");
int pid = safeInt(e, "cpid");
int order = safeInt(e, "channel_order");
ChannelNode node = conn.getModel().putChannel(cid, name, pid, order);
long icon = TeamspeakConnection.safeLong(e, "channel_icon_id");
if (icon != 0) node.iconId = icon;
}
@Override
public void onServerGroupList(ServerGroupListEvent e) {
int id = safeInt(e, "sgid");
if (id > 0) conn.getModel().putServerGroup(toGroup(e, id));
}
@Override
public void onChannelGroupList(ChannelGroupListEvent e) {
int id = safeInt(e, "cgid");
if (id > 0) conn.getModel().putChannelGroup(toGroup(e, id));
}
private static Group toGroup(BaseEvent e, int id) {
return new Group(id, e.get("name"), TeamspeakConnection.safeLong(e, "iconid"), safeInt(e, "sortid"));
}
@Override
public void onTextMessage(TextMessageEvent e) {
if (e.getInvokerId() == conn.getSelfClientId()) return; // don't echo our own
ConnectionListener.ChatScope scope;
SoundEvent notification;
switch (e.getTargetMode()) {
case CLIENT:
scope = ConnectionListener.ChatScope.PRIVATE;
notification = SoundEvent.CHAT_RECEIVED_MESSAGE_CLIENT;
break;
case CHANNEL:
scope = ConnectionListener.ChatScope.CHANNEL;
notification = SoundEvent.CHAT_RECEIVED_MESSAGE_CHANNEL;
break;
default:
scope = ConnectionListener.ChatScope.SERVER;
notification = SoundEvent.CHAT_RECEIVED_MESSAGE_SERVER;
break;
}
conn.sound(notification, conn.clientVars(e.getInvokerId(), e.getInvokerName()));
conn.ui.onChat(scope, e.getInvokerId(), e.getInvokerName(), e.getMessage());
}
@Override
public void onClientPoke(ClientPokeEvent e) {
conn.sound(SoundEvent.OTHER_RECEIVED_POKE, conn.clientVars(e.getInvokerId(), e.getInvokerName()));
conn.ui.onPoke(TeamspeakConnection.orEmpty(e.getInvokerName()), TeamspeakConnection.orEmpty(e.get("msg")));
}
@Override
public void onUnknownEvent(UnknownTeamspeakEvent e) {
if ("notifyconnectioninfo".equals(e.getCommand())) conn.stats.onReport(e);
}
// ---- helpers ----
private static String invokerName(BaseEvent e) {
String name = TeamspeakConnection.orEmpty(e.get("invokername"));
return name.isEmpty() ? "the server" : name;
}
private static int safeInt(BaseEvent e, String key) {
try {
String v = e.get(key);
return v == null ? 0 : Integer.parseInt(v.trim());
} catch (Exception ex) {
return 0;
}
}
/**
* Whether the event actually carries a field. ts3j answers a missing key with an
* empty string rather than null, so a plain null check is always true — and a
* partial update (say, someone muting) would otherwise look like it reported
* every other field as well.
*/
private static boolean has(BaseEvent e, String key) {
String value = e.get(key);
return value != null && !value.isEmpty();
}
/** Parses a comma-separated id list (e.g. server groups "6,12,15"). */
private static int[] parseIntList(String csv) {
if (csv == null || csv.isEmpty()) return new int[0];
String[] parts = csv.split(",");
int[] out = new int[parts.length];
int n = 0;
for (String p : parts) {
try {
out[n++] = Integer.parseInt(p.trim());
} catch (NumberFormatException ignored) {
}
}
return n == parts.length ? out : java.util.Arrays.copyOf(out, n);
}
}

View File

@@ -0,0 +1,300 @@
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;
import com.github.manevolent.ts3j.protocol.PacketKind;
import com.github.manevolent.ts3j.protocol.ProtocolRole;
import com.github.manevolent.ts3j.protocol.packet.statistics.PacketStatistics;
import com.github.manevolent.ts3j.protocol.socket.client.LocalTeamspeakClientSocket;
import com.github.manevolent.ts3j.util.Pair;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Consumer;
/**
* Builds {@link ConnectionStats} snapshots for the info dialog. The local client's own
* figures are read straight from its live packet counters; a remote client's come from
* {@code getconnectioninfo}, whose {@code notifyconnectioninfo} report arrives
* asynchronously via {@link #onReport} and is matched back up by client id.
*/
final class ConnectionStatsCollector {
private final TeamspeakConnection conn;
/** Outstanding {@code getconnectioninfo} requests awaiting a server report, keyed by client id. */
private final Map<Integer, PendingConnInfo> pending = new ConcurrentHashMap<>();
ConnectionStatsCollector(TeamspeakConnection conn) {
this.conn = conn;
}
/** Drops any requests left over from a connection that just went away. */
void clear() {
pending.clear();
}
/**
* Fetches connection statistics for a client and delivers them to
* {@code callback} (invoked off the Swing EDT — the callback is responsible
* for marshalling). May be called repeatedly to poll.
*/
void request(int clientId, Consumer<ConnectionStats> callback) {
if (clientId == conn.getSelfClientId()) {
new Thread(() -> callback.accept(buildLocalStats()), "ts3j-conninfo-self").start();
} else {
new Thread(() -> requestRemote(clientId, callback), "ts3j-conninfo").start();
}
}
/** Matches an asynchronously arriving {@code notifyconnectioninfo} report to its request. */
void onReport(UnknownTeamspeakEvent e) {
int clid = safeInt(e.get("clid"));
PendingConnInfo p = pending.remove(clid);
if (p == null) return;
applyConnectionFields(p.stats, e.getMap());
p.callback.accept(p.stats);
}
/** Builds a live snapshot of the local client's connection from its own counters. */
private ConnectionStats buildLocalStats() {
ConnectionStats s = new ConnectionStats();
s.clientId = conn.getSelfClientId();
s.self = true;
s.live = true;
s.packetLoss = 0; // the local client has no server->client loss figure
ClientEntry self = conn.getModel().getClient(conn.getSelfClientId());
if (self != null) {
s.nickname = self.nickname;
s.version = self.version;
s.platform = self.platform;
s.idleTimeMs = self.idleTimeMs;
}
long connectedAt = conn.connectedAtMs;
if (connectedAt > 0) s.connectedTimeMs = System.currentTimeMillis() - connectedAt;
LocalTeamspeakClientSocket c = conn.client;
if (c == null) return s;
try {
Pair<Double, Double> ping = c.getPing();
s.pingMs = ping.getKey() * 1000.0;
s.pingDeviationMs = ping.getValue() * 1000.0;
} catch (Exception ignored) {
// ping unavailable; leave as unknown
}
long pSent = 0, pRecv = 0, bSent = 0, bRecv = 0;
long bwSs = 0, bwRs = 0, bwSm = 0, bwRm = 0;
for (PacketKind kind : PacketKind.values()) {
PacketStatistics st = c.getStatistics(kind);
ConnectionStats.KindStats ks = new ConnectionStats.KindStats(mapKind(kind));
ks.packetLoss = 0; // the local client cannot measure its own server->client loss
ks.packetsSent = st.getSentPackets();
ks.packetsReceived = st.getReceivedPackets();
ks.bytesSent = st.getSentBytes();
ks.bytesReceived = st.getReceivedBytes();
ks.bandwidthSentLastSecond = st.getSentBytesLastSecond();
ks.bandwidthReceivedLastSecond = st.getReceivedBytesLastSecond();
ks.bandwidthSentLastMinute = st.getSentBytesLastMinute();
ks.bandwidthReceivedLastMinute = st.getReceivedBytesLastMinute();
s.perKind.add(ks);
pSent += ks.packetsSent;
pRecv += ks.packetsReceived;
bSent += ks.bytesSent;
bRecv += ks.bytesReceived;
bwSs += ks.bandwidthSentLastSecond;
bwRs += ks.bandwidthReceivedLastSecond;
bwSm += ks.bandwidthSentLastMinute;
bwRm += ks.bandwidthReceivedLastMinute;
}
s.packetsSentTotal = pSent;
s.packetsReceivedTotal = pRecv;
s.bytesSentTotal = bSent;
s.bytesReceivedTotal = bRecv;
s.bandwidthSentLastSecond = bwSs;
s.bandwidthReceivedLastSecond = bwRs;
s.bandwidthSentLastMinute = bwSm;
s.bandwidthReceivedLastMinute = bwRm;
return s;
}
/**
* 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.
*/
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
}
PendingConnInfo p = new PendingConnInfo(callback, s);
pending.put(clientId, p);
boolean sent = false;
try {
SingleCommand cmd = new SingleCommand("getconnectioninfo", ProtocolRole.CLIENT,
new CommandSingleParameter("clid", Integer.toString(clientId)));
client.executeCommand(cmd).complete();
sent = true;
} catch (Exception ignored) {
// command failed; fall back to the clientinfo snapshot below
}
if (sent) {
try {
Thread.sleep(700); // give notifyconnectioninfo a chance to arrive
} catch (InterruptedException ignored) {
Thread.currentThread().interrupt();
}
}
// If the report already arrived, onReport removed and delivered it.
if (pending.remove(clientId, p)) {
callback.accept(s);
}
}
/** Parses TeamSpeak {@code connection_*} fields from a command map into {@code s}. */
private static void applyConnectionFields(ConnectionStats s, Map<String, String> m) {
double ping = parseDouble(m.get("connection_ping"));
if (ping >= 0) s.pingMs = ping;
double dev = parseDouble(m.get("connection_ping_deviation"));
if (dev >= 0) s.pingDeviationMs = dev;
double loss = parseDouble(m.get("connection_packetloss_total"));
if (loss < 0) loss = parseDouble(m.get("connection_server2client_packetloss_total"));
if (loss >= 0) s.packetLoss = loss;
long connected = parseLong(m.get("connection_connected_time"));
if (connected >= 0) s.connectedTimeMs = connected;
String ip = m.get("connection_client_ip");
if (ip != null && !ip.isEmpty()) s.ip = ip;
s.packetsSentTotal = pick(s.packetsSentTotal, m.get("connection_packets_sent_total"));
s.packetsReceivedTotal = pick(s.packetsReceivedTotal, m.get("connection_packets_received_total"));
s.bytesSentTotal = pick(s.bytesSentTotal, m.get("connection_bytes_sent_total"));
s.bytesReceivedTotal = pick(s.bytesReceivedTotal, m.get("connection_bytes_received_total"));
s.bandwidthSentLastSecond =
pick(s.bandwidthSentLastSecond, m.get("connection_bandwidth_sent_last_second_total"));
s.bandwidthReceivedLastSecond =
pick(s.bandwidthReceivedLastSecond, m.get("connection_bandwidth_received_last_second_total"));
s.bandwidthSentLastMinute =
pick(s.bandwidthSentLastMinute, m.get("connection_bandwidth_sent_last_minute_total"));
s.bandwidthReceivedLastMinute =
pick(s.bandwidthReceivedLastMinute, m.get("connection_bandwidth_received_last_minute_total"));
s.filetransferBandwidthSent =
pick(s.filetransferBandwidthSent, m.get("connection_filetransfer_bandwidth_sent"));
s.filetransferBandwidthReceived =
pick(s.filetransferBandwidthReceived, m.get("connection_filetransfer_bandwidth_received"));
applyPerKindFields(s, m);
}
/**
* Parses the per-category {@code connection_*_<kind>} fields (as sent in a
* {@code notifyconnectioninfo} report) into {@code s}, merging into any
* existing rows. Categories absent from the map are left untouched.
*/
private static void applyPerKindFields(ConnectionStats s, Map<String, String> m) {
for (ConnectionStats.Kind kind : ConnectionStats.Kind.values()) {
String suffix = kind.name().toLowerCase(java.util.Locale.ROOT); // keepalive/control/speech
String probe = m.get("connection_packets_sent_" + suffix);
String probe2 = m.get("connection_server2client_packetloss_" + suffix);
if (probe == null && probe2 == null) continue; // this category not reported
ConnectionStats.KindStats ks = s.getOrCreateKind(kind);
ks.packetsSent = pick(ks.packetsSent, m.get("connection_packets_sent_" + suffix));
ks.packetsReceived = pick(ks.packetsReceived, m.get("connection_packets_received_" + suffix));
ks.bytesSent = pick(ks.bytesSent, m.get("connection_bytes_sent_" + suffix));
ks.bytesReceived = pick(ks.bytesReceived, m.get("connection_bytes_received_" + suffix));
ks.bandwidthSentLastSecond =
pick(ks.bandwidthSentLastSecond, m.get("connection_bandwidth_sent_last_second_" + suffix));
ks.bandwidthReceivedLastSecond =
pick(ks.bandwidthReceivedLastSecond, m.get("connection_bandwidth_received_last_second_" + suffix));
ks.bandwidthSentLastMinute =
pick(ks.bandwidthSentLastMinute, m.get("connection_bandwidth_sent_last_minute_" + suffix));
ks.bandwidthReceivedLastMinute =
pick(ks.bandwidthReceivedLastMinute, m.get("connection_bandwidth_received_last_minute_" + suffix));
double kloss = parseDouble(m.get("connection_server2client_packetloss_" + suffix));
if (kloss >= 0) ks.packetLoss = kloss;
}
}
private static long pick(long current, String value) {
long v = parseLong(value);
return v >= 0 ? v : current;
}
private static ConnectionStats.Kind mapKind(PacketKind kind) {
switch (kind) {
case KEEPALIVE:
return ConnectionStats.Kind.KEEPALIVE;
case SPEECH:
return ConnectionStats.Kind.SPEECH;
default:
return ConnectionStats.Kind.CONTROL;
}
}
/** Parses a long, returning -1 for null/blank/non-numeric input. */
private static long parseLong(String s) {
if (s == null || s.isEmpty()) return -1;
try {
return Long.parseLong(s.trim());
} catch (NumberFormatException e) {
return -1;
}
}
/** Parses a double, returning -1 for null/blank/non-numeric input. */
private static double parseDouble(String s) {
if (s == null || s.isEmpty()) return -1;
try {
return Double.parseDouble(s.trim());
} catch (NumberFormatException e) {
return -1;
}
}
/** @return the parsed value, or 0 when it is absent or not a number */
private static int safeInt(String value) {
try {
return value == null ? 0 : Integer.parseInt(value.trim());
} catch (NumberFormatException ex) {
return 0;
}
}
/** Callback + accumulating snapshot for an in-flight {@code getconnectioninfo}. */
private static final class PendingConnInfo {
final Consumer<ConnectionStats> callback;
final ConnectionStats stats;
PendingConnInfo(Consumer<ConnectionStats> callback, ConnectionStats stats) {
this.callback = callback;
this.stats = stats;
}
}
}

View File

@@ -7,11 +7,8 @@ import com.github.manevolent.ts3j.command.SingleCommand;
import com.github.manevolent.ts3j.command.parameter.CommandSingleParameter;
import com.github.manevolent.ts3j.event.*;
import com.github.manevolent.ts3j.identity.LocalIdentity;
import com.github.manevolent.ts3j.protocol.PacketKind;
import com.github.manevolent.ts3j.protocol.ProtocolRole;
import com.github.manevolent.ts3j.protocol.packet.statistics.PacketStatistics;
import com.github.manevolent.ts3j.protocol.socket.client.LocalTeamspeakClientSocket;
import com.github.manevolent.ts3j.util.Pair;
import com.github.manevolent.ts3j.util.Ts3Crypt;
import com.ts3client.audio.AudioBackend;
import com.ts3client.audio.VoiceInput;
@@ -52,11 +49,18 @@ public final class TeamspeakConnection implements TS3Listener {
private final Settings settings;
private final AudioBackend audio;
private final ServerModel model = new ServerModel();
private final ConnectionListener ui;
/** Package-private: read directly by {@link ConnectionEventHandler}. */
final ConnectionListener ui;
private final IconRepository icons;
private final SoundNotifier sounds;
/** Package-private: read directly by {@link ConnectionEventHandler}. */
final SoundNotifier sounds;
/** The {@link TS3Listener} side of this connection; see its class doc. */
private final ConnectionEventHandler events = new ConnectionEventHandler(this);
/** Builds {@code getconnectioninfo} snapshots; also package-private for {@link ConnectionEventHandler}. */
final ConnectionStatsCollector stats = new ConnectionStatsCollector(this);
private LocalTeamspeakClientSocket client;
/** Package-private: read directly by {@link ConnectionStatsCollector}. */
LocalTeamspeakClientSocket client;
private VoiceInput microphone;
private VoiceOutput playback;
private LocalIdentity identity;
@@ -64,7 +68,8 @@ public final class TeamspeakConnection implements TS3Listener {
private volatile boolean connected;
private volatile int selfClientId = -1;
private volatile long connectedAtMs;
/** Package-private: read directly by {@link ConnectionStatsCollector}. */
volatile long connectedAtMs;
private volatile String serverHost;
private volatile int serverPort;
@@ -83,9 +88,6 @@ public final class TeamspeakConnection implements TS3Listener {
private volatile boolean disconnectAnnounced = true;
private volatile long lastMutedTalkNanos;
/** Outstanding {@code getconnectioninfo} requests awaiting a server report, keyed by client id. */
private final Map<Integer, PendingConnInfo> pendingConnInfo = new ConcurrentHashMap<>();
public TeamspeakConnection(Settings settings, AudioBackend audio, ConnectionListener ui,
SoundNotifier sounds) {
this.settings = settings;
@@ -205,7 +207,7 @@ public final class TeamspeakConnection implements TS3Listener {
model.clear();
playback = audio.createOutput(settings);
playback.setMasterVolume(settings.outputVolume);
playback.setMasterVolume(settings.effectiveOutputVolume());
playback.setTalkListener((clientId, talking) -> {
ClientEntry c = model.getClient(clientId);
if (c != null) c.talking = talking;
@@ -233,6 +235,7 @@ public final class TeamspeakConnection implements TS3Listener {
client.setNickname(nickname);
client.setHWID("ts3jswing-" + Integer.toHexString(nickname.hashCode()));
client.addListener(this);
client.addListener(events);
client.setVoiceHandler(playback::handleVoice);
client.setWhisperHandler(playback::handleWhisper);
client.setExceptionHandler(t -> {
@@ -486,7 +489,7 @@ public final class TeamspeakConnection implements TS3Listener {
client = null;
fileTransfers = null;
selfClientId = -1;
pendingConnInfo.clear();
stats.clear();
if (ft != null) {
try {
@@ -774,499 +777,6 @@ public final class TeamspeakConnection implements TS3Listener {
}, "ts3j-rename").start();
}
// ---- TS3Listener: keep model in sync and notify UI ----
@Override
public void onClientJoin(ClientJoinEvent e) {
ClientEntry c = model.putClient(e.getClientId(), e.getClientNickname(), e.getClientTargetId());
c.type = safeInt(e, "client_type");
c.talkPower = e.getClientTalkPower();
c.inputMuted = e.isClientInputMuted();
c.outputMuted = e.isClientOutputMuted();
c.inputHardware = e.isClientUsingHardwareInput();
c.outputHardware = e.isClientUsingHardwareOutput();
c.away = e.isClientAway();
c.awayMessage = orEmpty(e.get("client_away_message"));
c.uniqueId = orEmpty(e.getUniqueClientIdentifier());
c.serverGroupIds = parseIntList(e.getClientServerGroups());
c.channelGroupId = e.getClientChannelGroupId();
c.self = (e.getClientId() == selfClientId);
if (e.getClientId() != selfClientId) {
announceClientEntered(e);
logClientEntered(e);
}
ui.onModelChanged();
}
@Override
public void onClientLeave(ClientLeaveEvent e) {
if (e.getClientId() == selfClientId) {
announceOwnRemoval(safeInt(e, "reasonid"), e);
} else {
ClientEntry leaving = model.getClient(e.getClientId());
String name = leaving != null ? leaving.nickname : "Client " + e.getClientId();
announceClientLeft(e);
logClientLeft(e, name);
}
model.removeClient(e.getClientId());
if (playback != null) playback.removeClient(e.getClientId());
ui.onModelChanged();
}
@Override
public void onClientMoved(ClientMovedEvent e) {
ClientEntry c = model.getClient(e.getClientId());
if (c != null) {
int from = c.channelId;
c.channelId = e.getTargetChannelId();
if (e.getClientId() == selfClientId) {
announceOwnMove(safeInt(e, "reasonid"), e);
} else {
announceClientMoved(safeInt(e, "reasonid"), e.getClientId(), from, e.getTargetChannelId());
logClientMoved(e, c.nickname, from, e.getTargetChannelId());
}
ui.onModelChanged();
}
}
/** A client became visible to us, logged the way native TS3's server tab does. */
private void logClientEntered(ClientJoinEvent e) {
String name = e.getClientNickname();
switch (safeInt(e, "reasonid")) {
case REASON_MOVED:
log(name + " appears, coming from channel \"" + channelName(e.getClientFromId()) + "\"");
break;
case REASON_CHANNEL_KICK:
log(name + " appears, was kicked from channel \"" + channelName(e.getClientFromId())
+ "\" by " + invokerName(e));
break;
case REASON_SWITCHED:
log(name + " switched to channel \"" + channelName(e.getClientTargetId())
+ "\", coming from channel \"" + channelName(e.getClientFromId()) + "\"");
break;
default:
log(name + " connected to channel \"" + channelName(e.getClientTargetId()) + "\"");
}
}
/** A client stopped being visible to us. */
private void logClientLeft(ClientLeaveEvent e, String name) {
String reasonMsg = orEmpty(e.get("reasonmsg"));
String suffix = reasonMsg.isEmpty() ? "" : " (" + reasonMsg + ")";
switch (safeInt(e, "reasonid")) {
case REASON_TIMEOUT:
log(name + " dropped (ping timeout)");
break;
case REASON_SERVER_KICK:
log(name + " was kicked from the server by " + invokerName(e) + suffix);
break;
case REASON_BAN:
log(name + " was banned from the server by " + invokerName(e) + suffix);
break;
case REASON_CHANNEL_KICK:
log(name + " left: was kicked to channel \"" + channelName(e.getClientTargetId())
+ "\" by " + invokerName(e) + suffix);
break;
case REASON_MOVED:
log(name + " left, heading to channel \"" + channelName(e.getClientTargetId()) + "\"");
break;
case REASON_SWITCHED:
log(name + " left, switched to channel \"" + channelName(e.getClientTargetId()) + "\"");
break;
default:
log(name + " disconnected");
}
}
/** A client we can see moved between two channels we can see. */
private void logClientMoved(ClientMovedEvent e, String name, int fromChannel, int toChannel) {
String reasonMsg = orEmpty(e.get("reasonmsg"));
String suffix = reasonMsg.isEmpty() ? "" : " (" + reasonMsg + ")";
switch (safeInt(e, "reasonid")) {
case REASON_MOVED:
log(name + " was moved from channel \"" + channelName(fromChannel) + "\" to \""
+ channelName(toChannel) + "\" by " + invokerName(e));
break;
case REASON_CHANNEL_KICK:
log(name + " was kicked from channel \"" + channelName(fromChannel) + "\" to \""
+ channelName(toChannel) + "\" by " + invokerName(e) + suffix);
break;
default:
log(name + " switched from channel \"" + channelName(fromChannel) + "\" to \""
+ channelName(toChannel) + "\"");
}
}
// ---- who went where: TeamSpeak's reason ids ----
/** {@code reasonid} of a client view/move notification. */
private static final int REASON_SWITCHED = 0;
private static final int REASON_MOVED = 1;
private static final int REASON_TIMEOUT = 3;
private static final int REASON_CHANNEL_KICK = 4;
private static final int REASON_SERVER_KICK = 5;
private static final int REASON_BAN = 6;
/**
* A client became visible: either they just connected, or they moved in from a
* channel we could not see — TeamSpeak's "appears" case.
*/
private void announceClientEntered(ClientJoinEvent e) {
if (!connected) return;
int clientId = e.getClientId();
boolean current = inOwnChannel(e.getClientTargetId());
Map<String, String> vars = clientVars(clientId, e.getClientNickname());
switch (safeInt(e, "reasonid")) {
case REASON_MOVED:
sound(current ? SoundEvent.CLIENT_MOVED_TO_CURRENT_CHANNEL_APPEARS
: SoundEvent.CLIENT_MOVED_TO_OTHER_CHANNEL_APPEARS, vars);
break;
case REASON_CHANNEL_KICK:
sound(current ? SoundEvent.CLIENT_WAS_KICKED_FROM_CHANNEL_TO_CURRENT_CHANNEL_APPEARS
: SoundEvent.CLIENT_WAS_KICKED_FROM_CHANNEL_TO_OTHER_CHANNEL_APPEARS, vars);
break;
case REASON_SWITCHED:
sound(current ? SoundEvent.CLIENT_SWITCHED_TO_CURRENT_CHANNEL_APPEARS
: SoundEvent.CLIENT_SWITCHED_TO_OTHER_CHANNEL_APPEARS, vars);
break;
default:
sound(current ? SoundEvent.CLIENT_CONNECTION_CONNECTED_CURRENT_CHANNEL
: SoundEvent.CLIENT_CONNECTION_CONNECTED_SERVER, vars);
}
}
/** A client stopped being visible: they left the server, or moved out of sight. */
private void announceClientLeft(ClientLeaveEvent e) {
if (!connected) return;
int clientId = e.getClientId();
boolean current = inOwnChannel(e.getClientFromId());
Map<String, String> vars = clientVars(clientId, null);
switch (safeInt(e, "reasonid")) {
case REASON_TIMEOUT:
sound(current ? SoundEvent.CLIENT_CONNECTION_LOST_CONNECTION_CURRENT_CHANNEL
: SoundEvent.CLIENT_CONNECTION_LOST_CONNECTION_SERVER, vars);
break;
case REASON_SERVER_KICK:
sound(current ? SoundEvent.CLIENT_WAS_KICKED_FROM_SERVER_CURRENT_CHANNEL
: SoundEvent.CLIENT_WAS_KICKED_FROM_SERVER_SERVER, vars);
break;
case REASON_BAN:
sound(current ? SoundEvent.CLIENT_WAS_BANNED_CURRENT_CHANNEL
: SoundEvent.CLIENT_WAS_BANNED_SERVER, vars);
break;
case REASON_CHANNEL_KICK:
sound(current ? SoundEvent.CLIENT_WAS_KICKED_FROM_CHANNEL_FROM_CURRENT_CHANNEL_DISAPPEARS
: SoundEvent.CLIENT_WAS_KICKED_FROM_CHANNEL_TO_OTHER_CHANNEL_DISAPPEARS, vars);
break;
case REASON_MOVED:
sound(current ? SoundEvent.CLIENT_MOVED_FROM_CURRENT_CHANNEL_DISAPPEARS
: SoundEvent.CLIENT_MOVED_TO_OTHER_CHANNEL_DISAPPEARS, vars);
break;
case REASON_SWITCHED:
sound(current ? SoundEvent.CLIENT_SWITCHED_FROM_CURRENT_CHANNEL_DISAPPEARS
: SoundEvent.CLIENT_SWITCHED_TO_OTHER_CHANNEL_DISAPPEARS, vars);
break;
default:
sound(current ? SoundEvent.CLIENT_CONNECTION_DISCONNECTED_CURRENT_CHANNEL
: SoundEvent.CLIENT_CONNECTION_DISCONNECTED_SERVER, vars);
}
}
/** A client we can see moved between two channels we can see ("stays"). */
private void announceClientMoved(int reason, int clientId, int fromChannel, int toChannel) {
if (!connected) return;
boolean toCurrent = inOwnChannel(toChannel);
boolean fromCurrent = inOwnChannel(fromChannel);
Map<String, String> vars = clientVars(clientId, null);
switch (reason) {
case REASON_MOVED:
sound(toCurrent ? SoundEvent.CLIENT_MOVED_TO_CURRENT_CHANNEL_STAYS
: fromCurrent ? SoundEvent.CLIENT_MOVED_FROM_CURRENT_CHANNEL_STAYS
: SoundEvent.CLIENT_MOVED_TO_OTHER_CHANNEL_STAYS, vars);
break;
case REASON_CHANNEL_KICK:
sound(toCurrent ? SoundEvent.CLIENT_WAS_KICKED_FROM_CHANNEL_TO_CURRENT_CHANNEL_STAYS
: fromCurrent ? SoundEvent.CLIENT_WAS_KICKED_FROM_CHANNEL_FROM_CURRENT_CHANNEL_STAYS
: SoundEvent.CLIENT_WAS_KICKED_FROM_CHANNEL_TO_OTHER_CHANNEL_STAYS, vars);
break;
default:
sound(toCurrent ? SoundEvent.CLIENT_SWITCHED_TO_CURRENT_CHANNEL_STAYS
: fromCurrent ? SoundEvent.CLIENT_SWITCHED_FROM_CURRENT_CHANNEL_STAYS
: SoundEvent.CLIENT_SWITCHED_TO_OTHER_CHANNEL_STAYS, vars);
}
}
/** We changed channel ourselves, or somebody changed it for us. */
private void announceOwnMove(int reason, ClientMovedEvent e) {
if (!connected) return;
Map<String, String> vars = channelVars(e.getTargetChannelId(), e.get("invokername"));
switch (reason) {
case REASON_MOVED:
sound(SoundEvent.YOU_WERE_MOVED_TO_DIFFERENT_CHANNEL, vars);
break;
case REASON_CHANNEL_KICK:
sound(SoundEvent.YOU_WERE_KICKED_FROM_CHANNEL, vars);
break;
default:
sound(SoundEvent.YOU_SWITCHED_CHANNEL, vars);
}
}
/** We were removed from the server (kick or ban); the disconnect follows. */
private void announceOwnRemoval(int reason, ClientLeaveEvent e) {
if (!connected) return;
Map<String, String> vars = SoundNotifier.vars(
"servername", model.getServerName(),
"clientname", orEmpty(e.get("invokername")),
"reason", orEmpty(e.get("reasonmsg")));
if (reason == REASON_SERVER_KICK) {
sound(SoundEvent.YOU_WERE_KICKED_FROM_SERVER, vars);
} else if (reason == REASON_BAN) {
sound(SoundEvent.YOU_WERE_BANNED, vars);
}
}
@Override
public void onClientChanged(ClientUpdatedEvent e) {
ClientEntry c = model.getClient(e.getClientId());
if (c == null) return;
boolean renamed = has(e, "client_nickname") && !e.get("client_nickname").equals(c.nickname);
String oldName = c.nickname;
if (renamed) c.nickname = e.get("client_nickname");
if (has(e, "client_input_muted")) c.inputMuted = e.getBoolean("client_input_muted");
if (has(e, "client_output_muted")) c.outputMuted = e.getBoolean("client_output_muted");
if (has(e, "client_input_hardware")) c.inputHardware = e.getBoolean("client_input_hardware");
if (has(e, "client_output_hardware")) c.outputHardware = e.getBoolean("client_output_hardware");
if (has(e, "client_away")) {
c.away = e.getBoolean("client_away");
// Both fields travel together, so an absent message here means "no message"
// — which `has` cannot tell from "not reported".
c.awayMessage = c.away ? orEmpty(e.get("client_away_message")) : "";
} else if (has(e, "client_away_message")) {
c.awayMessage = e.get("client_away_message");
}
if (has(e, "client_talk_power")) c.talkPower = e.getInt("client_talk_power");
if (has(e, "client_is_channel_commander"))
c.channelCommander = e.getBoolean("client_is_channel_commander");
announceClientUpdate(e, c, renamed, oldName);
ui.onModelChanged();
}
/** Renames, talk-power changes and recording flags all arrive as a client update. */
private void announceClientUpdate(ClientUpdatedEvent e, ClientEntry c, boolean renamed, String oldName) {
if (!connected) return;
boolean self = c.id == selfClientId;
Map<String, String> vars = clientVars(c.id, null);
if (renamed) {
sound(safeInt(e, "invokerid") == selfClientId
? SoundEvent.CLIENT_RENAMED_BY_YOU : SoundEvent.CLIENT_RENAMED_BY_OTHER, vars);
log(oldName + " is now known as " + c.nickname);
}
if (safeInt(e, "client_talk_request") > 0 && !self) {
sound(SoundEvent.CLIENT_REQUESTED_TALK_POWER, vars);
}
if (self && has(e, "client_is_talker")) {
sound(e.getBoolean("client_is_talker")
? SoundEvent.YOU_WERE_GRANTED_TALK_POWER : SoundEvent.YOU_WERE_REVOKED_TALK_POWER, vars);
}
if (!self && has(e, "client_is_recording")) {
boolean recording = e.getBoolean("client_is_recording");
if (!recording) {
sound(SoundEvent.CLIENT_RECORDING_STOP, vars);
} else {
sound(inOwnChannel(c.channelId)
? SoundEvent.CLIENT_RECORDING_IN_CHANNEL : SoundEvent.CLIENT_RECORDING_START, vars);
}
}
}
@Override
public void onChannelCreate(ChannelCreateEvent e) {
int cid = e.getChannelId();
String name = e.get("channel_name");
int pid = safeInt(e, "cpid");
if (pid == 0) pid = safeInt(e, "pid");
int order = safeInt(e, "channel_order");
ChannelNode node = model.putChannel(cid, name, pid, order);
long icon = safeLong(e, "channel_icon_id");
if (icon != 0) node.iconId = icon;
model.relinkChannel(cid, pid, order);
if (connected) {
sound(byInvoker(e, SoundEvent.CHANNEL_CREATED_BY_YOU, SoundEvent.CHANNEL_CREATED_BY_OTHER,
SoundEvent.CHANNEL_CREATED_BY_OTHER), channelVars(cid, e.get("invokername")));
log("Channel \"" + name + "\" was created by " + invokerName(e));
}
ui.onModelChanged();
}
@Override
public void onChannelDeleted(ChannelDeletedEvent e) {
int cid = e.getChannelId();
if (connected) {
sound(byInvoker(e, SoundEvent.CHANNEL_DELETED_BY_YOU, SoundEvent.CHANNEL_DELETED_BY_OTHER,
SoundEvent.CHANNEL_DELETED_BY_SERVER), channelVars(cid, e.get("invokername")));
log("Channel \"" + channelName(cid) + "\" was deleted by " + invokerName(e));
}
model.removeChannel(cid);
ui.onModelChanged();
}
@Override
public void onChannelEdit(ChannelEditedEvent e) {
ChannelNode ch = model.getChannel(safeInt(e, "cid"));
if (ch != null) {
if (has(e, "channel_name")) ch.name = e.get("channel_name");
if (has(e, "channel_order")) ch.order = e.getInt("channel_order");
if (has(e, "channel_icon_id")) ch.iconId = safeLong(e, "channel_icon_id");
if (connected) {
boolean current = inOwnChannel(ch.id);
sound(current
? byInvoker(e, SoundEvent.CHANNEL_EDITED_CURRENT_BY_YOU,
SoundEvent.CHANNEL_EDITED_CURRENT_BY_OTHER, SoundEvent.CHANNEL_EDITED_CURRENT_BY_OTHER)
: byInvoker(e, SoundEvent.CHANNEL_EDITED_OTHER_BY_YOU,
SoundEvent.CHANNEL_EDITED_OTHER_BY_OTHER, SoundEvent.CHANNEL_EDITED_OTHER_BY_SERVER),
channelVars(ch.id, e.get("invokername")));
log("Channel \"" + ch.name + "\" was edited by " + invokerName(e));
}
ui.onModelChanged();
}
}
@Override
public void onChannelMoved(ChannelMovedEvent e) {
ChannelNode ch = model.getChannel(safeInt(e, "cid"));
if (ch != null) {
int parent = has(e, "cpid") ? e.getInt("cpid") : ch.parentId;
int order = has(e, "order") ? e.getInt("order") : ch.order;
model.relinkChannel(ch.id, parent, order);
if (connected) {
sound(byInvoker(e, SoundEvent.CHANNEL_MOVED_BY_YOU, SoundEvent.CHANNEL_MOVED_BY_OTHER,
SoundEvent.CHANNEL_MOVED_BY_OTHER), channelVars(ch.id, e.get("invokername")));
log("Channel \"" + ch.name + "\" was moved by " + invokerName(e));
}
ui.onModelChanged();
}
}
@Override
public void onChannelSubscribed(ChannelSubscribedEvent e) {
setSubscribed(safeInt(e, "cid"), true);
}
@Override
public void onChannelUnsubscribed(ChannelUnsubscribedEvent e) {
setSubscribed(safeInt(e, "cid"), false);
}
private void setSubscribed(int cid, boolean subscribed) {
ChannelNode ch = model.getChannel(cid);
if (ch == null || ch.subscribed == subscribed) return;
ch.subscribed = subscribed;
ui.onModelChanged();
}
@Override
public void onServerEdit(ServerEditedEvent e) {
if (has(e, "virtualserver_name")) model.setServerName(e.get("virtualserver_name"));
if (connected) {
sound(byInvoker(e, SoundEvent.SERVER_EDITED_BY_YOU, SoundEvent.SERVER_EDITED_BY_OTHER,
SoundEvent.SERVER_EDITED_BY_OTHER), serverVars());
}
ui.onModelChanged();
}
@Override
public void onServerGroupClientAdded(ServerGroupClientAddedEvent e) {
boolean self = e.getClientId() == selfClientId;
sound(self
? byInvoker(e, SoundEvent.YOU_SERVERGROUP_ADDED_BY_USER, SoundEvent.YOU_SERVERGROUP_ADDED_BY_USER,
SoundEvent.YOU_SERVERGROUP_ADDED_BY_SERVER)
: byInvoker(e, SoundEvent.CLIENT_SERVERGROUP_ADDED_BY_USER,
SoundEvent.CLIENT_SERVERGROUP_ADDED_BY_USER, SoundEvent.CLIENT_SERVERGROUP_ADDED_BY_SERVER),
groupVars(e.getClientId(), e.getName()));
log(clientLogName(e.getClientId()) + " was added to server group \"" + e.getName()
+ "\" by " + invokerName(e) + ".");
}
@Override
public void onServerGroupClientDeleted(ServerGroupClientDeletedEvent e) {
int clientId = safeInt(e, "clid");
boolean self = clientId == selfClientId;
sound(self
? byInvoker(e, SoundEvent.YOU_SERVERGROUP_REMOVED_BY_USER,
SoundEvent.YOU_SERVERGROUP_REMOVED_BY_USER, SoundEvent.YOU_SERVERGROUP_REMOVED_BY_SERVER)
: byInvoker(e, SoundEvent.CLIENT_SERVERGROUP_REMOVED_BY_USER,
SoundEvent.CLIENT_SERVERGROUP_REMOVED_BY_USER, SoundEvent.CLIENT_SERVERGROUP_REMOVED_BY_SERVER),
groupVars(clientId, e.get("name")));
log(clientLogName(clientId) + " was removed from server group \"" + orEmpty(e.get("name"))
+ "\" by " + invokerName(e) + ".");
}
@Override
public void onClientChannelGroupChanged(ClientChannelGroupChangedEvent e) {
ClientEntry c = model.getClient(e.getClientId());
if (c != null) c.channelGroupId = e.getChannelGroupId();
boolean self = e.getClientId() == selfClientId;
String groupName = model.channelGroupName(e.getChannelGroupId());
sound(self
? byInvoker(e, SoundEvent.YOU_CHANNELGROUP_CHANGED_BY_USER,
SoundEvent.YOU_CHANNELGROUP_CHANGED_BY_USER, SoundEvent.YOU_CHANNELGROUP_CHANGED_BY_SERVER)
: byInvoker(e, SoundEvent.CLIENT_CHANNELGROUP_CHANGED_BY_USER,
SoundEvent.CLIENT_CHANNELGROUP_CHANGED_BY_USER, SoundEvent.CLIENT_CHANNELGROUP_CHANGED_BY_SERVER),
groupVars(e.getClientId(), groupName));
log("Channel group \"" + orEmpty(groupName) + "\" was assigned to " + clientLogName(e.getClientId())
+ " by " + invokerName(e) + ".");
ui.onModelChanged();
}
/** A client's nickname for a log line, falling back to its id once it has left. */
private String clientLogName(int clientId) {
ClientEntry c = model.getClient(clientId);
return c != null ? c.nickname : "Client " + clientId;
}
/** Picks the event variant matching who caused the change: us, another client, or the server. */
private SoundEvent byInvoker(BaseEvent e, SoundEvent byYou, SoundEvent byOther, SoundEvent byServer) {
int invoker = safeInt(e, "invokerid");
if (invoker == selfClientId && invoker != 0) return byYou;
return invoker == 0 ? byServer : byOther;
}
private Map<String, String> groupVars(int clientId, String groupName) {
Map<String, String> vars = clientVars(clientId, null);
vars.put("groupname", orEmpty(groupName));
return vars;
}
@Override
public void onChannelList(ChannelListEvent e) {
// Incremental channel arriving during connect.
int cid = e.getChannelId();
String name = e.get("channel_name");
int pid = safeInt(e, "cpid");
int order = safeInt(e, "channel_order");
ChannelNode node = model.putChannel(cid, name, pid, order);
long icon = safeLong(e, "channel_icon_id");
if (icon != 0) node.iconId = icon;
}
@Override
public void onServerGroupList(ServerGroupListEvent e) {
int id = safeInt(e, "sgid");
if (id > 0) model.putServerGroup(toGroup(e, id));
}
@Override
public void onChannelGroupList(ChannelGroupListEvent e) {
int id = safeInt(e, "cgid");
if (id > 0) model.putChannelGroup(toGroup(e, id));
}
private static Group toGroup(BaseEvent e, int id) {
return new Group(id, e.get("name"), safeLong(e, "iconid"), safeInt(e, "sortid"));
}
/** Fetches a channel's description (and topic) on demand, then notifies the UI. */
public void requestChannelInfo(int channelId) {
new Thread(() -> {
@@ -1405,262 +915,17 @@ public final class TeamspeakConnection implements TS3Listener {
*/
public void requestConnectionInfo(int clientId, Consumer<ConnectionStats> callback) {
if (client == null || !connected) return;
if (clientId == selfClientId) {
new Thread(() -> callback.accept(buildLocalStats()), "ts3j-conninfo-self").start();
} else {
new Thread(() -> requestRemoteConnInfo(clientId, callback), "ts3j-conninfo").start();
}
stats.request(clientId, callback);
}
/** Builds a live snapshot of the local client's connection from its own counters. */
private ConnectionStats buildLocalStats() {
ConnectionStats s = new ConnectionStats();
s.clientId = selfClientId;
s.self = true;
s.live = true;
s.packetLoss = 0; // the local client has no server->client loss figure
ClientEntry self = model.getClient(selfClientId);
if (self != null) {
s.nickname = self.nickname;
s.version = self.version;
s.platform = self.platform;
s.idleTimeMs = self.idleTimeMs;
}
long connectedAt = connectedAtMs;
if (connectedAt > 0) s.connectedTimeMs = System.currentTimeMillis() - connectedAt;
LocalTeamspeakClientSocket c = client;
if (c == null) return s;
try {
Pair<Double, Double> ping = c.getPing();
s.pingMs = ping.getKey() * 1000.0;
s.pingDeviationMs = ping.getValue() * 1000.0;
} catch (Exception ignored) {
// ping unavailable; leave as unknown
}
long pSent = 0, pRecv = 0, bSent = 0, bRecv = 0;
long bwSs = 0, bwRs = 0, bwSm = 0, bwRm = 0;
for (PacketKind kind : PacketKind.values()) {
PacketStatistics st = c.getStatistics(kind);
ConnectionStats.KindStats ks = new ConnectionStats.KindStats(mapKind(kind));
ks.packetLoss = 0; // the local client cannot measure its own server->client loss
ks.packetsSent = st.getSentPackets();
ks.packetsReceived = st.getReceivedPackets();
ks.bytesSent = st.getSentBytes();
ks.bytesReceived = st.getReceivedBytes();
ks.bandwidthSentLastSecond = st.getSentBytesLastSecond();
ks.bandwidthReceivedLastSecond = st.getReceivedBytesLastSecond();
ks.bandwidthSentLastMinute = st.getSentBytesLastMinute();
ks.bandwidthReceivedLastMinute = st.getReceivedBytesLastMinute();
s.perKind.add(ks);
pSent += ks.packetsSent;
pRecv += ks.packetsReceived;
bSent += ks.bytesSent;
bRecv += ks.bytesReceived;
bwSs += ks.bandwidthSentLastSecond;
bwRs += ks.bandwidthReceivedLastSecond;
bwSm += ks.bandwidthSentLastMinute;
bwRm += ks.bandwidthReceivedLastMinute;
}
s.packetsSentTotal = pSent;
s.packetsReceivedTotal = pRecv;
s.bytesSentTotal = bSent;
s.bytesReceivedTotal = bRecv;
s.bandwidthSentLastSecond = bwSs;
s.bandwidthReceivedLastSecond = bwRs;
s.bandwidthSentLastMinute = bwSm;
s.bandwidthReceivedLastMinute = bwRm;
return s;
}
// ---- events not handled by ConnectionEventHandler ----
/**
* 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 #onUnknownEvent}. If the report does not arrive shortly (e.g. the
* server withholds it), the clientinfo-only snapshot is delivered instead.
* Kept here rather than in {@link ConnectionEventHandler}: teardown needs
* {@link #safeCleanup()} and the {@code connected}/announced flags this class
* already owns, so routing it through the event handler would only add an
* extra hop back into private lifecycle state.
*/
private void requestRemoteConnInfo(int clientId, Consumer<ConnectionStats> callback) {
ConnectionStats s = new ConnectionStats();
s.clientId = clientId;
ClientEntry entry = model.getClient(clientId);
if (entry != null) s.nickname = entry.nickname;
try {
Client c = client.getClientInfo(clientId);
if (c != null) {
s.version = orEmpty(c.getVersion());
s.platform = orEmpty(c.getPlatform());
s.ip = orEmpty(c.getIp());
s.idleTimeMs = c.getIdleTime();
if (entry == null) s.nickname = orEmpty(c.getNickname());
applyConnectionFields(s, c.getMap());
}
} catch (Exception ignored) {
// clientinfo may be permission-restricted; continue with what we have
}
PendingConnInfo pending = new PendingConnInfo(callback, s);
pendingConnInfo.put(clientId, pending);
boolean sent = false;
try {
SingleCommand cmd = new SingleCommand("getconnectioninfo", ProtocolRole.CLIENT,
new CommandSingleParameter("clid", Integer.toString(clientId)));
client.executeCommand(cmd).complete();
sent = true;
} catch (Exception ignored) {
// command failed; fall back to the clientinfo snapshot below
}
if (sent) {
try {
Thread.sleep(700); // give notifyconnectioninfo a chance to arrive
} catch (InterruptedException ignored) {
Thread.currentThread().interrupt();
}
}
// If the report already arrived, onUnknownEvent removed and delivered it.
if (pendingConnInfo.remove(clientId, pending)) {
callback.accept(s);
}
}
/** Parses TeamSpeak {@code connection_*} fields from a command map into {@code s}. */
private static void applyConnectionFields(ConnectionStats s, Map<String, String> m) {
double ping = parseDouble(m.get("connection_ping"));
if (ping >= 0) s.pingMs = ping;
double dev = parseDouble(m.get("connection_ping_deviation"));
if (dev >= 0) s.pingDeviationMs = dev;
double loss = parseDouble(m.get("connection_packetloss_total"));
if (loss < 0) loss = parseDouble(m.get("connection_server2client_packetloss_total"));
if (loss >= 0) s.packetLoss = loss;
long connected = parseLong(m.get("connection_connected_time"));
if (connected >= 0) s.connectedTimeMs = connected;
String ip = m.get("connection_client_ip");
if (ip != null && !ip.isEmpty()) s.ip = ip;
s.packetsSentTotal = pick(s.packetsSentTotal, m.get("connection_packets_sent_total"));
s.packetsReceivedTotal = pick(s.packetsReceivedTotal, m.get("connection_packets_received_total"));
s.bytesSentTotal = pick(s.bytesSentTotal, m.get("connection_bytes_sent_total"));
s.bytesReceivedTotal = pick(s.bytesReceivedTotal, m.get("connection_bytes_received_total"));
s.bandwidthSentLastSecond =
pick(s.bandwidthSentLastSecond, m.get("connection_bandwidth_sent_last_second_total"));
s.bandwidthReceivedLastSecond =
pick(s.bandwidthReceivedLastSecond, m.get("connection_bandwidth_received_last_second_total"));
s.bandwidthSentLastMinute =
pick(s.bandwidthSentLastMinute, m.get("connection_bandwidth_sent_last_minute_total"));
s.bandwidthReceivedLastMinute =
pick(s.bandwidthReceivedLastMinute, m.get("connection_bandwidth_received_last_minute_total"));
s.filetransferBandwidthSent =
pick(s.filetransferBandwidthSent, m.get("connection_filetransfer_bandwidth_sent"));
s.filetransferBandwidthReceived =
pick(s.filetransferBandwidthReceived, m.get("connection_filetransfer_bandwidth_received"));
applyPerKindFields(s, m);
}
/**
* Parses the per-category {@code connection_*_<kind>} fields (as sent in a
* {@code notifyconnectioninfo} report) into {@code s}, merging into any
* existing rows. Categories absent from the map are left untouched.
*/
private static void applyPerKindFields(ConnectionStats s, Map<String, String> m) {
for (ConnectionStats.Kind kind : ConnectionStats.Kind.values()) {
String suffix = kind.name().toLowerCase(java.util.Locale.ROOT); // keepalive/control/speech
String probe = m.get("connection_packets_sent_" + suffix);
String probe2 = m.get("connection_server2client_packetloss_" + suffix);
if (probe == null && probe2 == null) continue; // this category not reported
ConnectionStats.KindStats ks = s.getOrCreateKind(kind);
ks.packetsSent = pick(ks.packetsSent, m.get("connection_packets_sent_" + suffix));
ks.packetsReceived = pick(ks.packetsReceived, m.get("connection_packets_received_" + suffix));
ks.bytesSent = pick(ks.bytesSent, m.get("connection_bytes_sent_" + suffix));
ks.bytesReceived = pick(ks.bytesReceived, m.get("connection_bytes_received_" + suffix));
ks.bandwidthSentLastSecond =
pick(ks.bandwidthSentLastSecond, m.get("connection_bandwidth_sent_last_second_" + suffix));
ks.bandwidthReceivedLastSecond =
pick(ks.bandwidthReceivedLastSecond, m.get("connection_bandwidth_received_last_second_" + suffix));
ks.bandwidthSentLastMinute =
pick(ks.bandwidthSentLastMinute, m.get("connection_bandwidth_sent_last_minute_" + suffix));
ks.bandwidthReceivedLastMinute =
pick(ks.bandwidthReceivedLastMinute, m.get("connection_bandwidth_received_last_minute_" + suffix));
double kloss = parseDouble(m.get("connection_server2client_packetloss_" + suffix));
if (kloss >= 0) ks.packetLoss = kloss;
}
}
private static long pick(long current, String value) {
long v = parseLong(value);
return v >= 0 ? v : current;
}
private static ConnectionStats.Kind mapKind(PacketKind kind) {
switch (kind) {
case KEEPALIVE:
return ConnectionStats.Kind.KEEPALIVE;
case SPEECH:
return ConnectionStats.Kind.SPEECH;
default:
return ConnectionStats.Kind.CONTROL;
}
}
@Override
public void onUnknownEvent(UnknownTeamspeakEvent e) {
if (!"notifyconnectioninfo".equals(e.getCommand())) return;
int clid = safeInt(e, "clid");
PendingConnInfo pending = pendingConnInfo.remove(clid);
if (pending == null) return;
applyConnectionFields(pending.stats, e.getMap());
pending.callback.accept(pending.stats);
}
/** Callback + accumulating snapshot for an in-flight {@code getconnectioninfo}. */
private static final class PendingConnInfo {
final Consumer<ConnectionStats> callback;
final ConnectionStats stats;
PendingConnInfo(Consumer<ConnectionStats> callback, ConnectionStats stats) {
this.callback = callback;
this.stats = stats;
}
}
@Override
public void onTextMessage(TextMessageEvent e) {
if (e.getInvokerId() == selfClientId) return; // don't echo our own
ConnectionListener.ChatScope scope;
SoundEvent notification;
switch (e.getTargetMode()) {
case CLIENT:
scope = ConnectionListener.ChatScope.PRIVATE;
notification = SoundEvent.CHAT_RECEIVED_MESSAGE_CLIENT;
break;
case CHANNEL:
scope = ConnectionListener.ChatScope.CHANNEL;
notification = SoundEvent.CHAT_RECEIVED_MESSAGE_CHANNEL;
break;
default:
scope = ConnectionListener.ChatScope.SERVER;
notification = SoundEvent.CHAT_RECEIVED_MESSAGE_SERVER;
break;
}
sound(notification, clientVars(e.getInvokerId(), e.getInvokerName()));
ui.onChat(scope, e.getInvokerId(), e.getInvokerName(), e.getMessage());
}
@Override
public void onClientPoke(ClientPokeEvent e) {
sound(SoundEvent.OTHER_RECEIVED_POKE, clientVars(e.getInvokerId(), e.getInvokerName()));
ui.onPoke(orEmpty(e.getInvokerName()), orEmpty(e.get("msg")));
}
@Override
public void onDisconnected(DisconnectedEvent e) {
connected = false;
@@ -1674,33 +939,29 @@ public final class TeamspeakConnection implements TS3Listener {
/**
* Plays the sound pack's entry for an action. Muting is passed along so the
* notifier can drop everything the user did not mark as important.
* notifier can drop everything the user did not mark as important. Package-private:
* also called by {@link ConnectionEventHandler}.
*/
private void sound(SoundEvent event) {
void sound(SoundEvent event) {
sounds.fire(event, deafened);
}
private void sound(SoundEvent event, Map<String, String> variables) {
void sound(SoundEvent event, Map<String, String> variables) {
sounds.fire(event, deafened, variables);
}
/** Records a line in the server tab's log, the way native TS3 reports server activity. */
private void log(String message) {
void log(String message) {
if (connected) ui.onServerLog(message);
}
private String channelName(int channelId) {
String channelName(int channelId) {
ChannelNode ch = model.getChannel(channelId);
return ch != null ? ch.name : "channel #" + channelId;
}
private static String invokerName(BaseEvent e) {
String name = orEmpty(e.get("invokername"));
return name.isEmpty() ? "the server" : name;
}
/** The placeholder values a pack may reference for an action involving a client. */
private Map<String, String> clientVars(int clientId, String fallbackName) {
Map<String, String> clientVars(int clientId, String fallbackName) {
ClientEntry c = model.getClient(clientId);
Map<String, String> vars = SoundNotifier.vars(
"servername", model.getServerName(),
@@ -1713,7 +974,7 @@ public final class TeamspeakConnection implements TS3Listener {
return vars;
}
private Map<String, String> channelVars(int channelId, String invokerName) {
Map<String, String> channelVars(int channelId, String invokerName) {
ChannelNode ch = model.getChannel(channelId);
return SoundNotifier.vars(
"servername", model.getServerName(),
@@ -1721,7 +982,7 @@ public final class TeamspeakConnection implements TS3Listener {
"clientname", orEmpty(invokerName));
}
private Map<String, String> serverVars() {
Map<String, String> serverVars() {
return SoundNotifier.vars("servername", model.getServerName());
}
@@ -1739,15 +1000,17 @@ public final class TeamspeakConnection implements TS3Listener {
return m.contains("insufficient") || m.contains("permission");
}
/** Whether the client is in the channel we are in ourselves. */
private boolean inOwnChannel(int channelId) {
/** Whether the client is in the channel we are in ourselves. Package-private: also
* used by {@link ConnectionEventHandler}. */
boolean inOwnChannel(int channelId) {
ClientEntry self = model.getClient(selfClientId);
return self != null && self.channelId == channelId;
}
// ---- helpers ----
private static long safeLong(BaseEvent e, String key) {
/** Package-private: also used by {@link ConnectionEventHandler}. */
static long safeLong(BaseEvent e, String key) {
return safeLong(e.get(key));
}
@@ -1760,65 +1023,11 @@ public final class TeamspeakConnection implements TS3Listener {
}
}
/**
* Whether the event actually carries a field. ts3j answers a missing key with an
* empty string rather than null, so a plain null check is always true — and a
* partial update (say, someone muting) would otherwise look like it reported
* every other field as well.
*/
private static boolean has(BaseEvent e, String key) {
String value = e.get(key);
return value != null && !value.isEmpty();
}
private static int safeInt(BaseEvent e, String key) {
try {
String v = e.get(key);
return v == null ? 0 : Integer.parseInt(v.trim());
} catch (Exception ex) {
return 0;
}
}
private static String orEmpty(String s) {
/** Package-private: also used by {@link ConnectionEventHandler} and {@link ConnectionStatsCollector}. */
static String orEmpty(String s) {
return s == null ? "" : s;
}
/** Parses a long, returning -1 for null/blank/non-numeric input. */
private static long parseLong(String s) {
if (s == null || s.isEmpty()) return -1;
try {
return Long.parseLong(s.trim());
} catch (NumberFormatException e) {
return -1;
}
}
/** Parses a double, returning -1 for null/blank/non-numeric input. */
private static double parseDouble(String s) {
if (s == null || s.isEmpty()) return -1;
try {
return Double.parseDouble(s.trim());
} catch (NumberFormatException e) {
return -1;
}
}
/** Parses a comma-separated id list (e.g. server groups "6,12,15"). */
private static int[] parseIntList(String csv) {
if (csv == null || csv.isEmpty()) return new int[0];
String[] parts = csv.split(",");
int[] out = new int[parts.length];
int n = 0;
for (String p : parts) {
try {
out[n++] = Integer.parseInt(p.trim());
} catch (NumberFormatException ignored) {
}
}
return n == parts.length ? out : java.util.Arrays.copyOf(out, n);
}
private static String rootMessage(Throwable t) {
Throwable r = t;
while (r.getCause() != null && r.getCause() != r) r = r.getCause();

View File

@@ -9,7 +9,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
/**
* Pins the ts3j behaviour {@code TeamspeakConnection.has(...)} exists for: a field
* Pins the ts3j behaviour {@code ConnectionEventHandler.has(...)} exists for: a field
* the event never carried reads back as an empty string, so a null check would treat
* every partial update (someone muting, say) as if it reported every other field too.
*/