Link the clients and channels named in server log lines

Log lines now name clients and channels with TeamSpeak's own client:// and
channel:// links, the same ones a dragged-in reference produces, so the
chat log's existing handling turns them into the context menus.

The log is rendered with a links-only pass rather than the full BBCode
renderer: its lines are assembled by the client itself, so nothing else in
them should be interpreted — a channel called "[b]VIP" stays literal.
Channels we no longer have (a deleted one) are still named in plain text.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 18:11:14 +00:00
parent cb05b097a6
commit 189620df7f
5 changed files with 129 additions and 29 deletions

View File

@@ -59,8 +59,7 @@ final class ConnectionEventHandler implements TS3Listener {
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();
String name = conn.clientLink(e.getClientId());
announceClientLeft(e);
logClientLeft(e, name);
}
@@ -79,7 +78,7 @@ final class ConnectionEventHandler implements TS3Listener {
announceOwnMove(safeInt(e, "reasonid"), e);
} else {
announceClientMoved(safeInt(e, "reasonid"), e.getClientId(), from, e.getTargetChannelId());
logClientMoved(e, c.nickname, from, e.getTargetChannelId());
logClientMoved(e, conn.clientLink(c.id), from, e.getTargetChannelId());
}
conn.ui.onModelChanged();
}
@@ -87,21 +86,22 @@ final class ConnectionEventHandler implements TS3Listener {
/** A client became visible to us, logged the way native TS3's server tab does. */
private void logClientEntered(ClientJoinEvent e) {
String name = e.getClientNickname();
String name = conn.clientLink(e.getClientId(),
TeamspeakConnection.orEmpty(e.getUniqueClientIdentifier()), e.getClientNickname());
switch (safeInt(e, "reasonid")) {
case REASON_MOVED:
conn.log(name + " appears, coming from channel \"" + conn.channelName(e.getClientFromId()) + "\"");
conn.log(name + " appears, coming from channel \"" + conn.channelLink(e.getClientFromId()) + "\"");
break;
case REASON_CHANNEL_KICK:
conn.log(name + " appears, was kicked from channel \"" + conn.channelName(e.getClientFromId())
conn.log(name + " appears, was kicked from channel \"" + conn.channelLink(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()) + "\"");
conn.log(name + " switched to channel \"" + conn.channelLink(e.getClientTargetId())
+ "\", coming from channel \"" + conn.channelLink(e.getClientFromId()) + "\"");
break;
default:
conn.log(name + " connected to channel \"" + conn.channelName(e.getClientTargetId()) + "\"");
conn.log(name + " connected to channel \"" + conn.channelLink(e.getClientTargetId()) + "\"");
}
}
@@ -120,14 +120,14 @@ final class ConnectionEventHandler implements TS3Listener {
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())
conn.log(name + " left: was kicked to channel \"" + conn.channelLink(e.getClientTargetId())
+ "\" by " + invokerName(e) + suffix);
break;
case REASON_MOVED:
conn.log(name + " left, heading to channel \"" + conn.channelName(e.getClientTargetId()) + "\"");
conn.log(name + " left, heading to channel \"" + conn.channelLink(e.getClientTargetId()) + "\"");
break;
case REASON_SWITCHED:
conn.log(name + " left, switched to channel \"" + conn.channelName(e.getClientTargetId()) + "\"");
conn.log(name + " left, switched to channel \"" + conn.channelLink(e.getClientTargetId()) + "\"");
break;
default:
conn.log(name + " disconnected");
@@ -140,16 +140,16 @@ final class ConnectionEventHandler implements TS3Listener {
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));
conn.log(name + " was moved from channel \"" + conn.channelLink(fromChannel) + "\" to \""
+ conn.channelLink(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);
conn.log(name + " was kicked from channel \"" + conn.channelLink(fromChannel) + "\" to \""
+ conn.channelLink(toChannel) + "\" by " + invokerName(e) + suffix);
break;
default:
conn.log(name + " switched from channel \"" + conn.channelName(fromChannel) + "\" to \""
+ conn.channelName(toChannel) + "\"");
conn.log(name + " switched from channel \"" + conn.channelLink(fromChannel) + "\" to \""
+ conn.channelLink(toChannel) + "\"");
}
}
@@ -311,7 +311,7 @@ final class ConnectionEventHandler implements TS3Listener {
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);
conn.log(oldName + " is now known as " + conn.clientLink(c.id));
}
if (safeInt(e, "client_talk_request") > 0 && !self) {
conn.sound(SoundEvent.CLIENT_REQUESTED_TALK_POWER, vars);
@@ -345,7 +345,7 @@ final class ConnectionEventHandler implements TS3Listener {
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.log("Channel \"" + conn.channelLink(cid) + "\" was created by " + invokerName(e));
}
conn.ui.onModelChanged();
}
@@ -378,7 +378,7 @@ final class ConnectionEventHandler implements TS3Listener {
: 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.log("Channel \"" + conn.channelLink(ch.id) + "\" was edited by " + invokerName(e));
}
conn.ui.onModelChanged();
}
@@ -407,7 +407,7 @@ final class ConnectionEventHandler implements TS3Listener {
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.log("Channel \"" + conn.channelLink(ch.id) + "\" was moved by " + invokerName(e));
}
conn.ui.onModelChanged();
}
@@ -490,10 +490,9 @@ final class ConnectionEventHandler implements TS3Listener {
conn.ui.onModelChanged();
}
/** A client's nickname for a log line, falling back to its id once it has left. */
/** A client's name for a log line, as a link, 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;
return conn.clientLink(clientId);
}
/** Picks the event variant matching who caused the change: us, another client, or the server. */
@@ -585,9 +584,12 @@ final class ConnectionEventHandler implements TS3Listener {
// ---- helpers ----
private static String invokerName(BaseEvent e) {
/** Who caused an event, linked where the server named them; "the server" when nobody did. */
private String invokerName(BaseEvent e) {
String name = TeamspeakConnection.orEmpty(e.get("invokername"));
return name.isEmpty() ? "the server" : name;
if (name.isEmpty()) return "the server";
return conn.clientLink(safeInt(e, "invokerid"),
TeamspeakConnection.orEmpty(e.get("invokeruid")), name);
}
private static int safeInt(BaseEvent e, String key) {

View File

@@ -19,6 +19,7 @@ import com.ts3client.net.filetransfer.FileTransferManager;
import com.ts3client.net.filetransfer.RemoteFile;
import com.ts3client.sound.SoundEvent;
import com.ts3client.sound.SoundNotifier;
import com.ts3client.text.TsLink;
import java.io.File;
import java.net.InetSocketAddress;
@@ -1204,6 +1205,28 @@ public final class TeamspeakConnection implements TS3Listener {
return ch != null ? ch.name : "channel #" + channelId;
}
/**
* A channel's name as the log wants it: TeamSpeak's own {@code channel://} link, which
* the chat log turns into the same clickable entry a dragged-in channel produces. A
* channel we no longer know (a deleted one, say) is named in plain text instead.
*/
String channelLink(int channelId) {
ChannelNode ch = model.getChannel(channelId);
return ch != null ? TsLink.channelBBCode(channelId, ch.name) : channelName(channelId);
}
/** The same for a client, named by the model. */
String clientLink(int clientId) {
ClientEntry c = model.getClient(clientId);
return c != null ? clientLink(clientId, c.uniqueId, c.nickname) : "Client " + clientId;
}
/** The same for a client an event describes, which may already be gone from the model. */
String clientLink(int clientId, String uniqueId, String nickname) {
if (clientId <= 0 || nickname == null || nickname.isEmpty()) return orEmpty(nickname);
return TsLink.clientBBCode(clientId, orEmpty(uniqueId), nickname);
}
/** The placeholder values a pack may reference for an action involving a client. */
Map<String, String> clientVars(int clientId, String fallbackName) {
ClientEntry c = model.getClient(clientId);

View File

@@ -37,6 +37,10 @@ public final class BBCode {
/** CSS class on links that leave the client, so they are visibly not an identity. */
public static final String EXTERNAL_LINK_CLASS = "extlink";
/** A single {@code [url=href]label[/url]} pair, the only markup the log carries. */
private static final Pattern URL_LINK =
Pattern.compile("(?i)\\[url=([^\\]]+)](.*?)\\[/url]");
private BBCode() {
}
@@ -70,6 +74,29 @@ public final class BBCode {
return out.toString();
}
/**
* Renders text carrying {@code [url=…]…[/url]} links and nothing else: everything
* outside those tags stays literal. The server log is written this way, so that a
* name happening to contain bracket markup is shown as it is rather than rendered.
*/
public static String linksToHtml(String input) {
if (input == null || input.isEmpty()) return "";
StringBuilder out = new StringBuilder();
Matcher m = URL_LINK.matcher(input);
int last = 0;
while (m.find()) {
out.append(escape(input.substring(last, m.start())));
String href = m.group(1);
if (SAFE_URL.matcher(href).matches()) {
out.append(link(href)).append(escape(m.group(2))).append(linkClose(href));
} else {
out.append(escape(m.group()));
}
last = m.end();
}
return out.append(escape(input.substring(last))).toString();
}
/** Escapes plain text for HTML without interpreting any BBCode. */
public static String escape(String s) {
if (s == null) return "";

View File

@@ -0,0 +1,40 @@
package com.ts3client.text;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* The server log's renderer: it turns the client's own links into anchors and leaves
* everything else — names carrying bracket markup included — as plain text.
*/
class BBCodeLinksTest {
@Test
void rendersClientAndChannelLinks() {
String log = TsLink.clientBBCode(7, "uid=", "Bob") + " connected to channel \""
+ TsLink.channelBBCode(3, "Lobby") + "\"";
String html = BBCode.linksToHtml(log);
assertTrue(html.contains("href=\"client://7/uid=~Bob\""), html);
assertTrue(html.contains(">Bob</a>"), html);
assertTrue(html.contains("href=\"channel://3/Lobby\""), html);
assertTrue(html.contains(">Lobby</a>"), html);
}
@Test
void leavesOtherMarkupLiteral() {
assertEquals("[b]not bold[/b] &amp; &lt;i&gt;", BBCode.linksToHtml("[b]not bold[/b] & <i>"));
}
@Test
void keepsBracketsInsideALabelLiteral() {
String html = BBCode.linksToHtml(TsLink.channelBBCode(3, "[cspacer]---"));
assertTrue(html.contains(">[cspacer]---</a>"), html);
}
@Test
void rejectsHrefsThatAreNotLinks() {
assertEquals("[url=javascript:alert(1)]x[/url]", BBCode.linksToHtml("[url=javascript:alert(1)]x[/url]"));
}
}

View File

@@ -190,10 +190,14 @@ public final class ChatPanel extends JPanel {
+ BBCode.escape(text) + "</span>"));
}
/** Server-side events (client joins/leaves/moves, group changes, channel edits, …). */
/**
* Server-side events (client joins/leaves/moves, group changes, channel edits, …).
* These lines name clients and channels with TeamSpeak's own links, which stay
* clickable here — everything else in them is literal text.
*/
public void appendServerLog(String text) {
edt(() -> serverTab.appendLine("<span style=\"color:" + hex(Theme.CHANNEL_TEXT) + "\">" + stamp()
+ BBCode.escape(text) + "</span>"));
+ BBCode.linksToHtml(text) + "</span>"));
}
public void appendServerMessage(int fromId, String from, String text) {
@@ -318,6 +322,10 @@ public final class ChatPanel extends JPanel {
// Client references look exactly like a message author's name.
css.addRule("a." + BBCode.IDENTITY_LINK_CLASS
+ " { color:" + hex(Theme.CHAT_NAME) + "; font-weight:bold; text-decoration:none; }");
// Channel (and other TeamSpeak protocol) references, which the log leans on
// heavily: same weight as the text around them, but their own colour.
css.addRule("a." + BBCode.TS_LINK_CLASS
+ " { color:" + hex(Theme.CHAT_NAME) + "; text-decoration:none; }");
// External links are underlined so they can't be mistaken for an identity.
css.addRule("a." + BBCode.EXTERNAL_LINK_CLASS
+ " { color:" + hex(Theme.LINK) + "; text-decoration:underline; }");