From 189620df7fec0b756a12d8d5c0d60cc2f3c818fa Mon Sep 17 00:00:00 2001 From: ericek111 Date: Wed, 19 Aug 2026 18:11:14 +0000 Subject: [PATCH] Link the clients and channels named in server log lines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../ts3client/net/ConnectionEventHandler.java | 56 ++++++++++--------- .../ts3client/net/TeamspeakConnection.java | 23 ++++++++ .../main/java/com/ts3client/text/BBCode.java | 27 +++++++++ .../com/ts3client/text/BBCodeLinksTest.java | 40 +++++++++++++ .../main/java/com/ts3client/ui/ChatPanel.java | 12 +++- 5 files changed, 129 insertions(+), 29 deletions(-) create mode 100644 ts3-client/core/src/test/java/com/ts3client/text/BBCodeLinksTest.java diff --git a/ts3-client/core/src/main/java/com/ts3client/net/ConnectionEventHandler.java b/ts3-client/core/src/main/java/com/ts3client/net/ConnectionEventHandler.java index cb3a023..3cda48e 100644 --- a/ts3-client/core/src/main/java/com/ts3client/net/ConnectionEventHandler.java +++ b/ts3-client/core/src/main/java/com/ts3client/net/ConnectionEventHandler.java @@ -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) { diff --git a/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java b/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java index edfb775..09c4749 100644 --- a/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java +++ b/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java @@ -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 clientVars(int clientId, String fallbackName) { ClientEntry c = model.getClient(clientId); diff --git a/ts3-client/core/src/main/java/com/ts3client/text/BBCode.java b/ts3-client/core/src/main/java/com/ts3client/text/BBCode.java index fe42a88..0c44c34 100644 --- a/ts3-client/core/src/main/java/com/ts3client/text/BBCode.java +++ b/ts3-client/core/src/main/java/com/ts3client/text/BBCode.java @@ -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 ""; diff --git a/ts3-client/core/src/test/java/com/ts3client/text/BBCodeLinksTest.java b/ts3-client/core/src/test/java/com/ts3client/text/BBCodeLinksTest.java new file mode 100644 index 0000000..fc232f9 --- /dev/null +++ b/ts3-client/core/src/test/java/com/ts3client/text/BBCodeLinksTest.java @@ -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"), html); + assertTrue(html.contains("href=\"channel://3/Lobby\""), html); + assertTrue(html.contains(">Lobby"), html); + } + + @Test + void leavesOtherMarkupLiteral() { + assertEquals("[b]not bold[/b] & <i>", BBCode.linksToHtml("[b]not bold[/b] & ")); + } + + @Test + void keepsBracketsInsideALabelLiteral() { + String html = BBCode.linksToHtml(TsLink.channelBBCode(3, "[cspacer]---")); + assertTrue(html.contains(">[cspacer]---"), html); + } + + @Test + void rejectsHrefsThatAreNotLinks() { + assertEquals("[url=javascript:alert(1)]x[/url]", BBCode.linksToHtml("[url=javascript:alert(1)]x[/url]")); + } +} diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ChatPanel.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ChatPanel.java index a70ba09..f44aeb9 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/ChatPanel.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ChatPanel.java @@ -190,10 +190,14 @@ public final class ChatPanel extends JPanel { + BBCode.escape(text) + "")); } - /** 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("" + stamp() - + BBCode.escape(text) + "")); + + BBCode.linksToHtml(text) + "")); } 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; }");