Add Set Server/Channel Group context menus and dialog
Lets moderators assign or revoke server and channel groups for a client directly from its context menu, with per-group checkboxes gated by the client's actual TS3 permissions (i_group_needed_member_ add/remove_power vs. each group's n_member_addp/removep, including TS3's -1 "unlimited" sentinel). A "Server Groups Dialog..." entry shows every server group, graying out ones the client can't touch. Group membership changes now update ClientEntry locally instead of only logging them, so the checkboxes and dialog reflect a change immediately instead of needing a reconnect. The server's default channel group (e.g. "Guest") is hidden from the channel-group menu since it's not something you assign directly.
This commit is contained in:
@@ -6,6 +6,7 @@ public final class ClientEntry {
|
||||
public int channelId;
|
||||
public String nickname;
|
||||
public String uniqueId = "";
|
||||
public int databaseId;
|
||||
public int type; // 0 = normal voice client, 1 = server-query
|
||||
public int talkPower;
|
||||
|
||||
@@ -39,4 +40,28 @@ public final class ClientEntry {
|
||||
public boolean isQuery() {
|
||||
return type == 1;
|
||||
}
|
||||
|
||||
/** Adds a server group id, if not already present. */
|
||||
public void addServerGroup(int groupId) {
|
||||
for (int id : serverGroupIds) if (id == groupId) return;
|
||||
int[] updated = java.util.Arrays.copyOf(serverGroupIds, serverGroupIds.length + 1);
|
||||
updated[serverGroupIds.length] = groupId;
|
||||
serverGroupIds = updated;
|
||||
}
|
||||
|
||||
/** Removes a server group id, if present. */
|
||||
public void removeServerGroup(int groupId) {
|
||||
int index = -1;
|
||||
for (int i = 0; i < serverGroupIds.length; i++) {
|
||||
if (serverGroupIds[i] == groupId) {
|
||||
index = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (index < 0) return;
|
||||
int[] updated = new int[serverGroupIds.length - 1];
|
||||
System.arraycopy(serverGroupIds, 0, updated, 0, index);
|
||||
System.arraycopy(serverGroupIds, index + 1, updated, index, serverGroupIds.length - index - 1);
|
||||
serverGroupIds = updated;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ final class ConnectionEventHandler implements TS3Listener {
|
||||
c.away = e.isClientAway();
|
||||
c.awayMessage = TeamspeakConnection.orEmpty(e.get("client_away_message"));
|
||||
c.uniqueId = TeamspeakConnection.orEmpty(e.getUniqueClientIdentifier());
|
||||
c.databaseId = e.getClientDatabaseId();
|
||||
c.serverGroupIds = parseIntList(e.getClientServerGroups());
|
||||
c.channelGroupId = e.getClientChannelGroupId();
|
||||
c.self = (e.getClientId() == conn.getSelfClientId());
|
||||
@@ -423,6 +424,8 @@ final class ConnectionEventHandler implements TS3Listener {
|
||||
|
||||
@Override
|
||||
public void onServerGroupClientAdded(ServerGroupClientAddedEvent e) {
|
||||
ClientEntry c = conn.getModel().getClient(e.getClientId());
|
||||
if (c != null) c.addServerGroup(e.getServerGroupId());
|
||||
boolean self = e.getClientId() == conn.getSelfClientId();
|
||||
conn.sound(self
|
||||
? byInvoker(e, SoundEvent.YOU_SERVERGROUP_ADDED_BY_USER, SoundEvent.YOU_SERVERGROUP_ADDED_BY_USER,
|
||||
@@ -432,11 +435,14 @@ final class ConnectionEventHandler implements TS3Listener {
|
||||
groupVars(e.getClientId(), e.getName()));
|
||||
conn.log(clientLogName(e.getClientId()) + " was added to server group \"" + e.getName()
|
||||
+ "\" by " + invokerName(e) + ".");
|
||||
conn.ui.onModelChanged();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onServerGroupClientDeleted(ServerGroupClientDeletedEvent e) {
|
||||
int clientId = safeInt(e, "clid");
|
||||
ClientEntry c = conn.getModel().getClient(clientId);
|
||||
if (c != null) c.removeServerGroup(e.getServerGroupId());
|
||||
boolean self = clientId == conn.getSelfClientId();
|
||||
conn.sound(self
|
||||
? byInvoker(e, SoundEvent.YOU_SERVERGROUP_REMOVED_BY_USER,
|
||||
@@ -446,6 +452,7 @@ final class ConnectionEventHandler implements TS3Listener {
|
||||
groupVars(clientId, e.get("name")));
|
||||
conn.log(clientLogName(clientId) + " was removed from server group \"" + TeamspeakConnection.orEmpty(e.get("name"))
|
||||
+ "\" by " + invokerName(e) + ".");
|
||||
conn.ui.onModelChanged();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -509,7 +516,18 @@ final class ConnectionEventHandler implements TS3Listener {
|
||||
}
|
||||
|
||||
private static Group toGroup(BaseEvent e, int id) {
|
||||
return new Group(id, e.get("name"), TeamspeakConnection.safeLong(e, "iconid"), safeInt(e, "sortid"));
|
||||
return new Group(id, e.get("name"), TeamspeakConnection.safeLong(e, "iconid"), safeInt(e, "sortid"),
|
||||
safeInt(e, "type"), safeInt(e, "n_member_addp"), safeInt(e, "n_member_removep"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPermissionList(PermissionListEvent e) {
|
||||
conn.getModel().putPermissionName(e.get("permname"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClientNeededPermissions(ClientNeededPermissionsEvent e) {
|
||||
conn.getModel().putSelfPermissionValue(safeInt(e, "permid"), safeInt(e, "permvalue"));
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -9,11 +9,26 @@ public final class Group {
|
||||
public final long iconId;
|
||||
/** Display order among groups; lower comes first. */
|
||||
public final int sortId;
|
||||
/** TS3 group type: 0 = template, 1 = regular, 2 = query (server groups only). */
|
||||
public final int type;
|
||||
/** Power needed to add a member to this group. */
|
||||
public final int neededMemberAddPower;
|
||||
/** Power needed to remove a member from this group. */
|
||||
public final int neededMemberRemovePower;
|
||||
|
||||
public Group(int id, String name, long iconId, int sortId) {
|
||||
public Group(int id, String name, long iconId, int sortId, int type,
|
||||
int neededMemberAddPower, int neededMemberRemovePower) {
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
this.iconId = iconId;
|
||||
this.sortId = sortId;
|
||||
this.type = type;
|
||||
this.neededMemberAddPower = neededMemberAddPower;
|
||||
this.neededMemberRemovePower = neededMemberRemovePower;
|
||||
}
|
||||
|
||||
/** Regular, user-assignable groups exclude templates and query-only groups. */
|
||||
public boolean isRegular() {
|
||||
return type == 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,13 +18,29 @@ public final class ServerModel {
|
||||
private final Map<Integer, ClientEntry> clients = new LinkedHashMap<>();
|
||||
private final Map<Integer, Group> serverGroups = new LinkedHashMap<>();
|
||||
private final Map<Integer, Group> channelGroups = new LinkedHashMap<>();
|
||||
/**
|
||||
* Permission id -> name, learned from the server's {@code permissionlist} response.
|
||||
* Entries carry no id of their own: TS3 numbers them by position in the response,
|
||||
* and the empty "group_id_end" separator records marking category boundaries don't
|
||||
* count toward that position, so only {@link #putPermissionName} advances it.
|
||||
*/
|
||||
private final Map<Integer, String> permissionNames = new LinkedHashMap<>();
|
||||
private int nextPermissionId;
|
||||
/** Permission id -> the local client's resolved value, from {@code notifyclientneededpermissions}. */
|
||||
private final Map<Integer, Integer> selfPermissionValues = new LinkedHashMap<>();
|
||||
private String serverName = "TeamSpeak Server";
|
||||
/** The channel group everyone starts in, e.g. "Guest" — not worth offering to (re)assign. */
|
||||
private int defaultChannelGroupId;
|
||||
|
||||
public synchronized void clear() {
|
||||
channels.clear();
|
||||
clients.clear();
|
||||
serverGroups.clear();
|
||||
channelGroups.clear();
|
||||
permissionNames.clear();
|
||||
nextPermissionId = 0;
|
||||
selfPermissionValues.clear();
|
||||
defaultChannelGroupId = 0;
|
||||
}
|
||||
|
||||
// ---- groups ----
|
||||
@@ -78,6 +94,54 @@ public final class ServerModel {
|
||||
return g == null ? null : g.name;
|
||||
}
|
||||
|
||||
/** Regular (non-template, non-query) server groups, ordered for display. */
|
||||
public synchronized List<Group> allServerGroups() {
|
||||
return regularGroups(serverGroups);
|
||||
}
|
||||
|
||||
/** Regular (non-template, non-query) channel groups, ordered for display. */
|
||||
public synchronized List<Group> allChannelGroups() {
|
||||
return regularGroups(channelGroups);
|
||||
}
|
||||
|
||||
private static List<Group> regularGroups(Map<Integer, Group> groups) {
|
||||
List<Group> list = new ArrayList<>();
|
||||
for (Group g : groups.values()) {
|
||||
if (g.isRegular()) list.add(g);
|
||||
}
|
||||
list.sort(Comparator.comparingInt((Group g) -> g.sortId).thenComparingInt(g -> g.id));
|
||||
return list;
|
||||
}
|
||||
|
||||
// ---- permissions ----
|
||||
|
||||
/** Records the next named entry of a {@code permissionlist} response; ignores separators. */
|
||||
public synchronized void putPermissionName(String name) {
|
||||
if (name == null || name.isEmpty()) return;
|
||||
permissionNames.put(nextPermissionId, name);
|
||||
nextPermissionId++;
|
||||
}
|
||||
|
||||
public synchronized void putSelfPermissionValue(int permId, int value) {
|
||||
selfPermissionValues.put(permId, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* The local client's resolved value for a named permission (e.g.
|
||||
* {@code "i_group_needed_member_add_power"}), or {@code 0} if it isn't known
|
||||
* yet (the {@code permissionlist} request is still in flight, or the server
|
||||
* never reported a non-default value for it).
|
||||
*/
|
||||
public synchronized int selfPermissionValue(String name) {
|
||||
for (Map.Entry<Integer, String> entry : permissionNames.entrySet()) {
|
||||
if (entry.getValue().equals(name)) {
|
||||
Integer value = selfPermissionValues.get(entry.getKey());
|
||||
if (value != null) return value;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
public synchronized String getServerName() {
|
||||
return serverName;
|
||||
}
|
||||
@@ -86,6 +150,14 @@ public final class ServerModel {
|
||||
if (name != null && !name.isEmpty()) this.serverName = name;
|
||||
}
|
||||
|
||||
public synchronized int defaultChannelGroupId() {
|
||||
return defaultChannelGroupId;
|
||||
}
|
||||
|
||||
public synchronized void setDefaultChannelGroupId(int id) {
|
||||
this.defaultChannelGroupId = id;
|
||||
}
|
||||
|
||||
// ---- channels ----
|
||||
|
||||
public synchronized ChannelNode putChannel(int id, String name, int parentId, int order) {
|
||||
|
||||
@@ -264,6 +264,7 @@ public final class TeamspeakConnection implements TS3Listener {
|
||||
disconnectAnnounced = false;
|
||||
deafened = false;
|
||||
ui.onConnected();
|
||||
requestPermissionNames();
|
||||
|
||||
ui.onStatus("Retrieving channels…");
|
||||
syncAll();
|
||||
@@ -396,6 +397,8 @@ public final class TeamspeakConnection implements TS3Listener {
|
||||
}
|
||||
// The name comes with initserver; servergetvariables never reports it.
|
||||
model.setServerName(client.getServerName());
|
||||
model.setDefaultChannelGroupId(
|
||||
(int) safeLong(client.getServerProperties().get("virtualserver_default_channel_group")));
|
||||
try {
|
||||
for (Channel ch : client.listChannels()) {
|
||||
model.putChannel(ch.getId(), ch.getName(), ch.getParentChannelId(), ch.getOrder());
|
||||
@@ -647,6 +650,51 @@ public final class TeamspeakConnection implements TS3Listener {
|
||||
}, "ts3j-move-channel").start();
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks the server to (re-)send its permission definitions, which arrive as a burst
|
||||
* of {@code notifypermissionlist} events — the only way to learn permission names,
|
||||
* since {@code notifyclientneededpermissions} (which resolves our own power for
|
||||
* them) only ever reports numeric ids. Needed for {@link ServerModel#selfPermissionValue}
|
||||
* to work; harmless if it's slow or fails, since group-assignment eligibility just
|
||||
* won't be known yet.
|
||||
*/
|
||||
private void requestPermissionNames() {
|
||||
new Thread(() -> {
|
||||
try {
|
||||
client.executeCommand(new SingleCommand("permissionlist", ProtocolRole.CLIENT)).complete();
|
||||
} catch (Exception ignored) {
|
||||
// Best-effort: the menus fall back to treating unresolved permissions as 0.
|
||||
}
|
||||
}, "ts3j-permission-list").start();
|
||||
}
|
||||
|
||||
/** Assigns or removes a server group for a client (by database id, as {@code servergroupaddclient} needs). */
|
||||
public void setClientServerGroup(int clientDatabaseId, int groupId, boolean assign) {
|
||||
new Thread(() -> {
|
||||
try {
|
||||
if (assign) client.serverGroupAddClient(groupId, clientDatabaseId);
|
||||
else client.serverGroupRemoveClient(groupId, clientDatabaseId);
|
||||
} catch (Exception e) {
|
||||
error("Could not " + (assign ? "assign" : "remove") + " server group: " + rootMessage(e));
|
||||
}
|
||||
}, "ts3j-server-group").start();
|
||||
}
|
||||
|
||||
/** Assigns a channel group for a client in the channel it currently sits in. */
|
||||
public void setClientChannelGroup(int clientDatabaseId, int channelId, int groupId) {
|
||||
new Thread(() -> {
|
||||
try {
|
||||
SingleCommand cmd = new SingleCommand("setclientchannelgroup", ProtocolRole.CLIENT);
|
||||
cmd.add(new CommandSingleParameter("cgid", Integer.toString(groupId)));
|
||||
cmd.add(new CommandSingleParameter("cid", Integer.toString(channelId)));
|
||||
cmd.add(new CommandSingleParameter("cldbid", Integer.toString(clientDatabaseId)));
|
||||
client.executeCommand(cmd).complete();
|
||||
} catch (Exception e) {
|
||||
error("Could not set channel group: " + rootMessage(e));
|
||||
}
|
||||
}, "ts3j-channel-group").start();
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to (or unsubscribes from) a set of channels in one command. The model
|
||||
* is left alone: the server answers with the subscription events that update it.
|
||||
|
||||
Reference in New Issue
Block a user