diff --git a/bot-integration/mythic-api.js b/bot-integration/mythic-api.js new file mode 100644 index 0000000..f486cff --- /dev/null +++ b/bot-integration/mythic-api.js @@ -0,0 +1,67 @@ +const http = require("node:http"); +const crypto = require("node:crypto"); + +const MAX_BODY_BYTES = 8192; + +// Configure ICREATE_MYTHIC_API_TOKEN and ICREATE_MYTHIC_EVENTS_CHANNEL_ID. +// Mount behind an HTTPS reverse proxy if the Minecraft server is remote. +// Usage: const { startMythicApi } = require("./mythic-api"); startMythicApi(client); +function startMythicApi(client, options = {}) { + const token = process.env.ICREATE_MYTHIC_API_TOKEN; + const channelId = process.env.ICREATE_MYTHIC_EVENTS_CHANNEL_ID; + const host = options.host || process.env.ICREATE_MYTHIC_API_HOST || "127.0.0.1"; + const port = Number(options.port || process.env.ICREATE_MYTHIC_API_PORT || 8787); + + if (!token || token.length < 32) throw new Error("ICREATE_MYTHIC_API_TOKEN must be at least 32 characters."); + if (!channelId) throw new Error("ICREATE_MYTHIC_EVENTS_CHANNEL_ID must identify the events channel."); + if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("Invalid ICREATE_MYTHIC_API_PORT."); + + const expected = Buffer.from(token, "utf8"); + const server = http.createServer(async (req, res) => { + res.setHeader("Cache-Control", "no-store"); + res.setHeader("X-Content-Type-Options", "nosniff"); + if (req.method !== "POST" || req.url !== "/api/icreate/events") return res.writeHead(404).end(); + + const auth = req.headers.authorization || ""; + const supplied = Buffer.from(auth.startsWith("Bearer ") ? auth.slice(7) : "", "utf8"); + if (supplied.length !== expected.length || !crypto.timingSafeEqual(supplied, expected)) { + return res.writeHead(401, { "Content-Type": "application/json" }).end(JSON.stringify({ error: "unauthorized" })); + } + if (!String(req.headers["content-type"] || "").toLowerCase().startsWith("application/json")) return res.writeHead(415).end(); + + try { + const chunks = []; + let size = 0; + for await (const chunk of req) { + size += chunk.length; + if (size > MAX_BODY_BYTES) { + res.writeHead(413).end(); + req.destroy(); + return; + } + chunks.push(chunk); + } + const body = JSON.parse(Buffer.concat(chunks).toString("utf8")); + if (!body || body.source !== "icreate-order-networking" || typeof body.event !== "string" || + typeof body.details !== "string" || typeof body.timestamp !== "string") { + return res.writeHead(400, { "Content-Type": "application/json" }).end(JSON.stringify({ error: "invalid event payload" })); + } + + const eventName = body.event.replace(/[\r\n\x00-\x1f]/g, " ").slice(0, 80); + const details = body.details.replace(/@everyone/g, "@\u200beveryone") + .replace(/@here/g, "@\u200bhere").replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, "").slice(0, 1800); + const channel = await client.channels.fetch(channelId); + if (!channel || !channel.isTextBased() || typeof channel.send !== "function") throw new Error("Configured events channel is not a text channel."); + await channel.send({ content: "**[iCreate Mythic] " + eventName + "**\n" + details, allowedMentions: { parse: [] } }); + res.writeHead(202, { "Content-Type": "application/json" }).end(JSON.stringify({ accepted: true })); + } catch (error) { + console.error("[Mythic API] Event rejected:", error.message); + if (!res.headersSent) res.writeHead(400, { "Content-Type": "application/json" }).end(JSON.stringify({ error: "event could not be processed" })); + } + }); + + server.listen(port, host, () => console.log("[Mythic API] Listening on " + host + ":" + port + "/api/icreate/events")); + return server; +} + +module.exports = { startMythicApi }; diff --git a/docs/MYTHIC_BOT_API.md b/docs/MYTHIC_BOT_API.md new file mode 100644 index 0000000..7140b87 --- /dev/null +++ b/docs/MYTHIC_BOT_API.md @@ -0,0 +1,20 @@ +# Mythic companion bot API + +The Mythic build can post events to a companion bot over HTTPS. Configure `apiBaseUrl` and `apiToken` in the dedicated server's `config/icreate-order-networking-mythic.json`. Keep the token private; never commit the server config or place credentials in client-side files. + +The mod posts to `POST /api/icreate/events` with `Authorization: Bearer ` and a JSON body containing `source`, `event`, `details`, and an ISO-8601 `timestamp`. Requests run asynchronously away from the Minecraft server tick. + +## Bot-side receiver + +A drop-in Node.js receiver is included at `bot-integration/mythic-api.js`. It uses Node's built-in HTTP and crypto modules and the existing Discord.js client. In the bot's entrypoint, add: + +```js +const { startMythicApi } = require("./mythic-api"); +startMythicApi(client); +``` + +Set `ICREATE_MYTHIC_API_TOKEN` (at least 32 random characters) and `ICREATE_MYTHIC_EVENTS_CHANNEL_ID` in the bot host environment. The receiver binds to `127.0.0.1` by default; use an HTTPS reverse proxy for remote Minecraft servers and set the mod's `apiBaseUrl` to that HTTPS origin. The receiver validates bearer credentials, limits request size, rejects malformed payloads, sanitizes mentions, and posts accepted events to the configured Discord channel. + +Events include server lifecycle and player join/leave, plus missile launch/impact/interception when the relevant gameplay handlers call the hooks. + +**Still outstanding:** this event receiver does not implement `/mc-link` or the team-sync approval queue, and the current mod repository does not yet contain a working missile flight/impact/defence entity that can call those hooks. Those require separate integration work. \ No newline at end of file diff --git a/src/main/java/com/innygr/icreateordernetworking/mythic/BotApiBridge.java b/src/main/java/com/innygr/icreateordernetworking/mythic/BotApiBridge.java new file mode 100644 index 0000000..c7f2aa3 --- /dev/null +++ b/src/main/java/com/innygr/icreateordernetworking/mythic/BotApiBridge.java @@ -0,0 +1,104 @@ +package com.innygr.icreateordernetworking.mythic; + +import com.google.gson.JsonObject; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Optional authenticated outbound event client for the companion Discord bot. + * The bot must expose POST /api/icreate/events and validate the bearer token. + * Credentials remain in the dedicated server's config and are never sent to clients. + */ +public final class BotApiBridge { + private static final Logger LOGGER = LoggerFactory.getLogger("iCreateOrderNetworking/BotApi"); + private static final HttpClient HTTP = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(4)) + .build(); + private static final ExecutorService EXECUTOR = Executors.newSingleThreadExecutor(task -> { + Thread thread = new Thread(task, "icreate-mythic-bot-api"); + thread.setDaemon(true); + return thread; + }); + + private static volatile URI endpoint; + private static volatile String token = ""; + private static volatile boolean enabled; + + private BotApiBridge() {} + + static void configure(String baseUrl, String sharedToken) { + enabled = false; + endpoint = null; + token = sharedToken == null ? "" : sharedToken.strip(); + if (baseUrl == null || baseUrl.isBlank() || token.isBlank()) return; + try { + URI base = URI.create(baseUrl.strip()); + if (!"https".equalsIgnoreCase(base.getScheme()) || base.getHost() == null + || base.getUserInfo() != null || base.getQuery() != null || base.getFragment() != null) { + LOGGER.warn("Mythic bot API disabled: apiBaseUrl must be an HTTPS base URL without credentials, query, or fragment."); + return; + } + String path = base.getPath() == null ? "" : base.getPath().replaceAll("/+$", ""); + endpoint = URI.create(base.getScheme() + "://" + base.getAuthority() + path + "/api/icreate/events"); + enabled = true; + } catch (IllegalArgumentException exception) { + LOGGER.warn("Mythic bot API disabled: invalid apiBaseUrl."); + } + } + + static void sendEvent(String eventType, String details) { + URI target = endpoint; + String secret = token; + if (!enabled || target == null || secret.isBlank()) return; + + JsonObject body = new JsonObject(); + body.addProperty("source", "icreate-order-networking"); + body.addProperty("event", safe(eventType, 80)); + body.addProperty("details", safe(details, 1800)); + body.addProperty("timestamp", java.time.Instant.now().toString()); + + final HttpRequest request; + try { + request = HttpRequest.newBuilder(target) + .timeout(Duration.ofSeconds(6)) + .header("Authorization", "Bearer " + secret) + .header("Content-Type", "application/json; charset=utf-8") + .POST(HttpRequest.BodyPublishers.ofString(body.toString(), StandardCharsets.UTF_8)) + .build(); + } catch (IllegalArgumentException exception) { + LOGGER.warn("Could not create Mythic bot API request."); + return; + } + + EXECUTOR.submit(() -> { + try { + HttpResponse response = HTTP.send(request, HttpResponse.BodyHandlers.discarding()); + if (response.statusCode() < 200 || response.statusCode() >= 300) { + LOGGER.warn("Mythic bot API returned HTTP {}; event was not accepted.", response.statusCode()); + } + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + } catch (Exception exception) { + LOGGER.warn("Unable to deliver event to Mythic bot API: {}", exception.getMessage()); + } + }); + } + + private static String safe(String value, int maxLength) { + if (value == null) return "unknown"; + String clean = value.replace("@everyone", "@\\u200beveryone") + .replace("@here", "@\\u200bhere") + .replace("\\r", " ").strip(); + return clean.length() > maxLength ? clean.substring(0, maxLength) : clean; + } +} diff --git a/src/main/java/com/innygr/icreateordernetworking/mythic/DiscordBridge.java b/src/main/java/com/innygr/icreateordernetworking/mythic/DiscordBridge.java index fcdef75..0323662 100644 --- a/src/main/java/com/innygr/icreateordernetworking/mythic/DiscordBridge.java +++ b/src/main/java/com/innygr/icreateordernetworking/mythic/DiscordBridge.java @@ -1,7 +1,6 @@ package com.innygr.icreateordernetworking.mythic; import com.google.gson.JsonObject; -import com.google.gson.JsonParser; import net.neoforged.fml.loading.FMLPaths; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -18,20 +17,12 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; -/** - * Server-only Discord notifier for the optional Mythic build. - * - * A Discord webhook is used instead of a bot token: it is configured only on - * the dedicated server and is never sent to clients or embedded in the JAR. - */ +/** Server-only Discord webhook transport for the optional Mythic build. */ public final class DiscordBridge { private static final Logger LOGGER = LoggerFactory.getLogger("iCreateOrderNetworking/MythicDiscord"); - private static final Path CONFIG_PATH = FMLPaths.CONFIGDIR.get().resolve("icreate-order-networking-mythic.json"); - private static final HttpClient HTTP = HttpClient.newBuilder() - .connectTimeout(Duration.ofSeconds(5)) - .build(); - private static final ExecutorService EXECUTOR = Executors.newSingleThreadExecutor(runnable -> { - Thread thread = new Thread(runnable, "icreate-mythic-discord"); + private static final HttpClient HTTP = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build(); + private static final ExecutorService EXECUTOR = Executors.newSingleThreadExecutor(task -> { + Thread thread = new Thread(task, "icreate-mythic-discord"); thread.setDaemon(true); return thread; }); @@ -56,16 +47,21 @@ public static boolean isConfigured() { } public static void sendEvent(String eventType, String details) { - if (stopped || !isConfigured()) return; + if (stopped) return; String safeType = sanitize(eventType, 80); String safeDetails = sanitize(details, 1500); + + // The companion bot receives events independently of the optional webhook. + BotApiBridge.sendEvent(safeType, safeDetails); + if (!isConfigured()) return; + JsonObject payload = new JsonObject(); - payload.addProperty("content", "**[" + serverName + "] " + safeType + "**\n" + safeDetails); + payload.addProperty("content", "**[" + sanitize(serverName, 100) + "] " + safeType + "**\n" + safeDetails); JsonObject allowedMentions = new JsonObject(); allowedMentions.add("parse", new com.google.gson.JsonArray()); payload.add("allowed_mentions", allowedMentions); - HttpRequest request; + final HttpRequest request; try { request = HttpRequest.newBuilder(URI.create(webhookUrl)) .timeout(Duration.ofSeconds(8)) @@ -79,7 +75,7 @@ public static void sendEvent(String eventType, String details) { EXECUTOR.submit(() -> { try { - HttpResponse response = HTTP.send(request, HttpResponse.BodyHandlers.ofString()); + HttpResponse response = HTTP.send(request, HttpResponse.BodyHandlers.discarding()); if (response.statusCode() < 200 || response.statusCode() >= 300) { LOGGER.warn("Mythic Discord webhook returned HTTP {}; event was not delivered.", response.statusCode()); } @@ -91,9 +87,7 @@ public static void sendEvent(String eventType, String details) { }); } - static void shutdown() { - stopped = true; - } + static void shutdown() { stopped = true; } private static boolean isHttpsWebhook(String value) { if (value == null || value.isBlank()) return false; @@ -111,9 +105,9 @@ private static boolean isHttpsWebhook(String value) { private static String sanitize(String value, int maxLength) { if (value == null) return "n/a"; - String normalized = value.replace("@everyone", "@\u200beveryone") - .replace("@here", "@\u200bhere") - .replace("\r", " ") + String normalized = value.replace("@everyone", "@\\u200beveryone") + .replace("@here", "@\\u200bhere") + .replace("\\r", " ") .strip(); return normalized.length() > maxLength ? normalized.substring(0, maxLength) : normalized; } diff --git a/src/main/java/com/innygr/icreateordernetworking/mythic/MythicIntegration.java b/src/main/java/com/innygr/icreateordernetworking/mythic/MythicIntegration.java index c2e4393..cbd7de0 100644 --- a/src/main/java/com/innygr/icreateordernetworking/mythic/MythicIntegration.java +++ b/src/main/java/com/innygr/icreateordernetworking/mythic/MythicIntegration.java @@ -12,10 +12,7 @@ import java.nio.file.Path; import java.util.UUID; -/** - * Optional server-only Mythic integration. This class is compiled into the - * Mythic variant only; the regular public build excludes this package. - */ +/** Optional server-only Mythic integration, compiled only in the Mythic build. */ public final class MythicIntegration { private static final Logger LOGGER = LoggerFactory.getLogger("iCreateOrderNetworking/Mythic"); private static final Path CONFIG_PATH = net.neoforged.fml.loading.FMLPaths.CONFIGDIR.get() @@ -24,6 +21,11 @@ public final class MythicIntegration { private boolean enabled; private boolean registered; + private String webhookUrl = ""; + private String serverName = "Minecraft server"; + private String apiBaseUrl = ""; + private String apiToken = ""; + private MythicIntegration() {} public static void onServerStarted(MinecraftServer server) { @@ -31,53 +33,48 @@ public static void onServerStarted(MinecraftServer server) { integration.loadConfig(); FtbTeamsSync.load(); active = integration; + BotApiBridge.configure(integration.apiBaseUrl, integration.apiToken); if (integration.enabled) { NeoForge.EVENT_BUS.register(integration); integration.registered = true; DiscordBridge.configure(true, integration.webhookUrl, integration.serverName); - DiscordBridge.sendEvent("Server online", "Mythic Discord integration connected."); + DiscordBridge.sendEvent("Server online", "Mythic integration connected."); LOGGER.info("Mythic server integration enabled."); } else { DiscordBridge.configure(false, "", integration.serverName); - LOGGER.info("Mythic server integration is disabled in config."); + LOGGER.info("Mythic server integration disabled; configure a valid webhook or bot API."); } } public static void onServerStopped(MinecraftServer server) { MythicIntegration integration = active; - if (integration != null && integration.registered) { - NeoForge.EVENT_BUS.unregister(integration); - } + if (integration != null && integration.registered) NeoForge.EVENT_BUS.unregister(integration); DiscordBridge.sendEvent("Server offline", "The Minecraft server is shutting down."); DiscordBridge.shutdown(); active = null; } - /** Hook for the missile assembly/launch implementation to report a launch. */ + /** Call when the missile assembly is successfully launched. */ public static void missileLaunched(UUID missileId, String teamId, String teamName, String target) { - String resolvedTeam = FtbTeamsSync.displayName(teamId, teamName); DiscordBridge.sendEvent("Missile launched", - "Missile: " + safeId(missileId) + "\nTeam: " + resolvedTeam + "\nTarget: " + safe(target)); + "Missile: " + safeId(missileId) + "\nTeam: " + FtbTeamsSync.displayName(teamId, teamName) + + "\nTarget: " + safe(target)); } - /** Hook for the missile impact handler to report a resolved impact. */ + /** Call once when a missile impact is resolved. */ public static void missileImpacted(UUID missileId, String teamId, String teamName, String result, String location) { - String resolvedTeam = FtbTeamsSync.displayName(teamId, teamName); DiscordBridge.sendEvent("Missile impact", - "Missile: " + safeId(missileId) + "\nTeam: " + resolvedTeam + "\nResult: " - + safe(result) + "\nLocation: " + safe(location)); + "Missile: " + safeId(missileId) + "\nTeam: " + FtbTeamsSync.displayName(teamId, teamName) + + "\nResult: " + safe(result) + "\nLocation: " + safe(location)); } - /** Hook for interception/defence code to report a successful interception. */ + /** Call once when defence/interception code confirms an interception. */ public static void missileIntercepted(UUID missileId, String interceptor, String location) { DiscordBridge.sendEvent("Missile intercepted", "Missile: " + safeId(missileId) + "\nIntercepted by: " + safe(interceptor) + "\nLocation: " + safe(location)); } - private String webhookUrl = ""; - private String serverName = "Minecraft server"; - private void loadConfig() { try { if (Files.notExists(CONFIG_PATH)) { @@ -86,7 +83,9 @@ private void loadConfig() { { "enabled": false, "serverName": "Minecraft server", - "discordWebhookUrl": "" + "discordWebhookUrl": "", + "apiBaseUrl": "", + "apiToken": "" } """, StandardCharsets.UTF_8); } @@ -95,10 +94,16 @@ private void loadConfig() { enabled = config.has("enabled") && config.get("enabled").getAsBoolean(); serverName = config.has("serverName") ? config.get("serverName").getAsString() : "Minecraft server"; webhookUrl = config.has("discordWebhookUrl") ? config.get("discordWebhookUrl").getAsString() : ""; - if (enabled && !isValidWebhook(webhookUrl)) { + apiBaseUrl = config.has("apiBaseUrl") ? config.get("apiBaseUrl").getAsString() : ""; + apiToken = config.has("apiToken") ? config.get("apiToken").getAsString() : ""; + + boolean webhookReady = isValidWebhook(webhookUrl); + boolean apiReady = apiBaseUrl != null && !apiBaseUrl.isBlank() && apiToken != null && !apiToken.isBlank(); + if (enabled && !webhookReady && !apiReady) { enabled = false; - LOGGER.warn("Mythic integration disabled: discordWebhookUrl must be a Discord HTTPS webhook URL."); + LOGGER.warn("Mythic integration disabled: configure a valid Discord webhook or HTTPS bot API URL plus apiToken."); } + if (!webhookReady) webhookUrl = ""; } catch (Exception exception) { enabled = false; LOGGER.warn("Could not load Mythic integration config from {}: {}", CONFIG_PATH, exception.getMessage()); @@ -107,28 +112,23 @@ private void loadConfig() { @net.neoforged.bus.api.SubscribeEvent public void onPlayerLogin(PlayerEvent.PlayerLoggedInEvent event) { - if (event.getEntity() instanceof ServerPlayer player) { + if (event.getEntity() instanceof ServerPlayer player) DiscordBridge.sendEvent("Player joined", player.getGameProfile().getName()); - } } @net.neoforged.bus.api.SubscribeEvent public void onPlayerLogout(PlayerEvent.PlayerLoggedOutEvent event) { - if (event.getEntity() instanceof ServerPlayer player) { + if (event.getEntity() instanceof ServerPlayer player) DiscordBridge.sendEvent("Player left", player.getGameProfile().getName()); - } } private static boolean isValidWebhook(String value) { try { java.net.URI uri = java.net.URI.create(value); - return "https".equalsIgnoreCase(uri.getScheme()) - && uri.getHost() != null - && uri.getHost().equalsIgnoreCase("discord.com") - && uri.getPath() != null && uri.getPath().startsWith("/api/webhooks/"); - } catch (IllegalArgumentException exception) { - return false; - } + return "https".equalsIgnoreCase(uri.getScheme()) && uri.getHost() != null + && uri.getHost().equalsIgnoreCase("discord.com") && uri.getPath() != null + && uri.getPath().startsWith("/api/webhooks/"); + } catch (IllegalArgumentException exception) { return false; } } private static String safeId(UUID id) { return id == null ? "unknown" : id.toString(); }