Add the chat log store missing from earlier commits

TeamspeakConnection and ChatPanel already use com.ts3client.chatlog, but the
package itself was never committed, so the tree did not build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-23 19:02:40 +00:00
parent f409f500fd
commit bab89f9b50
3 changed files with 289 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
package com.ts3client.chatlog;
public enum ChatLogScope {
SERVER,
CHANNEL,
PRIVATE
}

View File

@@ -0,0 +1,239 @@
package com.ts3client.chatlog;
import com.ts3client.text.BBCode;
import com.ts3client.text.TsLink;
import java.io.BufferedWriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Base64;
import java.util.Collections;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Reads and writes TeamSpeak-compatible chat logs. The file layout and HTML
* records match the native client closely enough for both clients to consume
* each other's logs.
*/
public final class ChatLogStore {
public static final int HISTORY_LIMIT = 1000;
private static final DateTimeFormatter BEGIN = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private static final DateTimeFormatter TIME = DateTimeFormatter.ofPattern("HH:mm:ss");
private static final String HTML_HEADER =
"<head><meta http-equiv=\"content-type\" content=\"text/html; charset=UTF-8\"></head><br>";
private static final Pattern TEXT_MESSAGE = Pattern.compile("^<([0-9]{2}:[0-9]{2}:[0-9]{2})> ([^:]+): (.*)$");
private final Path serverDir;
public ChatLogStore(String serverUniqueId) {
this(defaultRoot(), serverUniqueId);
}
public ChatLogStore(Path root, String serverUniqueId) {
this.serverDir = root.resolve(encode(serverUniqueId == null || serverUniqueId.isBlank()
? "unknown" : serverUniqueId));
}
public Path serverDir() {
return serverDir;
}
public synchronized void begin() throws IOException {
LocalDateTime now = LocalDateTime.now();
appendRaw(log(ChatLogScope.SERVER), "*** Log begins " + BEGIN.format(now), beginHtml("Log", now));
appendRaw(log(ChatLogScope.CHANNEL), "*** Chat begins " + BEGIN.format(now), beginHtml("Chat", now));
}
public List<String> serverHtmlHistory() throws IOException {
return readLastHtml(log(ChatLogScope.SERVER), HISTORY_LIMIT);
}
public List<String> channelHtmlHistory() throws IOException {
return readLastTextMessages(log(ChatLogScope.CHANNEL), HISTORY_LIMIT, ChatLogScope.CHANNEL);
}
public List<String> privateHtmlHistory(String clientUniqueId) throws IOException {
return readLastTextMessages(privateLog(clientUniqueId), HISTORY_LIMIT, ChatLogScope.PRIVATE);
}
public synchronized String appendMessage(ChatLogScope scope, boolean outgoing, int clientId, String uniqueId,
String nickname, String message) throws IOException {
LogFiles files = scope == ChatLogScope.PRIVATE ? privateLog(uniqueId) : log(scope);
LocalDateTime now = LocalDateTime.now();
String text = "<" + TIME.format(now) + "> " + safe(nickname) + ": " + safe(message);
String html = messageHtml(scope, outgoing, now, clientId, uniqueId, nickname, message);
appendRaw(files, text, html);
return html;
}
public synchronized String appendPrivateMessage(String peerUniqueId, boolean outgoing, int clientId,
String uniqueId, String nickname, String message)
throws IOException {
LocalDateTime now = LocalDateTime.now();
String text = "<" + TIME.format(now) + "> " + safe(nickname) + ": " + safe(message);
String html = messageHtml(ChatLogScope.PRIVATE, outgoing, now, clientId, uniqueId, nickname, message);
appendRaw(privateLog(peerUniqueId), text, html);
return html;
}
public synchronized String appendServerEvent(String message) throws IOException {
LocalDateTime now = LocalDateTime.now();
String text = "<" + TIME.format(now) + "> " + safe(message);
String html = eventHtml(now, BBCode.linksToHtml(message));
appendRaw(log(ChatLogScope.SERVER), text, html);
return html;
}
public static Path defaultRoot() {
return Path.of(System.getProperty("user.home", "."), ".ts3client", "chats");
}
public static String decodePathComponent(String value) {
return new String(Base64.getDecoder().decode(value), StandardCharsets.UTF_8);
}
private LogFiles log(ChatLogScope scope) {
return switch (scope) {
case SERVER -> new LogFiles(serverDir.resolve("server.txt"), serverDir.resolve("server.html"));
case CHANNEL -> new LogFiles(serverDir.resolve("channel.txt"), serverDir.resolve("channel.html"));
case PRIVATE -> throw new IllegalArgumentException("private log needs a client uid");
};
}
private LogFiles privateLog(String clientUniqueId) {
return new LogFiles(
serverDir.resolve("clients").resolve(encode(safe(clientUniqueId)) + ".txt"),
serverDir.resolve("clients").resolve(encode(safe(clientUniqueId)) + ".html"));
}
private void appendRaw(LogFiles files, String textLine, String htmlLine) throws IOException {
ensure(files);
try (BufferedWriter txt = Files.newBufferedWriter(files.txt, StandardCharsets.UTF_8,
StandardOpenOption.CREATE, StandardOpenOption.APPEND);
BufferedWriter html = Files.newBufferedWriter(files.html, StandardCharsets.UTF_8,
StandardOpenOption.CREATE, StandardOpenOption.APPEND)) {
txt.write(textLine);
txt.newLine();
html.write(htmlLine);
html.newLine();
}
}
private static void ensure(LogFiles files) throws IOException {
Files.createDirectories(files.txt.getParent());
Files.createDirectories(files.html.getParent());
if (!Files.exists(files.html)) {
Files.writeString(files.html, HTML_HEADER + System.lineSeparator(), StandardCharsets.UTF_8,
StandardOpenOption.CREATE, StandardOpenOption.APPEND);
}
if (!Files.exists(files.txt)) Files.createFile(files.txt);
}
private static List<String> readLastHtml(LogFiles files, int limit) throws IOException {
if (!Files.isRegularFile(files.html)) return Collections.emptyList();
List<String> all = Files.readAllLines(files.html, StandardCharsets.UTF_8);
List<String> entries = new ArrayList<>();
for (String line : all) {
if (line.startsWith("<p ")) entries.add(line);
}
if (entries.size() <= limit) return entries;
return new ArrayList<>(entries.subList(entries.size() - limit, entries.size()));
}
private static List<String> readLastTextMessages(LogFiles files, int limit, ChatLogScope scope) throws IOException {
if (!Files.isRegularFile(files.txt)) return Collections.emptyList();
List<String> all = Files.readAllLines(files.txt, StandardCharsets.UTF_8);
List<String> entries = new ArrayList<>();
for (String line : all) {
String entry = textLineToHtml(line, scope);
if (entry == null) continue;
entries.add(entry);
}
if (entries.size() <= limit) return entries;
return new ArrayList<>(entries.subList(entries.size() - limit, entries.size()));
}
private static String textLineToHtml(String line, ChatLogScope scope) {
if (line == null || line.isBlank()) return null;
if (line.startsWith("*** ")) {
return "<div class=\"TextMessage_BeginHistory\"><span class=\"Body\">"
+ BBCode.escape(line) + "</span></div>";
}
Matcher m = TEXT_MESSAGE.matcher(line);
if (!m.matches()) return null;
String target = switch (scope) {
case PRIVATE -> "Private";
case CHANNEL -> "Channel";
case SERVER -> "Server";
};
return "<div class=\"TextMessage_Message Incoming Neutral " + target + "\"><span class=\"Body\">"
+ "<span class=\"TextMessage_Time\">&lt;" + m.group(1) + "&gt;</span> "
+ "<span class=\"TextMessage_UserLink\">&quot;" + BBCode.escape(m.group(2)) + "&quot;</span>: "
+ "<span class=\"TextMessage_Text\">" + BBCode.toHtml(m.group(3)) + "</span></span></div>";
}
private static String beginHtml(String kind, LocalDateTime timestamp) {
return "<p class=\"TextMessage_BeginHistory\"><span class=\"Body\">" + infoIcon()
+ "*** " + kind + " begins " + BEGIN.format(timestamp) + "</span></p>";
}
private static String messageHtml(ChatLogScope scope, boolean outgoing, LocalDateTime timestamp,
int clientId, String uniqueId, String nickname, String message) {
String target = switch (scope) {
case PRIVATE -> "Private";
case CHANNEL -> "Channel";
case SERVER -> "Server";
};
return "<p class=\"TextMessage_Message " + (outgoing ? "Outgoing" : "Incoming")
+ " Neutral " + target + "\"><span class=\"Body\">"
+ (outgoing ? outgoingIcon() : incomingIcon())
+ "<span class=\"TextMessage_Time\">&lt;" + TIME.format(timestamp) + "&gt;</span> "
+ userLink(clientId, uniqueId, nickname)
+ ": <span class=\"TextMessage_Text\">" + BBCode.toHtml(message) + "</span></span></p>";
}
private static String eventHtml(LocalDateTime timestamp, String html) {
return "<p class=\"TextMessage_ServerError\"><span class=\"Body\">" + infoIcon()
+ "<span class=\"TextMessage_Time\">&lt;" + TIME.format(timestamp) + "&gt;</span> "
+ "<span class=\"TextMessage_Text\">" + html + "</span></span></p>";
}
private static String userLink(int clientId, String uniqueId, String nickname) {
if (clientId <= 0) return "&quot;" + BBCode.escape(nickname) + "&quot;";
return "<a class=\"TextMessage_UserLink\" href=\""
+ BBCode.escape(TsLink.clientHref(clientId, uniqueId, nickname)) + "\">&quot;"
+ BBCode.escape(nickname) + "&quot;</a>";
}
private static String infoIcon() {
return "<img src=\"iconpath:MESSAGE_INFO?size=17x17\" width=\"17\" height=\"17\">";
}
private static String incomingIcon() {
return "<img src=\"iconpath:MESSAGE_INCOMING?size=17x17\" width=\"17\" height=\"17\">";
}
private static String outgoingIcon() {
return "<img src=\"iconpath:MESSAGE_OUTGOING?size=17x17\" width=\"17\" height=\"17\">";
}
private static String encode(String value) {
return Base64.getEncoder().encodeToString(safe(value).getBytes(StandardCharsets.UTF_8));
}
private static String safe(String value) {
return value == null ? "" : value;
}
private record LogFiles(Path txt, Path html) {
}
}

