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