Dateien nach "src/main/java/net/viper/status/modules/chat/bridge" hochladen
This commit is contained in:
@@ -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<String, AtomicLong> 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<String> 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<DiscordMessage> 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<DiscordMessage> 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 <TOKEN> ──
|
||||||
|
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<DiscordMessage> parseMessages(String json) {
|
||||||
|
java.util.List<DiscordMessage> 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("\\\\", "\\");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 = "<b>" + escapeHtml(header) + "</b>\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<TelegramUpdate> 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<TelegramUpdate> 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 <TOKEN> ──
|
||||||
|
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: <b>"
|
||||||
|
+ escapeHtml(acc.minecraftName) + "</b>");
|
||||||
|
} 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<TelegramUpdate> parseUpdates(String json) {
|
||||||
|
java.util.List<TelegramUpdate> 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(">", ">");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user