diff --git a/ts3-client/core/src/main/java/com/ts3client/chatlog/ChatLogScope.java b/ts3-client/core/src/main/java/com/ts3client/chatlog/ChatLogScope.java new file mode 100644 index 0000000..e1fd0ac --- /dev/null +++ b/ts3-client/core/src/main/java/com/ts3client/chatlog/ChatLogScope.java @@ -0,0 +1,7 @@ +package com.ts3client.chatlog; + +public enum ChatLogScope { + SERVER, + CHANNEL, + PRIVATE +} diff --git a/ts3-client/core/src/main/java/com/ts3client/chatlog/ChatLogStore.java b/ts3-client/core/src/main/java/com/ts3client/chatlog/ChatLogStore.java new file mode 100644 index 0000000..21f9603 --- /dev/null +++ b/ts3-client/core/src/main/java/com/ts3client/chatlog/ChatLogStore.java @@ -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 = + "
"; + 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 serverHtmlHistory() throws IOException { + return readLastHtml(log(ChatLogScope.SERVER), HISTORY_LIMIT); + } + + public List channelHtmlHistory() throws IOException { + return readLastTextMessages(log(ChatLogScope.CHANNEL), HISTORY_LIMIT, ChatLogScope.CHANNEL); + } + + public List 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 readLastHtml(LogFiles files, int limit) throws IOException { + if (!Files.isRegularFile(files.html)) return Collections.emptyList(); + List all = Files.readAllLines(files.html, StandardCharsets.UTF_8); + List entries = new ArrayList<>(); + for (String line : all) { + if (line.startsWith("

(entries.subList(entries.size() - limit, entries.size())); + } + + private static List readLastTextMessages(LogFiles files, int limit, ChatLogScope scope) throws IOException { + if (!Files.isRegularFile(files.txt)) return Collections.emptyList(); + List all = Files.readAllLines(files.txt, StandardCharsets.UTF_8); + List 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 "

" + + BBCode.escape(line) + "
"; + } + 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 "
" + + "<" + m.group(1) + "> " + + """ + BBCode.escape(m.group(2)) + "": " + + "" + BBCode.toHtml(m.group(3)) + "
"; + } + + private static String beginHtml(String kind, LocalDateTime timestamp) { + return "

" + infoIcon() + + "*** " + kind + " begins " + BEGIN.format(timestamp) + "

"; + } + + 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 "

" + + (outgoing ? outgoingIcon() : incomingIcon()) + + "<" + TIME.format(timestamp) + "> " + + userLink(clientId, uniqueId, nickname) + + ": " + BBCode.toHtml(message) + "

"; + } + + private static String eventHtml(LocalDateTime timestamp, String html) { + return "

" + infoIcon() + + "<" + TIME.format(timestamp) + "> " + + "" + html + "

"; + } + + private static String userLink(int clientId, String uniqueId, String nickname) { + if (clientId <= 0) return """ + BBCode.escape(nickname) + """; + return """ + + BBCode.escape(nickname) + """; + } + + private static String infoIcon() { + return ""; + } + + private static String incomingIcon() { + return ""; + } + + private static String outgoingIcon() { + return ""; + } + + 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) { + } +} diff --git a/ts3-client/core/src/test/java/com/ts3client/chatlog/ChatLogStoreTest.java b/ts3-client/core/src/test/java/com/ts3client/chatlog/ChatLogStoreTest.java new file mode 100644 index 0000000..96a27cf --- /dev/null +++ b/ts3-client/core/src/test/java/com/ts3client/chatlog/ChatLogStoreTest.java @@ -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 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")); + } +}