View File

@@ -0,0 +1,43 @@
package com.ts3client.chatlog;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class ChatLogStoreTest {
@TempDir
Path temp;
@Test
void writesTs3CompatibleClientLogNamesAndEntries() throws Exception {
ChatLogStore logs = new ChatLogStore(temp, "+Tyg2JtxE8vRNZp+JiUBnmBh0MY=");
logs.begin();
logs.appendPrivateMessage("cckqISCrfr6Lq2SLMC//afsbO9w=", true,
610, "bdrlePoB9hU8K8CyjCabY07t3Wo=", "ericek111", "hello");
Path serverDir = temp.resolve("K1R5ZzJKdHhFOHZSTlpwK0ppVUJubUJoME1ZPQ==");
Path clientHtml = serverDir.resolve("clients")
.resolve("Y2NrcUlTQ3JmcjZMcTJTTE1DLy9hZnNiTzl3PQ==.html");
Path clientTxt = serverDir.resolve("clients")
.resolve("Y2NrcUlTQ3JmcjZMcTJTTE1DLy9hZnNiTzl3PQ==.txt");
assertTrue(Files.isRegularFile(serverDir.resolve("server.html")));
assertTrue(Files.isRegularFile(serverDir.resolve("channel.html")));
assertTrue(Files.isRegularFile(clientHtml));
assertEquals(1, Files.readAllLines(clientTxt).size());
List<String> history = logs.privateHtmlHistory("cckqISCrfr6Lq2SLMC//afsbO9w=");
assertEquals(1, history.size());
assertTrue(history.get(0).contains("TextMessage_Message"));
assertTrue(history.get(0).contains("ericek111"));
assertTrue(history.get(0).contains("hello"));
}
}