Add a Permissions menu with privilege keys: list, create, remove and use

Permissions > Use Privilege Key redeems a key (tokenuse). Permissions >
Privilege Keys opens a per-tab window listing the server's keys with
their group, channel, creation date and description; keys can be created,
removed, copied and reloaded. The create dialog takes a server group, or a
channel group in a chosen channel, plus a description, offers only groups
we have the power to add members to, and shows each new key for copying.

tokenlist and tokenadd answer with notifytokenlist / notifytokenadd ahead
of the command's completion, so core collects them the way it does the
ban list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 23:00:27 +00:00
parent 2c1b37ee30
commit 4911542d39
12 changed files with 806 additions and 9 deletions

View File

@@ -662,6 +662,8 @@ final class ConnectionEventHandler implements TS3Listener {
if ("notifyconnectioninfo".equals(e.getCommand())) conn.stats.onReport(e);
else if ("notifychannelpermlist".equals(e.getCommand())) conn.channels.onPermListEntry(e);
else if ("notifybanlist".equals(e.getCommand())) conn.bans.onListEntry(e);
else if ("notifytokenlist".equals(e.getCommand())) conn.privilegeKeys.onListEntry(e);
else if ("notifytokenadd".equals(e.getCommand())) conn.privilegeKeys.onAdded(e);
}
// ---- helpers ----

View File

@@ -0,0 +1,36 @@
package com.ts3client.net;
import java.util.Map;
/**
* A privilege key (token): a one-time code that puts whoever redeems it into a server
* group, or into a channel group in one channel.
*
* @param groupId the server or channel group it grants
* @param channelId the channel a channel group key applies in; 0 for a server group key
*/
public record PrivilegeKey(String token, boolean channelGroup, int groupId, int channelId,
long createdEpochSeconds, String description) {
/** Reads a key off a {@code notifytokenlist} event's fields. */
static PrivilegeKey from(Map<String, String> fields) {
return new PrivilegeKey(orEmpty(fields.get("token")),
parseLong(fields.get("token_type")) == 1,
(int) parseLong(fields.get("token_id1")),
(int) parseLong(fields.get("token_id2")),
parseLong(fields.get("token_created")),
orEmpty(fields.get("token_description")));
}
private static String orEmpty(String s) {
return s == null || s.equals("null") ? "" : s;
}
private static long parseLong(String s) {
try {
return s == null ? 0 : Long.parseLong(s.trim());
} catch (NumberFormatException e) {
return 0;
}
}
}

View File

@@ -0,0 +1,90 @@
package com.ts3client.net;
import com.github.manevolent.ts3j.command.CommandException;
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.ProtocolRole;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/**
* The server's privilege keys: listing, creating, deleting and redeeming them.
*
* <p>Split out of {@link TeamspeakConnection} like {@link BanAdmin}, and answered the same
* way: {@code tokenlist} and {@code tokenadd} reply with notifications ahead of the
* command's own completion. Every call here blocks and must run off the UI thread; the
* connection's public wrappers do that.
*/
final class PrivilegeKeyAdmin {
/** What {@code tokenlist} answers when there are no keys at all. */
private static final int ERROR_DATABASE_EMPTY_RESULT = 0x0501;
private final TeamspeakConnection conn;
/** The {@code tokenlist} reply in progress; one request may be in flight at a time. */
private volatile List<PrivilegeKey> listing;
/** The key the {@code tokenadd} in flight was answered with. */
private volatile String created;
PrivilegeKeyAdmin(TeamspeakConnection conn) {
this.conn = conn;
}
List<PrivilegeKey> list() throws Exception {
List<PrivilegeKey> keys = Collections.synchronizedList(new ArrayList<>());
listing = keys;
try {
conn.socket().executeCommand(new SingleCommand("tokenlist", ProtocolRole.CLIENT)).complete();
conn.awaitEventsProcessed();
} catch (CommandException e) {
if (e.getErrorId() != ERROR_DATABASE_EMPTY_RESULT) throw e;
} finally {
listing = null;
}
return new ArrayList<>(keys);
}
void onListEntry(UnknownTeamspeakEvent e) {
List<PrivilegeKey> keys = listing;
if (keys != null) keys.add(PrivilegeKey.from(e.getMap()));
}
/**
* Creates a key.
*
* @param channelId the channel a channel group key applies in; ignored for a server group
* @return the new key
*/
synchronized String add(boolean channelGroup, int groupId, int channelId, String description) throws Exception {
SingleCommand cmd = new SingleCommand("tokenadd", ProtocolRole.CLIENT);
cmd.add(new CommandSingleParameter("tokentype", channelGroup ? "1" : "0"));
cmd.add(new CommandSingleParameter("tokenid1", Integer.toString(groupId)));
cmd.add(new CommandSingleParameter("tokenid2", channelGroup ? Integer.toString(channelId) : "0"));
cmd.add(new CommandSingleParameter("tokendescription", description));
created = null;
conn.socket().executeCommand(cmd).complete();
conn.awaitEventsProcessed();
String token = created;
if (token == null || token.isEmpty()) throw new IllegalStateException("The server did not return the key");
return token;
}
void onAdded(UnknownTeamspeakEvent e) {
created = e.get("token");
}
void delete(String token) throws Exception {
conn.socket().executeCommand(new SingleCommand("tokendelete", ProtocolRole.CLIENT,
new CommandSingleParameter("token", token))).complete();
}
/** Redeems a key; the server announces the resulting group change itself. */
void use(String token) throws Exception {
conn.socket().executeCommand(new SingleCommand("tokenuse", ProtocolRole.CLIENT,
new CommandSingleParameter("token", token))).complete();
}
}

