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:
@@ -0,0 +1,7 @@
|
|||||||
|
package com.ts3client.chatlog;
|
||||||
|
|
||||||
|
public enum ChatLogScope {
|
||||||
|
SERVER,
|
||||||
|
CHANNEL,
|
||||||
|
PRIVATE
|
||||||
|
}
|
||||||
@@ -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\"><" + m.group(1) + "></span> "
|
||||||
|
+ "<span class=\"TextMessage_UserLink\">"" + BBCode.escape(m.group(2)) + ""</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\"><" + TIME.format(timestamp) + "></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\"><" + TIME.format(timestamp) + "></span> "
|
||||||
|
+ "<span class=\"TextMessage_Text\">" + html + "</span></span></p>";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String userLink(int clientId, String uniqueId, String nickname) {
|
||||||
|
if (clientId <= 0) return """ + BBCode.escape(nickname) + """;
|
||||||
|
return "<a class=\"TextMessage_UserLink\" href=\""
|
||||||
|
+ BBCode.escape(TsLink.clientHref(clientId, uniqueId, nickname)) + "\">""
|
||||||
|
+ BBCode.escape(nickname) + ""</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) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user