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 b3e153a..15f1d49 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 @@ -586,8 +586,9 @@ final class ConnectionEventHandler implements TS3Listener { notification = SoundEvent.CHAT_RECEIVED_MESSAGE_SERVER; break; } + conn.persistIncomingChat(scope, e.getInvokerId(), e.getInvokerUniqueId(), e.getInvokerName(), e.getMessage()); conn.sound(notification, conn.clientVars(e.getInvokerId(), e.getInvokerName())); - conn.ui.onChat(scope, e.getInvokerId(), e.getInvokerName(), e.getMessage()); + conn.ui.onChat(scope, e.getInvokerId(), e.getInvokerUniqueId(), e.getInvokerName(), e.getMessage()); } @Override diff --git a/ts3-client/core/src/main/java/com/ts3client/net/ConnectionListener.java b/ts3-client/core/src/main/java/com/ts3client/net/ConnectionListener.java index c28c3cb..1716762 100644 --- a/ts3-client/core/src/main/java/com/ts3client/net/ConnectionListener.java +++ b/ts3-client/core/src/main/java/com/ts3client/net/ConnectionListener.java @@ -1,5 +1,7 @@ package com.ts3client.net; +import java.util.List; + /** * UI-facing callbacks fired by {@link TeamspeakConnection}. Implementations are * responsible for marshalling to the Swing EDT. @@ -24,7 +26,15 @@ public interface ConnectionListener { /** A group icon finished downloading; repaint anything showing icons. */ void onIconsUpdated(); - void onChat(ChatScope scope, int fromClientId, String fromName, String message); + void onChat(ChatScope scope, int fromClientId, String fromUniqueId, String fromName, String message); + + /** Previously persisted TS3 HTML log entries for one chat scope. */ + default void onChatHistory(ChatScope scope, List htmlEntries) { + } + + /** Previously persisted TS3 HTML log entries for a private chat. */ + default void onPrivateChatHistory(int clientId, String nickname, List htmlEntries) { + } /** A client (possibly the local one) started or stopped talking. */ void onTalkStateChanged(int clientId, boolean talking); @@ -40,4 +50,9 @@ public interface ConnectionListener { * a group being (un)assigned, and so on. */ void onServerLog(String message); + + /** A server-log line already rendered in TS3's HTML log format. */ + default void onServerLogHtml(String html) { + onServerLog(html == null ? "" : html); + } } 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 243a076..5a9c59a 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 @@ -4,6 +4,7 @@ import com.github.manevolent.ts3j.api.Channel; import com.github.manevolent.ts3j.api.Client; import com.github.manevolent.ts3j.command.MultiCommand; import com.github.manevolent.ts3j.command.SingleCommand; +import com.github.manevolent.ts3j.command.parameter.CommandParameter; import com.github.manevolent.ts3j.command.parameter.CommandSingleParameter; import com.github.manevolent.ts3j.event.*; import com.github.manevolent.ts3j.identity.LocalIdentity; @@ -13,6 +14,8 @@ import com.github.manevolent.ts3j.util.Ts3Crypt; import com.ts3client.audio.AudioBackend; import com.ts3client.audio.VoiceInput; import com.ts3client.audio.VoiceOutput; +import com.ts3client.chatlog.ChatLogScope; +import com.ts3client.chatlog.ChatLogStore; import com.ts3client.config.Settings; import com.ts3client.net.filetransfer.FileTransfer; import com.ts3client.net.filetransfer.FileTransferManager; @@ -22,8 +25,10 @@ import com.ts3client.sound.SoundNotifier; import com.ts3client.text.TsLink; import java.io.File; +import java.io.IOException; import java.net.InetSocketAddress; import java.util.ArrayList; +import java.util.Base64; import java.util.Collection; import java.util.List; import java.util.Map; @@ -72,6 +77,7 @@ public final class TeamspeakConnection implements TS3Listener { private VoiceOutput playback; private LocalIdentity identity; private FileTransferManager fileTransfers; + private ChatLogStore chatLogs; /** * Runs ts3j's event callbacks. Supplying it ourselves (rather than leaving ts3j to make * its own) is what lets {@link #awaitEventsProcessed()} tell when everything the server @@ -287,6 +293,7 @@ public final class TeamspeakConnection implements TS3Listener { leaving = false; disconnectAnnounced = false; deafened = false; + loadChatLogs(); ui.onConnected(); requestPermissionNames(); @@ -517,6 +524,7 @@ public final class TeamspeakConnection implements TS3Listener { playback = null; client = null; fileTransfers = null; + chatLogs = null; selfClientId = -1; stats.clear(); @@ -749,6 +757,7 @@ public final class TeamspeakConnection implements TS3Listener { ClientEntry self = model.getClient(selfClientId); int cid = self != null ? self.channelId : 0; client.sendChannelMessage(cid, text); + persistOutgoingChat(ConnectionListener.ChatScope.CHANNEL, 0, "", text); sound(SoundEvent.CHAT_SENT_MESSAGE_CHANNEL, channelVars(cid, null)); } catch (Exception e) { error("Message failed: " + rootMessage(e)); @@ -760,6 +769,7 @@ public final class TeamspeakConnection implements TS3Listener { new Thread(() -> { try { client.sendServerMessage(text); + persistOutgoingChat(ConnectionListener.ChatScope.SERVER, 0, "", text); sound(SoundEvent.CHAT_SENT_MESSAGE_SERVER, serverVars()); } catch (Exception e) { error("Message failed: " + rootMessage(e)); @@ -771,6 +781,16 @@ public final class TeamspeakConnection implements TS3Listener { new Thread(() -> { try { client.sendPrivateMessage(clientId, text); + ClientEntry peer = model.getClient(clientId); + String peerUid = peer != null ? peer.uniqueId : ""; + if (peerUid == null || peerUid.isBlank()) { + try { + Client info = client.getClientInfo(clientId); + if (info != null) peerUid = info.getUniqueIdentifier(); + } catch (Exception ignored) { + } + } + persistOutgoingChat(ConnectionListener.ChatScope.PRIVATE, clientId, peerUid, text); sound(SoundEvent.CHAT_SENT_MESSAGE_CLIENT, clientVars(clientId, null)); } catch (Exception e) { error("Message failed: " + rootMessage(e)); @@ -1199,9 +1219,162 @@ public final class TeamspeakConnection implements TS3Listener { sounds.fire(event, deafened, variables); } + private void loadChatLogs() { + try { + String uniqueId = serverUniqueId(); + if (uniqueId == null) { + chatLogs = null; + ui.onStatus("Chat logs unavailable: server unique id was not reported"); + return; + } + ChatLogStore logs = new ChatLogStore(uniqueId); + chatLogs = logs; + logs.begin(); + List serverHistory = logs.serverHtmlHistory(); + List channelHistory = logs.channelHtmlHistory(); + ui.onChatHistory(ConnectionListener.ChatScope.SERVER, serverHistory); + ui.onChatHistory(ConnectionListener.ChatScope.CHANNEL, channelHistory); + ui.onStatus("Loaded chat logs from " + logs.serverDir() + + " (" + serverHistory.size() + " server, " + channelHistory.size() + " channel)"); + } catch (Exception e) { + chatLogs = null; + ui.onStatus("Chat logs unavailable: " + rootMessage(e)); + } + } + + private String serverUniqueId() { + String uniqueId = validServerUniqueId(firstPresent( + client.getServerProperties().get("virtualserver_unique_identifier"), + client.getServerProperties().get("virtualserver_unique_id"))); + if (uniqueId != null) return uniqueId; + + uniqueId = serverUniqueIdFrom(new SingleCommand("serverinfo", ProtocolRole.CLIENT)); + if (uniqueId != null) return uniqueId; + + try { + SingleCommand command = new SingleCommand("servergetvariables", ProtocolRole.CLIENT); + String serverId = client.getServerProperties().get("virtualserver_id"); + if (serverId != null && !serverId.isBlank()) { + command.add(new CommandSingleParameter("virtualserver_id", serverId)); + } + return serverUniqueIdFrom(command); + } catch (Exception ignored) { + return null; + } + } + + private String serverUniqueIdFrom(SingleCommand command) { + try { + for (SingleCommand response : client.executeCommand(command).get()) { + String uniqueId = validServerUniqueId(firstPresent( + parameterValue(response, "virtualserver_unique_identifier"), + parameterValue(response, "virtualserver_unique_id"))); + if (uniqueId != null) return uniqueId; + } + return null; + } catch (Exception e) { + return null; + } + } + + private static String validServerUniqueId(String value) { + if (value == null || value.isBlank()) return null; + try { + byte[] decoded = Base64.getDecoder().decode(value); + if (decoded.length != 20) return null; + + // TS3 server UIDs are SHA-1-sized binary identifiers represented as base64. + // Plain host strings are rejected before they can become log folder names. + boolean printable = true; + for (byte b : decoded) { + int c = b & 0xff; + if (c < 0x20 || c > 0x7e) { + printable = false; + break; + } + } + return printable ? null : value; + } catch (IllegalArgumentException e) { + return null; + } + } + + private static String firstPresent(String... values) { + for (String value : values) { + if (value != null && !value.isBlank()) return value; + } + return null; + } + + private static String parameterValue(SingleCommand command, String key) { + CommandParameter parameter = command.get(key); + return parameter == null ? null : parameter.getValue(); + } + + public void loadPrivateChatHistory(int clientId, String uniqueId) { + ChatLogStore logs = chatLogs; + if (logs == null || uniqueId == null || uniqueId.isBlank()) return; + try { + ClientEntry c = model.getClient(clientId); + ui.onPrivateChatHistory(clientId, c != null ? c.nickname : "Client " + clientId, + logs.privateHtmlHistory(uniqueId)); + } catch (IOException e) { + ui.onStatus("Private chat log unavailable: " + rootMessage(e)); + } + } + + void persistIncomingChat(ConnectionListener.ChatScope scope, int fromClientId, String fromUniqueId, + String fromName, String message) { + ChatLogStore logs = chatLogs; + if (logs == null) return; + try { + logs.appendMessage(toLogScope(scope), false, fromClientId, fromUniqueId, fromName, message); + } catch (IOException e) { + ui.onStatus("Could not write chat log: " + rootMessage(e)); + } + } + + private void persistOutgoingChat(ConnectionListener.ChatScope scope, int peerClientId, String peerUniqueId, + String message) { + ChatLogStore logs = chatLogs; + if (logs == null) return; + try { + ClientEntry self = model.getClient(selfClientId); + String uid = identity != null ? identity.getUid().toBase64() : ""; + String name = self != null ? self.nickname : settings.nickname; + if (scope == ConnectionListener.ChatScope.PRIVATE) { + if (peerUniqueId == null || peerUniqueId.isBlank()) return; + logs.appendPrivateMessage(peerUniqueId, true, selfClientId, uid, name, message); + } else { + logs.appendMessage(toLogScope(scope), true, selfClientId, uid, name, message); + } + } catch (IOException e) { + ui.onStatus("Could not write chat log: " + rootMessage(e)); + } + } + + private static ChatLogScope toLogScope(ConnectionListener.ChatScope scope) { + return switch (scope) { + case SERVER -> ChatLogScope.SERVER; + case CHANNEL -> ChatLogScope.CHANNEL; + case PRIVATE -> ChatLogScope.PRIVATE; + }; + } + /** Records a line in the server tab's log, the way native TS3 reports server activity. */ void log(String message) { - if (connected) ui.onServerLog(message); + if (!connected) return; + ChatLogStore logs = chatLogs; + if (logs == null) { + ui.onServerLog(message); + return; + } + try { + ui.onServerLogHtml(logs.appendServerEvent(message)); + } catch (IOException e) { + ui.onServerLog(message); + ui.onStatus("Could not write server log: " + rootMessage(e)); + } } String channelName(int channelId) { 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 1c528ee..a1d7ee0 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 @@ -29,6 +29,7 @@ import java.io.IOException; import java.text.SimpleDateFormat; import java.util.Date; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; /** @@ -138,13 +139,30 @@ public final class ChatPanel extends JPanel { /** Opens (or focuses) the private chat with {@code clientId}. */ public void openPrivateChat(int clientId, String nickname) { + openPrivateChat(clientId, "", nickname); + } + + /** Opens (or focuses) the private chat with {@code clientId}. */ + public void openPrivateChat(int clientId, String uniqueId, String nickname) { edt(() -> { - Tab tab = privateTab(clientId, nickname); + Tab tab = privateTab(clientId, uniqueId, nickname); tabs.setSelectedComponent(tab.scroll); input.requestFocusInWindow(); }); } + public void setServerHistory(List htmlEntries) { + edt(() -> serverTab.setHistory(htmlEntries)); + } + + public void setChannelHistory(List htmlEntries) { + edt(() -> channelTab.setHistory(htmlEntries)); + } + + public void setPrivateHistory(int clientId, String nickname, List htmlEntries) { + edt(() -> privateTab(clientId, "", nickname).setHistory(htmlEntries)); + } + /** Drops every private chat tab, e.g. after disconnecting. */ public void closePrivateChats() { edt(() -> { @@ -214,17 +232,35 @@ public final class ChatPanel extends JPanel { + BBCode.linksToHtml(text) + "")); } + public void appendServerLogHtml(String html) { + edt(() -> serverTab.appendRawHtml(html)); + } + public void appendServerMessage(int fromId, String from, String text) { - edt(() -> serverTab.appendMessage(fromId, from, text)); + appendServerMessage(fromId, "", from, text); + } + + public void appendServerMessage(int fromId, String uniqueId, String from, String text) { + edt(() -> serverTab.appendMessage(fromId, uniqueId, from, text)); } public void appendChannelMessage(int fromId, String from, String text) { - edt(() -> channelTab.appendMessage(fromId, from, text)); + appendChannelMessage(fromId, "", from, text); + } + + public void appendChannelMessage(int fromId, String uniqueId, String from, String text) { + edt(() -> channelTab.appendMessage(fromId, uniqueId, from, text)); } /** Appends to the private chat with {@code peerId}, creating the tab if needed. */ public void appendPrivateMessage(int peerId, String peerName, int fromId, String from, String text) { - edt(() -> privateTab(peerId, peerName).appendMessage(fromId, from, text)); + appendPrivateMessage(peerId, "", peerName, fromId, "", from, text); + } + + /** Appends to the private chat with {@code peerId}, creating the tab if needed. */ + public void appendPrivateMessage(int peerId, String peerUniqueId, String peerName, + int fromId, String fromUniqueId, String from, String text) { + edt(() -> privateTab(peerId, peerUniqueId, peerName).appendMessage(fromId, fromUniqueId, from, text)); } private String stamp() { @@ -232,11 +268,17 @@ public final class ChatPanel extends JPanel { } private Tab privateTab(int clientId, String nickname) { + return privateTab(clientId, "", nickname); + } + + private Tab privateTab(int clientId, String uniqueId, String nickname) { Tab tab = privateTabs.get(clientId); if (tab == null) { - tab = new Tab(Target.PRIVATE, clientId, nickname, Icons.privateChat()); + tab = new Tab(Target.PRIVATE, clientId, uniqueId, nickname, Icons.privateChat()); privateTabs.put(clientId, tab); addTab(tab, true); + } else { + tab.setClientUniqueId(uniqueId); } return tab; } @@ -301,6 +343,7 @@ public final class ChatPanel extends JPanel { final Target target; final int clientId; + private String clientUniqueId; private String title; final JEditorPane log = HtmlStyles.pane("font-family:sans-serif; font-size:11px; margin:4px 6px;"); final JScrollPane scroll; @@ -311,17 +354,23 @@ public final class ChatPanel extends JPanel { private JLabel titleLabel; Tab(Target target, int clientId, String title, Icon icon) { - this(target, clientId, title, icon, null, null); + this(target, clientId, "", title, icon, null, null); + } + + Tab(Target target, int clientId, String clientUniqueId, String title, Icon icon) { + this(target, clientId, clientUniqueId, title, icon, null, null); } /** A static-content tab: no send target, closing it runs {@code onClose}. */ Tab(String noteKey, Icon icon, String title, Runnable onClose) { - this(null, 0, title, icon, noteKey, onClose); + this(null, 0, "", title, icon, noteKey, onClose); } - private Tab(Target target, int clientId, String title, Icon icon, String noteKey, Runnable onClose) { + private Tab(Target target, int clientId, String clientUniqueId, String title, + Icon icon, String noteKey, Runnable onClose) { this.target = target; this.clientId = clientId; + this.clientUniqueId = clientUniqueId == null ? "" : clientUniqueId; this.title = title; this.icon = icon; this.noteKey = noteKey; @@ -338,6 +387,10 @@ public final class ChatPanel extends JPanel { scroll.setBorder(BorderFactory.createEmptyBorder()); } + void setClientUniqueId(String uniqueId) { + if (uniqueId != null && !uniqueId.isBlank()) clientUniqueId = uniqueId; + } + private void onLink(String href) { if (href == null) return; TsLink.Ref ref = TsLink.parse(href); @@ -405,16 +458,27 @@ public final class ChatPanel extends JPanel { titleLabel.setForeground(unread ? Theme.accent() : null); } - void appendMessage(int fromId, String from, String text) { + void appendMessage(int fromId, String fromUniqueId, String from, String text) { String name = BBCode.escape(from); String sender = fromId > 0 ? "" + name + "" + + BBCode.escape(TsLink.clientHref(fromId, + fromUniqueId == null || fromUniqueId.isBlank() ? clientUniqueId : fromUniqueId, + from)) + "\">" + name + "" : "" + name + ""; appendLine("" + stamp() + "" + sender + ": " + BBCode.toHtml(text)); } + void setHistory(List htmlEntries) { + StringBuilder html = new StringBuilder(); + if (htmlEntries != null) { + for (String entry : htmlEntries) html.append(normalizeTs3LogEntry(entry)); + } + log.setText("
" + html + "
"); + log.setCaretPosition(log.getDocument().getLength()); + } + /** Replaces the whole log with fixed content; used by static-content tabs. */ void setStaticContent(String html) { log.setText("
" + html + "
"); @@ -422,13 +486,26 @@ public final class ChatPanel extends JPanel { } void appendLine(String html) { + appendRawHtml("
" + html + "
"); + } + + void appendRawHtml(String html) { try { HTMLDocument doc = (HTMLDocument) log.getDocument(); - doc.insertBeforeEnd(doc.getElement("chatlog"), "
" + html + "
"); + doc.insertBeforeEnd(doc.getElement("chatlog"), normalizeTs3LogEntry(html)); log.setCaretPosition(doc.getLength()); } catch (BadLocationException | IOException ignored) { } if (tabs.getSelectedComponent() != scroll) setUnread(true); } + + private String normalizeTs3LogEntry(String html) { + if (html == null || html.isBlank()) return ""; + String normalized = html + .replaceAll("(?i)]*>", "") + .replaceFirst("(?i)^\\s*\\s*$", ""); + return normalized.startsWith("" + normalized + ""; + } } } diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTabConnectionEvents.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTabConnectionEvents.java index 30c9414..9048b38 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTabConnectionEvents.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTabConnectionEvents.java @@ -7,6 +7,7 @@ import com.ts3client.net.TeamspeakConnection; import javax.swing.JOptionPane; import javax.swing.SwingUtilities; +import java.util.List; /** * Marshals {@link ConnectionListener} callbacks onto the EDT and fans them out to @@ -129,18 +130,34 @@ final class ServerTabConnectionEvents implements ConnectionListener { } @Override - public void onChat(ChatScope scope, int fromClientId, String fromName, String message) { - switch (scope) { - case PRIVATE: - chatPanel.appendPrivateMessage(fromClientId, fromName, fromClientId, fromName, message); - break; - case SERVER: - chatPanel.appendServerMessage(fromClientId, fromName, message); - break; - default: - chatPanel.appendChannelMessage(fromClientId, fromName, message); - break; - } + public void onChat(ChatScope scope, int fromClientId, String fromUniqueId, String fromName, String message) { + SwingUtilities.invokeLater(() -> { + switch (scope) { + case PRIVATE: + chatPanel.appendPrivateMessage(fromClientId, fromUniqueId, fromName, + fromClientId, fromUniqueId, fromName, message); + break; + case SERVER: + chatPanel.appendServerMessage(fromClientId, fromUniqueId, fromName, message); + break; + default: + chatPanel.appendChannelMessage(fromClientId, fromUniqueId, fromName, message); + break; + } + }); + } + + @Override + public void onChatHistory(ChatScope scope, List htmlEntries) { + SwingUtilities.invokeLater(() -> { + if (scope == ChatScope.SERVER) chatPanel.setServerHistory(htmlEntries); + else if (scope == ChatScope.CHANNEL) chatPanel.setChannelHistory(htmlEntries); + }); + } + + @Override + public void onPrivateChatHistory(int clientId, String nickname, List htmlEntries) { + SwingUtilities.invokeLater(() -> chatPanel.setPrivateHistory(clientId, nickname, htmlEntries)); } @Override @@ -165,6 +182,11 @@ final class ServerTabConnectionEvents implements ConnectionListener { SwingUtilities.invokeLater(() -> chatPanel.appendServerLog(message)); } + @Override + public void onServerLogHtml(String html) { + SwingUtilities.invokeLater(() -> chatPanel.appendServerLogHtml(html)); + } + @Override public void onPoke(String fromName, String message) { SwingUtilities.invokeLater(() -> { diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTabTreeActions.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTabTreeActions.java index b0c9cbf..4ee5eff 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTabTreeActions.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTabTreeActions.java @@ -123,7 +123,8 @@ final class ServerTabTreeActions implements ServerTreePanel.Actions { @Override public void openPrivateChat(ClientEntry client) { - chatPanel.openPrivateChat(client.id, client.nickname); + chatPanel.openPrivateChat(client.id, client.uniqueId, client.nickname); + conn.loadPrivateChatHistory(client.id, client.uniqueId); } @Override