Kick, ban and set groups from the Android client sheet, with a ban list

The client sheet offers kicking from the channel or the server (asking to
confirm, with an optional reason), banning with a reason and duration, and
the client's server groups and channel group. The server sheet opens the ban
list: search, own bans only, and adding, editing and deleting bans, with the
reason presets the desktop keeps.

Kick and ban powers, like move power, never come with the needed
permissions, so core now asks for all four in one piped permget. The ban
permission checks, a ban's summary, the duration units, the group choices
for a client and the kick reason limit move into core, and Swing uses them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 21:27:29 +00:00
parent 76f6a9feed
commit cf1db61a4a
22 changed files with 887 additions and 116 deletions

View File

@@ -508,7 +508,7 @@ final class ConnectionEventHandler implements TS3Listener {
ClientEntry c = conn.getModel().getClient(e.getClientId());
if (c != null) c.addServerGroup(e.getServerGroupId());
boolean self = e.getClientId() == conn.getSelfClientId();
if (self) conn.requestMovePower();
if (self) conn.requestSelfPowers();
conn.sound(self
? byInvoker(e, SoundEvent.YOU_SERVERGROUP_ADDED_BY_USER, SoundEvent.YOU_SERVERGROUP_ADDED_BY_USER,
SoundEvent.YOU_SERVERGROUP_ADDED_BY_SERVER)
@@ -526,7 +526,7 @@ final class ConnectionEventHandler implements TS3Listener {
ClientEntry c = conn.getModel().getClient(clientId);
if (c != null) c.removeServerGroup(e.getServerGroupId());
boolean self = clientId == conn.getSelfClientId();
if (self) conn.requestMovePower();
if (self) conn.requestSelfPowers();
conn.sound(self
? byInvoker(e, SoundEvent.YOU_SERVERGROUP_REMOVED_BY_USER,
SoundEvent.YOU_SERVERGROUP_REMOVED_BY_USER, SoundEvent.YOU_SERVERGROUP_REMOVED_BY_SERVER)
@@ -543,7 +543,7 @@ final class ConnectionEventHandler implements TS3Listener {
ClientEntry c = conn.getModel().getClient(e.getClientId());
if (c != null) c.channelGroupId = e.getChannelGroupId();
boolean self = e.getClientId() == conn.getSelfClientId();
if (self) conn.requestMovePower();
if (self) conn.requestSelfPowers();
String groupName = conn.getModel().channelGroupName(e.getChannelGroupId());
conn.sound(self
? byInvoker(e, SoundEvent.YOU_CHANNELGROUP_CHANGED_BY_USER,

View File

@@ -0,0 +1,5 @@
package com.ts3client.net;
/** A group as offered for one client: whether they are in it, and whether we may change that. */
public record GroupChoice(Group group, boolean assigned, boolean allowed) {
}

View File

@@ -1,6 +1,9 @@
package com.ts3client.net;
import com.ts3client.net.ban.BanEntry;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator;
import java.util.HashMap;
import java.util.LinkedHashMap;
@@ -32,8 +35,8 @@ public final class ServerModel {
private int nextPermissionId = FIRST_PERMISSION_ID;
/** Permission id -> the local client's resolved value, from {@code notifyclientneededpermissions}. */
private final Map<Integer, Integer> selfPermissionValues = new LinkedHashMap<>();
/** The local client's {@code i_client_move_power}, which the needed permissions leave out; 0 until known. */
private int selfMovePower;
/** The local client's {@link #SELF_POWERS}, which the needed permissions leave out; absent until known. */
private final Map<String, Integer> selfPowers = new HashMap<>();
private String serverName = "TeamSpeak Server";
/** The {@code virtualserver_*} properties from {@code initserver}, kept up to date by server edits. */
private final Map<String, String> serverProperties = new HashMap<>();
@@ -49,7 +52,7 @@ public final class ServerModel {
permissionIds.clear();
nextPermissionId = FIRST_PERMISSION_ID;
selfPermissionValues.clear();
selfMovePower = 0;
selfPowers.clear();
serverProperties.clear();
defaultChannelGroupId = 0;
}
@@ -157,16 +160,56 @@ public final class ServerModel {
return value == null ? 0 : value;
}
public synchronized void setSelfMovePower(int power) {
selfMovePower = power;
/**
* The powers {@code notifyclientneededpermissions} never carries, not even for an admin;
* they have to be asked for with {@code permget}.
*/
public static final List<String> SELF_POWERS = List.of("i_client_move_power",
"i_client_kick_from_channel_power", "i_client_kick_from_server_power", "i_client_ban_power");
/** Takes on the answer to asking for {@link #SELF_POWERS}; those it leaves out count as 0. */
public synchronized void setSelfPowers(Map<String, Integer> powers) {
selfPowers.clear();
selfPowers.putAll(powers);
}
public synchronized int selfMovePower() {
return selfMovePower;
/** One of {@link #SELF_POWERS}; 0 until known, or when the server would not tell. */
public synchronized int selfPower(String name) {
return selfPowers.getOrDefault(name, 0);
}
// ---- what the local client may do ----
/**
* Whether we hold a permission. Until the server has named its permissions nothing can
* be resolved, so everything is offered and the server is left to refuse.
*/
public synchronized boolean permits(String permission) {
return !hasPermissionNames() || selfPermissionValue(permission) > 0;
}
/**
* Whether we may delete all of {@code bans}: any ban with {@code b_client_ban_delete},
* only our own with {@code b_client_ban_delete_own}.
*
* @param selfUid our unique identifier, which is how a ban names its creator
*/
public synchronized boolean canDeleteBans(Collection<BanEntry> bans, String selfUid) {
if (permits("b_client_ban_delete")) return true;
if (!permits("b_client_ban_delete_own")) return false;
return bans.stream().allMatch(b -> b.createdBy(selfUid));
}
/** Whether we may rewrite {@code ban}: that is deleting it and creating its replacement. */
public synchronized boolean canEditBan(BanEntry ban, String selfUid) {
return permits("b_client_ban_create") && canDeleteBans(List.of(ban), selfUid);
}
/** {@code i_client_ban_max_bantime}: the longest ban we may hand out in seconds; 0 or -1 for no limit. */
public synchronized long maxBanSeconds() {
return selfPermissionValue("i_client_ban_max_bantime");
}
/**
* Whether the local client has enough power to assign {@code group}, or to take it
* away when {@code assigned}: the add power always gates it, and the remove power
@@ -180,6 +223,26 @@ public final class ServerModel {
selfPermissionValue("i_group_member_remove_power")));
}
/** Every regular server group, with whether {@code client} is in it and whether we may change that. */
public synchronized List<GroupChoice> serverGroupChoices(ClientEntry client) {
List<GroupChoice> out = new ArrayList<>();
for (Group g : allServerGroups()) {
boolean assigned = client.inServerGroup(g.id);
out.add(new GroupChoice(g, assigned, canToggleGroup(g, assigned)));
}
return out;
}
/** Every regular channel group, with whether it is {@code client}'s and whether we may make it so. */
public synchronized List<GroupChoice> channelGroupChoices(ClientEntry client) {
List<GroupChoice> out = new ArrayList<>();
for (Group g : allChannelGroups()) {
boolean assigned = client.channelGroupId == g.id;
out.add(new GroupChoice(g, assigned, canToggleGroup(g, assigned)));
}
return out;
}
private static boolean hasPower(int needed, int own) {
if (own == -1) return true;
if (needed == -1) return false;

View File

@@ -37,6 +37,7 @@ import java.net.InetSocketAddress;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@@ -57,6 +58,9 @@ public final class TeamspeakConnection implements TS3Listener {
/** Upper bound for a group icon; anything larger is not an icon. Also used by {@link ChannelAdmin}. */
static final int MAX_ICON_BYTES = 1024 * 1024;
/** The longest reason the protocol carries with a kick. */
public static final int KICK_REASON_LIMIT = 40;
/** The protocol's reason ids for the two flavours of <b>clientkick</b>. */
private static final int REASON_KICK_CHANNEL = 4;
private static final int REASON_KICK_SERVER = 5;
@@ -352,7 +356,7 @@ public final class TeamspeakConnection implements TS3Listener {
loadChatLogs();
ui.onConnected();
requestPermissionNames();
requestMovePower();
requestSelfPowers();
ui.onStatus("Retrieving channels…");
syncAll();
@@ -730,20 +734,25 @@ public final class TeamspeakConnection implements TS3Listener {
}
/**
* Asks for our own {@code i_client_move_power}, which {@code notifyclientneededpermissions}
* never carries. Asking needs {@code b_client_permissionoverview_own}; refused, we take it
* as none, which is what the groups that lack the one usually have of the other.
* Asks for our own {@link ServerModel#SELF_POWERS}, which {@code notifyclientneededpermissions}
* never carries. Asking needs {@code b_client_permissionoverview_own}; refused, we take them
* as none, which is what the groups that lack the one usually have of the others.
*/
void requestMovePower() {
void requestSelfPowers() {
List<SingleCommand> rows = new ArrayList<>();
for (String name : ServerModel.SELF_POWERS) {
rows.add(new SingleCommand("permget", ProtocolRole.CLIENT, new CommandSingleParameter("permsid", name)));
}
submit(sock -> {
int power = 0;
for (SingleCommand answer : sock.executeCommand(new SingleCommand("permget", ProtocolRole.CLIENT,
new CommandSingleParameter("permsid", "i_client_move_power"))).get()) {
String value = answer.toMap().get("permvalue");
if (value != null) power = Integer.parseInt(value);
Map<String, Integer> powers = new HashMap<>();
for (SingleCommand answer : sock.executeCommand(new MultiCommand("permget", ProtocolRole.CLIENT, rows)).get()) {
Map<String, String> fields = answer.toMap();
String name = fields.get("permsid");
String value = fields.get("permvalue");
if (name != null && value != null) powers.put(name, Integer.parseInt(value));
}
model.setSelfMovePower(power);
}, e -> model.setSelfMovePower(0));
model.setSelfPowers(powers);
}, e -> model.setSelfPowers(Map.of()));
}
/** Assigns or removes a server group for a client (by database id, as {@code servergroupaddclient} needs). */

View File

@@ -1,5 +1,7 @@
package com.ts3client.net.ban;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/** One entry of the server's ban list, as a {@code notifybanlist} event describes it. */
@@ -68,6 +70,16 @@ public final class BanEntry {
return uid != null && !uid.isEmpty() && uid.equals(invokerUid);
}
/** What the ban matches, as the official list sums it up: {@code name=…, ip=…, uid=… (last nickname), mytsid=…}. */
public String targets() {
List<String> parts = new ArrayList<>(4);
if (!name.isEmpty()) parts.add("name=" + name);
if (!ip.isEmpty()) parts.add("ip=" + ip);
if (!uid.isEmpty()) parts.add("uid=" + uid + (lastNickname.isEmpty() ? "" : " (" + lastNickname + ")"));
if (!mytsid.isEmpty()) parts.add("mytsid=" + mytsid);
return String.join(", ", parts);
}
/** The entry's targets and settings as a rule, which is what editing starts from. */
public BanRule toRule() {
return new BanRule(ip, name, uid, mytsid, durationSeconds, reason);

View File

@@ -0,0 +1,45 @@
package com.ts3client.net.ban;
/**
* The units a ban's length is given in, as the official client asks for it: an amount of
* seconds, minutes, hours or days, or permanent, which takes no amount.
*/
public enum BanUnit {
SECONDS("seconds", 1),
MINUTES("minutes", 60),
HOURS("hours", 3600),
DAYS("days", 86400),
PERMANENT("permanent", 0);
private final String label;
private final long seconds;
BanUnit(String label, long seconds) {
this.label = label;
this.seconds = seconds;
}
public String label() {
return label;
}
/** The length of {@code amount} of this unit in seconds; 0 when permanent. */
public long toSeconds(long amount) {
return amount * seconds;
}
/** The largest unit that divides {@code seconds} evenly; a length of 0 or less is permanent. */
public static BanUnit largestFor(long seconds) {
if (seconds <= 0) return PERMANENT;
for (int i = PERMANENT.ordinal() - 1; i > 0; i--) {
if (seconds % values()[i].seconds == 0) return values()[i];
}
return SECONDS;
}
/** How many of this unit {@code seconds} is; 0 when permanent. */
public long amountOf(long seconds) {
return this == PERMANENT ? 0 : seconds / this.seconds;
}
}

View File

@@ -373,28 +373,23 @@ public final class ServerSession {
// ---- permissions ----
/**
* Whether the server grants us {@code b_client_ban_list}. Until it has named its
* permissions nothing can be resolved, so the list is offered and the server left to refuse.
*/
/** Whether the server grants us {@code b_client_ban_list}; offered while unknown. */
public boolean canViewBanList() {
if (!conn.isConnected()) return false;
ServerModel model = conn.getModel();
return !model.hasPermissionNames() || model.selfPermissionValue("b_client_ban_list") > 0;
return permits("b_client_ban_list");
}
/** Whether the server lets us upload an avatar ({@code i_client_max_avatar_filesize}); offered while unknown. */
public boolean canSetAvatar() {
if (!conn.isConnected()) return false;
ServerModel model = conn.getModel();
return !model.hasPermissionNames() || model.selfPermissionValue("i_client_max_avatar_filesize") > 0;
return permits("i_client_max_avatar_filesize");
}
/** Whether the server lets us delete other clients' avatars; offered while unknown. */
public boolean canDeleteOtherAvatars() {
if (!conn.isConnected()) return false;
ServerModel model = conn.getModel();
return !model.hasPermissionNames() || model.selfPermissionValue("b_client_avatar_delete_other") > 0;
return permits("b_client_avatar_delete_other");
}
private boolean permits(String permission) {
return conn.isConnected() && conn.getModel().permits(permission);
}
/** Whether we publish an avatar on this server. */
@@ -404,12 +399,28 @@ public final class ServerSession {
return self != null && !self.avatarFlag.isEmpty();
}
/**
* Whether we have any {@code i_client_move_power}; whether it beats a client's needed
* move power only the server knows, so it is left to refuse.
*/
public boolean canMoveClients() {
return conn.isConnected() && conn.getModel().selfMovePower() != 0;
return hasPower("i_client_move_power");
}
public boolean canKickFromChannel() {
return hasPower("i_client_kick_from_channel_power");
}
public boolean canKickFromServer() {
return hasPower("i_client_kick_from_server_power");
}
public boolean canBanClients() {
return hasPower("i_client_ban_power");
}
/**
* Whether we have any of a power; whether it beats the client's needed power only the
* server knows, so it is left to refuse.
*/
private boolean hasPower(String power) {
return conn.isConnected() && conn.getModel().selfPower(power) != 0;
}
// ---- connection events ----

View File

@@ -0,0 +1,24 @@
package com.ts3client.net.ban;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class BanUnitTest {
@Test
void lengthsShowInTheLargestUnitThatDividesThem() {
assertEquals(BanUnit.DAYS, BanUnit.largestFor(2 * 86400));
assertEquals(BanUnit.HOURS, BanUnit.largestFor(90 * 60 * 2));
assertEquals(BanUnit.MINUTES, BanUnit.largestFor(120));
assertEquals(BanUnit.SECONDS, BanUnit.largestFor(61));
assertEquals(2, BanUnit.DAYS.amountOf(2 * 86400));
}
@Test
void noLengthIsPermanent() {
assertEquals(BanUnit.PERMANENT, BanUnit.largestFor(0));
assertEquals(BanUnit.PERMANENT, BanUnit.largestFor(-1));
assertEquals(0, BanUnit.PERMANENT.toSeconds(30));
}
}