Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions bot-integration/mythic-api.js
Original file line number Diff line number Diff line change
@@ -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 };
20 changes: 20 additions & 0 deletions docs/MYTHIC_BOT_API.md
Original file line number Diff line number Diff line change
@@ -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 <apiToken>` 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.
Original file line number Diff line number Diff line change
@@ -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<Void> 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;
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
});
Expand All @@ -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))
Expand All @@ -79,7 +75,7 @@ public static void sendEvent(String eventType, String details) {

EXECUTOR.submit(() -> {
try {
HttpResponse<String> response = HTTP.send(request, HttpResponse.BodyHandlers.ofString());
HttpResponse<Void> 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());
}
Expand All @@ -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;
Expand All @@ -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;
}
Expand Down
Loading
Loading