View File

@@ -243,6 +243,15 @@ public final class ServerModel {
return out;
}
/** The regular server or channel groups we have the power to put someone in, so may make a privilege key for. */
public synchronized List<Group> privilegeKeyGroups(boolean channelGroups) {
List<Group> out = new ArrayList<>();
for (Group g : channelGroups ? allChannelGroups() : allServerGroups()) {
if (canToggleGroup(g, false)) out.add(g);
}
return out;
}
private static boolean hasPower(int needed, int own) {
if (own == -1) return true;
if (needed == -1) return false;
@@ -397,6 +406,20 @@ public final class ServerModel {
return children;
}
/** Every channel, depth first in the order the tree shows them. */
public synchronized List<ChannelNode> channelsInTreeOrder() {
List<ChannelNode> out = new ArrayList<>();
addInTreeOrder(0, out);
return out;
}
private void addInTreeOrder(int parentId, List<ChannelNode> out) {
for (ChannelNode c : childrenOf(parentId)) {
out.add(c);
addInTreeOrder(c.id, out);
}
}
/** The channel of that exact name directly below {@code parentId}, or {@code null}. */
public synchronized ChannelNode findChannelByName(int parentId, String name) {
for (ChannelNode c : channels.values()) {

View File

@@ -95,8 +95,9 @@ public final class TeamspeakConnection implements TS3Listener {
final ConnectionStatsCollector stats = new ConnectionStatsCollector(this);
/** Channel editing and the server's icon store; package-private for {@link ConnectionEventHandler}. */
final ChannelAdmin channels = new ChannelAdmin(this);
/** The server's ban list; also package-private for {@link ConnectionEventHandler}. */
/** The server's ban list and privilege keys; also package-private for {@link ConnectionEventHandler}. */
final BanAdmin bans = new BanAdmin(this);
final PrivilegeKeyAdmin privilegeKeys = new PrivilegeKeyAdmin(this);
private final AvatarAdmin avatarAdmin = new AvatarAdmin(this);
/*
@@ -954,6 +955,43 @@ public final class TeamspeakConnection implements TS3Listener {
});
}
// ---- privilege keys ----
/** Reads the server's privilege keys, delivering them (or a failure message) off the UI thread. */
public void requestPrivilegeKeys(BiConsumer<List<PrivilegeKey>, String> callback) {
run(callback, privilegeKeys::list);
}
/**
* Creates a privilege key for a server group, or for a channel group in {@code channelId}.
*
* @param callback given the new key, or the failure message
*/
public void addPrivilegeKey(boolean channelGroup, int groupId, int channelId, String description,
BiConsumer<String, String> callback) {
run(callback, () -> privilegeKeys.add(channelGroup, groupId, channelId, description));
}
/**
* Deletes privilege keys one by one, stopping at the first the server refuses.
*
* @param callback given {@code null} on success, or the failure message
*/
public void deletePrivilegeKeys(Collection<String> tokens, Consumer<String> callback) {
run((ignored, error) -> callback.accept(error), () -> {
for (String token : tokens) privilegeKeys.delete(token);
return null;
});
}
/** @param callback given {@code null} on success, or the failure message */
public void usePrivilegeKey(String token, Consumer<String> callback) {
run((ignored, error) -> callback.accept(error), () -> {
privilegeKeys.use(token);
return null;
});
}
// ---- bans ----
/** Reads the server's ban list, delivering it (or a failure message) off the UI thread. */

View File

@@ -387,6 +387,11 @@ public final class ServerSession {
return permits("b_client_ban_list");
}
/** Whether the server grants us {@code b_virtualserver_token_list}; offered while unknown. */
public boolean canListPrivilegeKeys() {
return permits("b_virtualserver_token_list");
}
/** Whether the server lets us upload an avatar ({@code i_client_max_avatar_filesize}); offered while unknown. */
public boolean canSetAvatar() {
return permits("i_client_max_avatar_filesize");