diff --git a/src/main/java/net/viper/status/modules/chat/bridge/DiscordBridge.java b/src/main/java/net/viper/status/modules/chat/bridge/DiscordBridge.java new file mode 100644 index 0000000..2e8a4e6 --- /dev/null +++ b/src/main/java/net/viper/status/modules/chat/bridge/DiscordBridge.java @@ -0,0 +1,424 @@ +package net.viper.status.modules.chat.bridge; + +import net.md_5.bungee.api.ChatColor; +import net.md_5.bungee.api.ProxyServer; +import net.md_5.bungee.api.chat.TextComponent; +import net.md_5.bungee.api.plugin.Plugin; +import net.viper.status.modules.chat.AccountLinkManager; +import net.viper.status.modules.chat.ChatConfig; + +import java.io.*; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; +import java.util.logging.Logger; + +/** + * Discord-Brücke für bidirektionale Kommunikation. + * + * Minecraft → Discord: Via Webhook (kein Bot benötigt) + * Discord → Minecraft: Via Bot-Polling der Discord REST-API + * + * Voraussetzungen: + * - Bot mit "Read Message History" und "Send Messages" Permissions + * - Bot muss in den jeweiligen Kanälen sein + * - Bot-Token in chat.yml eintragen + */ +public class DiscordBridge { + + private final Plugin plugin; + private final ChatConfig config; + private final Logger logger; + private AccountLinkManager linkManager; + + // Letztes verarbeitetes Discord Message-ID pro Kanal (für Polling) + private final java.util.Map lastMessageIds = new java.util.concurrent.ConcurrentHashMap<>(); + + private volatile boolean running = false; + + public DiscordBridge(Plugin plugin, ChatConfig config) { + this.plugin = plugin; + this.config = config; + this.logger = plugin.getLogger(); + } + + /** Setzt den AccountLinkManager – muss vor start() aufgerufen werden. */ + public void setLinkManager(AccountLinkManager linkManager) { + this.linkManager = linkManager; + } + + public void start() { + if (!config.isDiscordEnabled() + || config.getDiscordBotToken().isEmpty() + || config.getDiscordBotToken().equals("YOUR_BOT_TOKEN_HERE")) { + logger.warning("[ChatModule-Discord] Bot-Token nicht konfiguriert. Discord-Empfang deaktiviert."); + return; + } + + running = true; + + // Starte Polling-Task für alle konfigurierten Kanäle + int interval = Math.max(2, config.getDiscordPollInterval()); + plugin.getProxy().getScheduler().schedule(plugin, this::pollAllChannels, + interval, interval, TimeUnit.SECONDS); + + logger.info("[ChatModule-Discord] Brücke gestartet (Poll-Intervall: " + interval + "s)."); + } + + public void stop() { + running = false; + } + + // ===== Minecraft → Discord ===== + + /** + * Sendet eine Nachricht via Webhook an Discord. + * Funktioniert ohne Bot-Token! + */ + public void sendToDiscord(String webhookUrl, String username, String message, String avatarUrl) { + if (webhookUrl == null || webhookUrl.isEmpty()) return; + + plugin.getProxy().getScheduler().runAsync(plugin, () -> { + try { + String safeUsername = escapeJson(username); + String safeMessage = escapeJson(message); + String payload = "{\"username\":\"" + safeUsername + "\"" + + (avatarUrl != null && !avatarUrl.isEmpty() + ? ",\"avatar_url\":\"" + avatarUrl + "\"" + : "") + + ",\"content\":\"" + safeMessage + "\"}"; + + postJson(webhookUrl, payload, null); + } catch (Exception e) { + logger.warning("[ChatModule-Discord] Webhook-Fehler: " + e.getMessage()); + } + }); + } + + /** + * Sendet eine Embed-Nachricht (für HelpOp, Broadcast) an einen Discord-Kanal via Webhook. + */ + public void sendEmbedToDiscord(String webhookUrl, String title, String description, String colorHex) { + if (webhookUrl == null || webhookUrl.isEmpty()) return; + + plugin.getProxy().getScheduler().runAsync(plugin, () -> { + try { + int color = 0; + try { color = Integer.parseInt(colorHex.replace("#", ""), 16); } + catch (Exception ignored) { color = 0x5865F2; } + + String payload = "{\"embeds\":[{\"title\":\"" + escapeJson(title) + "\"" + + ",\"description\":\"" + escapeJson(description) + "\"" + + ",\"color\":" + color + "}]}"; + + logger.info("[ChatModule-Discord] Sende Embed an Webhook: " + webhookUrl); + logger.info("[ChatModule-Discord] Payload: " + payload); + + postJson(webhookUrl, payload, null); + + logger.info("[ChatModule-Discord] Embed erfolgreich an Discord gesendet."); + } catch (Exception e) { + logger.warning("[ChatModule-Discord] Embed-Fehler: " + e.getMessage()); + } + }); + } + + /** + * Sendet eine Nachricht direkt in einen Discord-Kanal via Bot-Token. + * Benötigt: DISCORD_BOT_TOKEN, channel-id + */ + public void sendToChannel(String channelId, String message) { + if (channelId == null || channelId.isEmpty()) return; + if (config.getDiscordBotToken().isEmpty()) return; + + plugin.getProxy().getScheduler().runAsync(plugin, () -> { + try { + String url = "https://discord.com/api/v10/channels/" + channelId + "/messages"; + String payload = "{\"content\":\"" + escapeJson(message) + "\"}"; + postJson(url, payload, "Bot " + config.getDiscordBotToken()); + } catch (Exception e) { + logger.warning("[ChatModule-Discord] Send-to-Channel-Fehler: " + e.getMessage()); + } + }); + } + + // ===== Discord → Minecraft (Polling) ===== + + private void pollAllChannels() { + if (!running) return; + + // Alle Kanal-IDs aus der Konfiguration sammeln + java.util.Set channelIds = new java.util.LinkedHashSet<>(); + + for (net.viper.status.modules.chat.ChatChannel ch : config.getChannels().values()) { + if (!ch.getDiscordChannelId().isEmpty()) { + channelIds.add(ch.getDiscordChannelId()); + } + } + if (!config.getDiscordAdminChannelId().isEmpty()) { + channelIds.add(config.getDiscordAdminChannelId()); + } + + for (String channelId : channelIds) { + pollChannel(channelId); + } + } + + private void pollChannel(String channelId) { + try { + AtomicLong lastId = lastMessageIds.computeIfAbsent(channelId, k -> new AtomicLong(0L)); + + // Beim ersten Poll: aktuelle neueste ID holen und merken, nicht broadcasten. + // So werden beim Start keine alten Discord-Nachrichten in Minecraft angezeigt. + if (lastId.get() == 0L) { + String initUrl = "https://discord.com/api/v10/channels/" + channelId + "/messages?limit=1"; + String initResp = getJson(initUrl, "Bot " + config.getDiscordBotToken()); + if (initResp != null && !initResp.equals("[]") && !initResp.isEmpty()) { + java.util.List initMsgs = parseMessages(initResp); + if (!initMsgs.isEmpty()) { + lastId.set(initMsgs.get(0).id); + } + } + return; // Erster Poll nur zum Initialisieren, nichts broadcasten + } + + String afterParam = "?after=" + lastId.get() + "&limit=10"; + + String url = "https://discord.com/api/v10/channels/" + channelId + "/messages" + afterParam; + String response = getJson(url, "Bot " + config.getDiscordBotToken()); + if (response == null || response.equals("[]") || response.isEmpty()) return; + + // JSON-Array von Nachrichten parsen (ohne externe Library) + java.util.List messages = parseMessages(response); + + // Nachrichten chronologisch verarbeiten (älteste zuerst) + messages.sort(java.util.Comparator.comparingLong(m -> m.id)); + + for (DiscordMessage msg : messages) { + if (msg.id <= lastId.get()) continue; + if (msg.isBot) continue; + if (msg.content.isEmpty()) continue; + + lastId.set(msg.id); + + // ── Token-Einlösung: !link ── + if (msg.content.startsWith("!link ")) { + String token = msg.content.substring(6).trim().toUpperCase(); + if (linkManager != null) { + AccountLinkManager.LinkedAccount acc = + linkManager.redeemDiscord(token, msg.authorId, msg.authorName); + if (acc != null) { + sendToChannel(channelId, + "✅ Verknüpfung erfolgreich! Minecraft-Account: **" + + acc.minecraftName + "**"); + } else { + sendToChannel(channelId, + "❌ Ungültiger oder abgelaufener Token. Bitte `/discordlink` im Spiel erneut ausführen."); + } + } + continue; // Nicht als Chat-Nachricht weiterleiten + } + + // ── Account-Name auflösen ── + String displayName = (linkManager != null) + ? linkManager.resolveDiscordName(msg.authorId, msg.authorName) + : msg.authorName; + + // Welchem Kanal gehört diese Discord-Kanal-ID? + final String mcFormat = resolveFormat(channelId); + if (mcFormat == null) continue; + + final String formatted = ChatColor.translateAlternateColorCodes('&', + mcFormat.replace("{user}", displayName) + .replace("{message}", msg.content)); + + ProxyServer.getInstance().getScheduler().runAsync(plugin, () -> + ProxyServer.getInstance().broadcast(new TextComponent(formatted)) + ); + } + } catch (Exception e) { + logger.fine("[ChatModule-Discord] Poll-Fehler für Kanal " + channelId + ": " + e.getMessage()); + } + } + + private String resolveFormat(String channelId) { + // Admin-Kanal? + if (channelId.equals(config.getDiscordAdminChannelId())) { + return config.getDiscordFromFormat(); + } + // Reguläre Kanäle + for (net.viper.status.modules.chat.ChatChannel ch : config.getChannels().values()) { + if (channelId.equals(ch.getDiscordChannelId())) { + return config.getDiscordFromFormat(); + } + } + return null; + } + + // ===== HTTP-Hilfsklassen ===== + + private void postJson(String urlStr, String payload, String authorization) throws Exception { + HttpURLConnection conn = openConnection(urlStr, "POST", authorization); + byte[] data = payload.getBytes(StandardCharsets.UTF_8); + conn.setRequestProperty("Content-Length", String.valueOf(data.length)); + conn.setDoOutput(true); + try (OutputStream os = conn.getOutputStream()) { os.write(data); } + int code = conn.getResponseCode(); + if (code >= 400) { + String err = readStream(conn.getErrorStream()); + logger.warning("[ChatModule-Discord] HTTP " + code + ": " + err); + } + conn.disconnect(); + } + + private String getJson(String urlStr, String authorization) throws Exception { + HttpURLConnection conn = openConnection(urlStr, "GET", authorization); + int code = conn.getResponseCode(); + if (code != 200) { conn.disconnect(); return null; } + String result = readStream(conn.getInputStream()); + conn.disconnect(); + return result; + } + + private HttpURLConnection openConnection(String urlStr, String method, String authorization) throws Exception { + HttpURLConnection conn = (HttpURLConnection) new URL(urlStr).openConnection(); + conn.setRequestMethod(method); + conn.setConnectTimeout(5000); + conn.setReadTimeout(8000); + conn.setRequestProperty("Content-Type", "application/json"); + conn.setRequestProperty("User-Agent", "StatusAPI-ChatModule/1.0"); + if (authorization != null && !authorization.isEmpty()) { + conn.setRequestProperty("Authorization", authorization); + } + return conn; + } + + private String readStream(InputStream in) throws IOException { + if (in == null) return ""; + try (BufferedReader br = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) { + StringBuilder sb = new StringBuilder(); + String line; + while ((line = br.readLine()) != null) sb.append(line); + return sb.toString(); + } + } + + // ===== JSON Mini-Parser ===== + + /** Repräsentiert eine Discord-Nachricht (minimale Felder). */ + private static class DiscordMessage { + long id; + String authorId = ""; + String authorName = ""; + String content = ""; + boolean isBot = false; + } + + /** + * Parst ein JSON-Array von Discord-Nachrichten ohne externe Bibliothek. + * Nur die benötigten Felder werden extrahiert. + */ + private java.util.List parseMessages(String json) { + java.util.List result = new java.util.ArrayList<>(); + // Jedes Objekt im Array extrahieren + int depth = 0, start = -1; + for (int i = 0; i < json.length(); i++) { + char c = json.charAt(i); + if (c == '{') { if (depth++ == 0) start = i; } + else if (c == '}') { + if (--depth == 0 && start != -1) { + String obj = json.substring(start, i + 1); + DiscordMessage msg = parseMessage(obj); + if (msg != null) result.add(msg); + start = -1; + } + } + } + return result; + } + + private DiscordMessage parseMessage(String obj) { + try { + DiscordMessage msg = new DiscordMessage(); + msg.id = Long.parseLong(extractJsonString(obj, "\"id\"")); + msg.content = unescapeJson(extractJsonString(obj, "\"content\"")); + + // Webhook-Nachrichten herausfiltern (Echo-Loop verhindern): + // Nachrichten die via Webhook gesendet wurden haben "webhook_id" gesetzt. + // Das sind unsere eigenen Minecraft→Discord Nachrichten die wir ignorieren. + String webhookId = extractJsonString(obj, "\"webhook_id\""); + if (!webhookId.isEmpty()) { + msg.isBot = true; // Als Bot markieren → wird übersprungen + return msg; + } + + // Author-Block + int authStart = obj.indexOf("\"author\""); + if (authStart >= 0) { + String authBlock = extractJsonObject(obj, authStart); + msg.authorId = extractJsonString(authBlock, "\"id\""); + msg.authorName = unescapeJson(extractJsonString(authBlock, "\"username\"")); + String botFlag = extractJsonString(authBlock, "\"bot\""); + msg.isBot = "true".equals(botFlag); + } + return msg; + } catch (Exception e) { + return null; + } + } + + private String extractJsonString(String json, String key) { + int keyIdx = json.indexOf(key); + if (keyIdx < 0) return ""; + int colon = json.indexOf(':', keyIdx + key.length()); + if (colon < 0) return ""; + // Wert direkt nach dem Doppelpunkt + int valStart = colon + 1; + while (valStart < json.length() && json.charAt(valStart) == ' ') valStart++; + if (valStart >= json.length()) return ""; + char first = json.charAt(valStart); + if (first == '"') { + // String-Wert + int end = valStart + 1; + while (end < json.length()) { + if (json.charAt(end) == '"' && json.charAt(end - 1) != '\\') break; + end++; + } + return json.substring(valStart + 1, end); + } else { + // Primitiver Wert (Zahl, Boolean) + int end = valStart; + while (end < json.length() && ",}\n".indexOf(json.charAt(end)) < 0) end++; + return json.substring(valStart, end).trim(); + } + } + + private String extractJsonObject(String json, int fromIndex) { + int depth = 0, start = -1; + for (int i = fromIndex; i < json.length(); i++) { + char c = json.charAt(i); + if (c == '{') { if (depth++ == 0) start = i; } + else if (c == '}') { if (--depth == 0 && start >= 0) return json.substring(start, i + 1); } + } + return ""; + } + + private static String escapeJson(String s) { + if (s == null) return ""; + return s.replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t"); + } + + private static String unescapeJson(String s) { + if (s == null) return ""; + return s.replace("\\\"", "\"") + .replace("\\n", "\n") + .replace("\\r", "\r") + .replace("\\\\", "\\"); + } +} \ No newline at end of file diff --git a/src/main/java/net/viper/status/modules/chat/bridge/TelegramBridge.java b/src/main/java/net/viper/status/modules/chat/bridge/TelegramBridge.java new file mode 100644 index 0000000..e4657ec --- /dev/null +++ b/src/main/java/net/viper/status/modules/chat/bridge/TelegramBridge.java @@ -0,0 +1,399 @@ +package net.viper.status.modules.chat.bridge; + +import net.md_5.bungee.api.ChatColor; +import net.md_5.bungee.api.ProxyServer; +import net.md_5.bungee.api.chat.TextComponent; +import net.md_5.bungee.api.plugin.Plugin; +import net.viper.status.modules.chat.AccountLinkManager; +import net.viper.status.modules.chat.ChatConfig; + +import java.io.*; +import java.net.HttpURLConnection; +import java.net.URL; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; +import java.util.logging.Logger; + +/** + * Telegram-Brücke für bidirektionale Kommunikation. + * + * Minecraft → Telegram: Via Bot API (sendMessage) + * Telegram → Minecraft: Via Long-Polling (getUpdates) + * + * Voraussetzungen: + * - Telegram Bot via @BotFather erstellen + * - Bot-Token in chat.yml eintragen + * - Bot in die gewünschten Gruppen/Kanäle einladen + * - Bot zu Admin machen (für Gruppen-Nachrichten empfangen) + */ +public class TelegramBridge { + + private static final String API_BASE = "https://api.telegram.org/bot"; + + private final Plugin plugin; + private final ChatConfig config; + private final Logger logger; + private AccountLinkManager linkManager; // wird nach dem Start gesetzt + + // Letztes verarbeitetes Update-ID (für getUpdates Offset) + private final AtomicLong lastUpdateId = new AtomicLong(0L); + + private volatile boolean running = false; + + public TelegramBridge(Plugin plugin, ChatConfig config) { + this.plugin = plugin; + this.config = config; + this.logger = plugin.getLogger(); + } + + /** Setzt den AccountLinkManager – muss vor start() aufgerufen werden. */ + public void setLinkManager(AccountLinkManager linkManager) { + this.linkManager = linkManager; + } + + public void start() { + if (!config.isTelegramEnabled() + || config.getTelegramBotToken().isEmpty() + || config.getTelegramBotToken().equals("YOUR_TELEGRAM_BOT_TOKEN")) { + logger.warning("[ChatModule-Telegram] Bot-Token nicht konfiguriert. Telegram-Empfang deaktiviert."); + return; + } + + running = true; + int interval = Math.max(2, config.getTelegramPollInterval()); + + plugin.getProxy().getScheduler().schedule(plugin, this::pollUpdates, + interval, interval, TimeUnit.SECONDS); + + logger.info("[ChatModule-Telegram] Brücke gestartet (Poll-Intervall: " + interval + "s)."); + } + + public void stop() { + running = false; + } + + // ===== Minecraft → Telegram ===== + + /** + * Sendet eine Nachricht an eine Telegram-Chat-ID. + * Unterstützt Themen-Gruppen via message_thread_id. + */ + public void sendToTelegram(String chatId, String message) { + sendToTelegram(chatId, 0, message); + } + + public void sendToTelegram(String chatId, int threadId, String message) { + if (chatId == null || chatId.isEmpty()) return; + + plugin.getProxy().getScheduler().runAsync(plugin, () -> { + try { + String cleanMessage = ChatColor.stripColor( + ChatColor.translateAlternateColorCodes('&', message)); + + String url = API_BASE + config.getTelegramBotToken() + + "/sendMessage?chat_id=" + URLEncoder.encode(chatId, "UTF-8") + + "&text=" + URLEncoder.encode(cleanMessage, "UTF-8") + + "&parse_mode=HTML" + + (threadId > 0 ? "&message_thread_id=" + threadId : ""); + + getJson(url); + } catch (Exception e) { + logger.warning("[ChatModule-Telegram] Sende-Fehler: " + e.getMessage()); + } + }); + } + + /** + * Sendet eine formatierte HelpOp/Broadcast-Nachricht an Telegram. + * Unterstützt Themen-Gruppen via message_thread_id. + */ + public void sendFormattedToTelegram(String chatId, String header, String content) { + sendFormattedToTelegram(chatId, 0, header, content); + } + + public void sendFormattedToTelegram(String chatId, int threadId, String header, String content) { + if (chatId == null || chatId.isEmpty()) return; + String text = "" + escapeHtml(header) + "\n" + escapeHtml(content); + + plugin.getProxy().getScheduler().runAsync(plugin, () -> { + try { + String url = API_BASE + config.getTelegramBotToken() + + "/sendMessage?chat_id=" + URLEncoder.encode(chatId, "UTF-8") + + "&text=" + URLEncoder.encode(text, "UTF-8") + + "&parse_mode=HTML" + + (threadId > 0 ? "&message_thread_id=" + threadId : ""); + getJson(url); + } catch (Exception e) { + logger.warning("[ChatModule-Telegram] Format-Sende-Fehler: " + e.getMessage()); + } + }); + } + + // ===== Telegram → Minecraft (Polling) ===== + + private void pollUpdates() { + if (!running) return; + try { + // Beim ersten Poll: nur den aktuellen Offset holen, keine alten Updates verarbeiten + if (lastUpdateId.get() == 0L) { + String initUrl = API_BASE + config.getTelegramBotToken() + + "/getUpdates?limit=1&offset=-1"; + String initResp = getJson(initUrl); + if (initResp != null && initResp.contains("\"ok\":true")) { + java.util.List initUpdates = parseUpdates(initResp); + if (!initUpdates.isEmpty()) { + lastUpdateId.set(initUpdates.get(initUpdates.size() - 1).updateId); + } + } + return; // Erster Poll nur zum Initialisieren + } + + long offset = lastUpdateId.get() + 1; + String url = API_BASE + config.getTelegramBotToken() + + "/getUpdates?timeout=2&limit=10" + + (offset > 0 ? "&offset=" + offset : ""); + + String response = getJson(url); + if (response == null || !response.contains("\"ok\":true")) return; + + java.util.List updates = parseUpdates(response); + + for (TelegramUpdate update : updates) { + if (update.updateId > lastUpdateId.get()) { + lastUpdateId.set(update.updateId); + } + if (update.text == null || update.text.isEmpty()) continue; + if (update.isBot) continue; + + // ── Token-Einlösung: /link ── + if (update.text.startsWith("/link ") || update.text.startsWith("/link@")) { + String[] parts = update.text.split("\\s+", 2); + if (parts.length == 2 && linkManager != null) { + String token = parts[1].trim().toUpperCase(); + AccountLinkManager.LinkedAccount acc = + linkManager.redeemTelegram(token, update.fromId, update.fromName); + if (acc != null) { + sendToTelegram(update.chatId, update.threadId, + "✅ Verknüpfung erfolgreich! Minecraft-Account: " + + escapeHtml(acc.minecraftName) + ""); + } else { + sendToTelegram(update.chatId, update.threadId, + "❌ Ungültiger oder abgelaufener Token. Bitte /telegramlink im Spiel erneut ausführen."); + } + } + continue; // Nicht als Chat-Nachricht weiterleiten + } + + // Bot-Befehle ignorieren + if (update.text.startsWith("/")) continue; + + // ── Account-Name auflösen ── + String displayName = (linkManager != null) + ? linkManager.resolveTelegramName(update.fromId, update.fromName) + : update.fromName; + + // Welchem Minecraft-Kanal gehört diese Telegram-Chat-ID + Thread? + final boolean isAdminChat = update.chatId.equals(config.getTelegramAdminChatId()) + && (config.getTelegramAdminTopicId() == 0 + || config.getTelegramAdminTopicId() == update.threadId); + + // Prüfen ob die Nachricht zu einem konfigurierten Kanal-Thema gehört + final boolean matchesChannel = isAdminChat || matchesTelegramChannel(update); + + if (!matchesChannel && !isAdminChat) continue; + + final String format = config.getTelegramFromFormat(); + final String finalDisplay = displayName; + final String formatted = ChatColor.translateAlternateColorCodes('&', + format.replace("{user}", finalDisplay) + .replace("{message}", update.text)); + + ProxyServer.getInstance().getScheduler().runAsync(plugin, () -> { + if (isAdminChat) { + for (net.md_5.bungee.api.connection.ProxiedPlayer p : + ProxyServer.getInstance().getPlayers()) { + if (p.hasPermission("chat.admin.bypass")) { + p.sendMessage(new TextComponent(formatted)); + } + } + } else { + ProxyServer.getInstance().broadcast(new TextComponent(formatted)); + } + }); + } + } catch (Exception e) { + logger.fine("[ChatModule-Telegram] Poll-Fehler: " + e.getMessage()); + } + } + + // ===== HTTP-Hilfsmethoden ===== + + private String getJson(String urlStr) throws Exception { + HttpURLConnection conn = (HttpURLConnection) new URL(urlStr).openConnection(); + conn.setRequestMethod("GET"); + conn.setConnectTimeout(6000); + conn.setReadTimeout(10000); + conn.setRequestProperty("User-Agent", "StatusAPI-ChatModule/1.0"); + int code = conn.getResponseCode(); + String result = readStream(code == 200 ? conn.getInputStream() : conn.getErrorStream()); + conn.disconnect(); + return result; + } + + private String readStream(InputStream in) throws IOException { + if (in == null) return ""; + try (BufferedReader br = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) { + StringBuilder sb = new StringBuilder(); + String line; + while ((line = br.readLine()) != null) sb.append(line); + return sb.toString(); + } + } + + // ===== JSON Mini-Parser ===== + + private static class TelegramUpdate { + long updateId; + String chatId = ""; + String fromId = ""; // Telegram User-ID (für Account-Link) + String fromName = ""; + String text = ""; + boolean isBot = false; + int threadId = 0; // message_thread_id für Themen-Gruppen (0 = kein Thema) + } + + private java.util.List parseUpdates(String json) { + java.util.List result = new java.util.ArrayList<>(); + // Suche nach "result":[...] + int resultStart = json.indexOf("\"result\":["); + if (resultStart < 0) return result; + + // Extrahiere alle Update-Objekte + int depth = 0, start = -1; + boolean inResult = false; + for (int i = resultStart + 10; i < json.length(); i++) { + char c = json.charAt(i); + if (c == '[' && !inResult) { inResult = true; continue; } + if (!inResult) continue; + if (c == '{') { if (depth++ == 0) start = i; } + else if (c == '}') { + if (--depth == 0 && start >= 0) { + TelegramUpdate upd = parseUpdate(json.substring(start, i + 1)); + if (upd != null) result.add(upd); + start = -1; + } + } else if (c == ']' && depth == 0) break; + } + return result; + } + + /** Prüft ob ein Update zu einem konfigurierten Kanal-Thema gehört. */ + private boolean matchesTelegramChannel(TelegramUpdate update) { + for (net.viper.status.modules.chat.ChatChannel ch : config.getChannels().values()) { + if (!ch.getTelegramChatId().equals(update.chatId)) continue; + // Thema konfiguriert? → Thread-ID muss übereinstimmen + if (ch.getTelegramThreadId() > 0 && ch.getTelegramThreadId() != update.threadId) continue; + return true; + } + return false; + } + + private TelegramUpdate parseUpdate(String obj) { + try { + TelegramUpdate upd = new TelegramUpdate(); + upd.updateId = Long.parseLong(extractValue(obj, "update_id")); + + // message-Block + int msgIdx = obj.indexOf("\"message\""); + if (msgIdx < 0) return null; + String msgBlock = extractObject(obj, msgIdx); + + upd.text = unescapeJson(extractString(msgBlock, "text")); + + // message_thread_id (Themen-Gruppen) + String threadIdStr = extractValue(msgBlock, "message_thread_id"); + if (!threadIdStr.isEmpty()) { + try { upd.threadId = Integer.parseInt(threadIdStr); } catch (Exception ignored) {} + } + + // from-Block (Absender) + int fromIdx = msgBlock.indexOf("\"from\""); + if (fromIdx >= 0) { + String fromBlock = extractObject(msgBlock, fromIdx); + String firstName = unescapeJson(extractString(fromBlock, "first_name")); + String lastName = unescapeJson(extractString(fromBlock, "last_name")); + String username = unescapeJson(extractString(fromBlock, "username")); + upd.fromId = extractValue(fromBlock, "id"); + upd.fromName = !username.isEmpty() ? "@" + username + : (firstName + (lastName.isEmpty() ? "" : " " + lastName)).trim(); + String botFlag = extractValue(fromBlock, "is_bot"); + upd.isBot = "true".equals(botFlag); + } + + // chat-Block (Chat-ID) + int chatIdx = msgBlock.indexOf("\"chat\""); + if (chatIdx >= 0) { + String chatBlock = extractObject(msgBlock, chatIdx); + upd.chatId = extractValue(chatBlock, "id"); + } + + return upd; + } catch (Exception e) { + return null; + } + } + + private String extractValue(String json, String key) { + String fullKey = "\"" + key + "\""; + int idx = json.indexOf(fullKey); + if (idx < 0) return ""; + int colon = json.indexOf(':', idx + fullKey.length()); + if (colon < 0) return ""; + int valStart = colon + 1; + while (valStart < json.length() && json.charAt(valStart) == ' ') valStart++; + if (valStart >= json.length()) return ""; + char first = json.charAt(valStart); + if (first == '"') { + return extractString(json.substring(valStart - 1 - key.length()), key); + } + int end = valStart; + while (end < json.length() && ",}\n".indexOf(json.charAt(end)) < 0) end++; + return json.substring(valStart, end).trim(); + } + + private String extractString(String json, String key) { + String fullKey = "\"" + key + "\":\""; + int idx = json.indexOf(fullKey); + if (idx < 0) return ""; + int start = idx + fullKey.length(); + int end = start; + while (end < json.length()) { + if (json.charAt(end) == '"' && json.charAt(end - 1) != '\\') break; + end++; + } + return json.substring(start, end); + } + + private String extractObject(String json, int fromIndex) { + int depth = 0, start = -1; + for (int i = fromIndex; i < json.length(); i++) { + char c = json.charAt(i); + if (c == '{') { if (depth++ == 0) start = i; } + else if (c == '}') { if (--depth == 0 && start >= 0) return json.substring(start, i + 1); } + } + return ""; + } + + private static String unescapeJson(String s) { + if (s == null) return ""; + return s.replace("\\\"", "\"").replace("\\n", "\n") + .replace("\\r", "\r").replace("\\\\", "\\"); + } + + private static String escapeHtml(String s) { + if (s == null) return ""; + return s.replace("&", "&").replace("<", "<").replace(">", ">"); + } +} \ No newline at end of file