// This script aims to bridge Minecraft to a guild of your choice by piping // the Minecraft output into this script. RCON isrequired for messages to // be sent to Minecraft. // Running example - working directory: project root: // (cd ~/minecraft_server/ ; exec ~/minecraft_server/start.sh) | RCON_PASSWORD="your rcon password" node scripts/minecraft.js import fetch from "node-fetch"; import { WebSocket } from "ws"; import Rcon from "rcon"; const TOKEN = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InN0YWdpbmctbWluZWNyYWZ0LWJyaWRnZSIsImF2YXRhclVSTCI6bnVsbCwiZGlzY29yZElEIjoiMCIsImd1aWxkQWNjZXNzIjpbIjczNjI5MjUwOTEzNDc0OTgwNyJdLCJpc1N1cGVyVG9rZW4iOnRydWUsImlhdCI6MTY0NDQ1MzM4MX0.-XIBl6VLnXVwve9iqhWs51ABZkm1i_v1tS6X01SPk3U"; // A supertoken is required to send messages from Minecraft. const TARGET_GUILD_ID = "_"; const TARGET_CHANNEL_ID = "_"; const ORIGIN = "http://localhost:4050"; const GATEWAY_ORIGIN = "ws://localhost:4050/gateway"; const FRONTEND_ORIGIN = "http://localhost:4050/"; const messageSchema = { t: "number", d: "object" }; const messageTypes = { HELLO: 0, YOO: 1, READY: 2, EVENT: 3 }; const chatMessageRegex = /^\[(?:.*?)\]: \<(?[a-zA-Z0-9]+)\> (?.*)/; const joinNotificationRegex = /^\[(?:.*?)\]: (?[a-zA-Z0-9]+) joined the game/; const leaveNotificationRegex = /^\[(?:.*?)\]: (?[a-zA-Z0-9]+) left the game/; const rconConnection = new Rcon("localhost", "25575", process.env.RCON_PASSWORD); const pendingHandoffs = new Map(); const usernameToToken = new Map(); export default class GatewayClient { constructor(gatewayPath) { this.gatewayPath = gatewayPath; this.ws = null; this.token = null; this.user = null; this.onEvent = (e) => {}; } connect(token) { if (!token) token = this.token; if (this.ws) { console.log("gateway: connect() but connection already exists, killing existing connection..."); try { this.ws.removeAllListeners(); this.ws.close(); this.ws = null; } catch (e) { console.log("gateway: error while closing existing connection - it might not be established yet"); } } console.log("gateway: connecting"); this.ws = new WebSocket(this.gatewayPath); this.ws.on("message", (data, isBinary) => { if (isBinary) { console.warn("gateway: got binary data from server, ignoring..."); return; } let message = data.toString(); try { message = JSON.parse(data); } catch(e) { console.warn("gateway: got invalid JSON from server (failed to parse), ignoring..."); return; } if (!this._checkMessageSchema(message)) { console.warn("gateway: got invalid JSON from server (does not match schema), ignoring..."); return; } switch (message.t) { case messageTypes.HELLO: { console.log("gateway: HELLO"); this.ws.send(JSON.stringify({ t: messageTypes.YOO, d: { token } })); break; } case messageTypes.READY: { console.log("gateway: READY"); this.user = message.d.user; break; } case messageTypes.EVENT: { this.onEvent(message.d); break; } default: { console.warn("gateway: got invalid JSON from server (invalid type), ignoring..."); return; } } }); this.ws.on("open", () => { console.log("gateway: open"); }); this.ws.on("close", () => { console.log("gateway: closed, reconnecting in 4000ms"); setTimeout(() => { console.log("gateway: reconnecting"); this.connect(token); }, 4000); }); this.ws.on("error", (e) => { console.error("gateway: error", e); console.log("gateway: reconnecting in 4000ms due to previous error"); setTimeout(() => { console.log("gateway: reconnecting"); this.connect(token); }, 4000); }); } _checkMessageSchema(message) { for (const [key, value] of Object.entries(message)) { if (!messageSchema[key]) return false; if (typeof value !== messageSchema[key]) return false; } return true; } } async function sendBridgeMessageAs(guildId, channelId, content, username=undefined, avatarURL=undefined) { return await fetch(`${ORIGIN}/api/v1/guilds/${guildId}/channels/${channelId}/messages/create`, { method: "POST", body: JSON.stringify({ content, username, avatarURL }), headers: { "content-type": "application/json", "authorization": TOKEN } }); } async function sendMinecraftMessageAs(rcon, username, content, attachments, referencedMessage=null) { if (!attachments) { attachments = []; } const tellrawPayload = [ { text: "[" }, { text: `${username}`, color: "gray" }, { text: "]" }, { text: " " }, ]; if (referencedMessage) { let trimmedContent = referencedMessage.content.substring(0, 50); if (trimmedContent !== referencedMessage.content) { trimmedContent += "..."; } tellrawPayload.push({ text: `` }); tellrawPayload.push({ text: " " }); } attachments.forEach((e) => { tellrawPayload.push({ text: ``, color: "gray", clickEvent: { action: "open_url", value: e.proxy_url } }); tellrawPayload.push({ text: " " }); }); tellrawPayload.push({ text: content }); rcon.send(`tellraw @a ${JSON.stringify(tellrawPayload)}`); } async function startHandoffForUser(username) { const handoffRes = await fetch(`${ORIGIN}/api/v1/tokens/handoff/create`, { method: "POST", body: JSON.stringify({}), headers: { "content-type": "application/json", "authorization": TOKEN } }); const { token: handoffToken, dispatchUUID } = (await handoffRes.json()); pendingHandoffs.set(dispatchUUID, username); // TODO: probably not a good idea setTimeout(() => { pendingHandoffs.delete(dispatchUUID); }, 40000); return handoffToken; } async function main() { rconConnection.on("error", (e) => { console.error("rcon: got error", e); if (!rconConnection.hasAuthed) { console.log("rcon: reconnecting in 5000ms due to error before hasAuthed (server might not be up yet?)"); setTimeout(() => { rconConnection.connect(); }, 5000); } }); const gateway = new GatewayClient(GATEWAY_ORIGIN); gateway.onEvent = (e) => { if (e.eventType === "$BRIDGE_HANDOFF_FULFILLMENT") { const username = pendingHandoffs.get(e.dispatchUUID); if (!username) return; usernameToToken.set(username, e.token); sendMinecraftMessageAs(rconConnection, "Minecraft Bridge System", `${username} has been successfully linked to a bridge token.`, null, null); } else if (e.eventType === "MESSAGE_CREATE" && e.message.channel_id === TARGET_CHANNEL_ID && e.message.guild_id === TARGET_GUILD_ID && !e.message.webhook_id) { sendMinecraftMessageAs(rconConnection, e.message.author.username, e.message.content, e.message.attachments, e.message.referenced_message); } }; rconConnection.connect(); gateway.connect(TOKEN); process.stdin.resume(); process.stdin.on("data", async (rawDataBuffer) => { const stringData = rawDataBuffer.toString().trim(); console.log(stringData); const joinResult = joinNotificationRegex.exec(stringData); if (joinResult) { await sendBridgeMessageAs(TARGET_GUILD_ID, TARGET_CHANNEL_ID, `**${joinResult.groups.username}** joined the game`, null, null); return; } const leaveResult = leaveNotificationRegex.exec(stringData); if (leaveResult) { await sendBridgeMessageAs(TARGET_GUILD_ID, TARGET_CHANNEL_ID, `**${leaveResult.groups.username}** left the game`, null, null); return; } const messageResult = chatMessageRegex.exec(stringData); if (messageResult) { if (messageResult.groups.message === "!:minecraft-link") { try { const handoffToken = await startHandoffForUser(messageResult.groups.username); const handoffLink = `${FRONTEND_ORIGIN}#token_handoff,,${handoffToken}`; sendMinecraftMessageAs( rconConnection, "Minecraft Bridge System", `${messageResult.groups.username}: Click on the attached link to finish linking your account. You have 40 seconds.`, [ { filename: `link account to ${messageResult.groups.username}`, proxy_url: handoffLink } ] ); } catch(e) { sendMinecraftMessageAs(rconConnection, "Minecraft Bridge System", `Failed to create link for ${messageResult.groups.username}.`, null, null); } } else { await sendBridgeMessageAs(TARGET_GUILD_ID, TARGET_CHANNEL_ID, messageResult.groups.message, messageResult.groups.username, null); } } }); } await main();