Compare commits

...

3 commits

8 changed files with 201 additions and 9 deletions

View file

@ -161,7 +161,8 @@ class GatewayServer {
username: user.username,
guildAccess: user.guildAccess,
discordID: user.discordID,
avatarURL: user.avatarURL
avatarURL: user.avatarURL,
isSuperToken: user.isSuperToken
}
}
}));

View file

@ -1,6 +1,6 @@
MIT License
Copyright (c) 2021 hippoz
Copyright (c) 2022 hippoz
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

View file

@ -1,5 +1,5 @@
export const mainHttpListenPort = 4050;
export const watchedGuildIds = ["822089558886842418"];
export const watchedGuildIds = ["822089558886842418", "736292509134749807"];
export const jwtSecret = process.env.JWT_SECRET;
export const discordToken = process.env.DISCORD_TOKEN;
export const dangerousAdminMode = true;

View file

@ -9,5 +9,8 @@
"jsonwebtoken": "^8.5.1",
"node-fetch": "^3.2.0",
"ws": "^8.4.2"
},
"optionalDependencies": {
"rcon": "^1.1.0"
}
}

View file

@ -13,12 +13,12 @@ router.post("/tokens/create", async (req, res) => {
if (!dangerousAdminMode)
return res.status(403).send({ error: true, message: "ERROR_FEATURE_DISABLED" });
const { username, avatarURL, discordID, guildAccess } = req.body;
const { username, avatarURL, discordID, guildAccess, isSuperToken=false } = req.body;
if (!username || !discordID || !guildAccess)
return res.status(400).send({ error: true, message: "ERROR_BAD_REQUEST" });
try {
const token = await createToken({ username, avatarURL, discordID, guildAccess });
const token = await createToken({ username, avatarURL, discordID, guildAccess, isSuperToken });
res.status(200).send({ error: false, message: "SUCCESS_TOKEN_CREATED", token });
} catch(e) {
res.status(500).send({ error: true, message: "ERROR_TOKEN_CREATE_FAILURE" });
@ -30,7 +30,8 @@ router.get("/users/@self", checkAuth(async (req, res) => {
username: req.user.username,
avatarURL: req.user.avatarURL,
discordID: req.user.discordID,
guildAccess: req.user.guildAccess
guildAccess: req.user.guildAccess,
isSuperToken: req.user.isSuperToken
}});
}));
@ -48,12 +49,22 @@ router.post("/guilds/:guildId/channels/:channelId/messages/create", checkAuth(as
return res.status(400).send({ error: true, message: "ERROR_NO_CHANNEL_ID" });
const { username, avatarURL, guildAccess } = req.user;
let { username, avatarURL, guildAccess, isSuperToken } = req.user;
if (isSuperToken) {
if (req.body.username)
username = req.body.username;
if (req.body.avatarURL)
avatarURL = req.body.avatarURL;
}
if (guildAccess.indexOf(guildId) === -1)
return res.status(403).send({ error: true, message: "ERROR_NO_GUILD_ACCESS" });
const guild = guildMap.get(guildId);
if (!guild)
return res.status(404).send({ error: true, message: "ERROR_GUILD_NOT_FOUND" });
try {
await guild.discordSendMessage(messageContent, channelId, username, avatarURL);
res.status(201).send({ error: false });

172
scripts/minecraft.js Normal file
View file

@ -0,0 +1,172 @@
// 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 messageSchema = { t: "number", d: "object" };
const messageTypes = {
HELLO: 0,
YOO: 1,
READY: 2,
EVENT: 3
};
const chatMessageRegex = /^\[(?:.*?)\]: \<(?<username>.*)\> (?<message>.*)/;
const rconConnection = new Rcon("localhost", "25575", process.env.RCON_PASSWORD);
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;
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");
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) {
rcon.send(`tellraw @a ${JSON.stringify([
{ text: "[" },
{ text: `${username}`, color: "gray" },
{ text: "]" },
{ text: " " },
{ text: content },
])}`);
}
async function main() {
rconConnection.on("error", (e) => {
console.error("rcon: got error", e);
if (!rconConnection.hasAuthed) {
console.log("rcon: reconnecting in 1200ms due to error before hasAuthed");
setTimeout(() => {
rconConnection.connect();
}, 1200);
}
});
const gateway = new GatewayClient(GATEWAY_ORIGIN);
gateway.onEvent = (e) => {
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);
}
};
rconConnection.connect();
gateway.connect(TOKEN);
process.stdin.resume();
process.stdin.on("data", async (rawDataBuffer) => {
const stringData = rawDataBuffer.toString().trim();
console.log(stringData);
const result = chatMessageRegex.exec(stringData);
if (!result)
return;
const { username, message } = result.groups;
await sendBridgeMessageAs(TARGET_GUILD_ID, TARGET_CHANNEL_ID, message, username, null);
});
}
await main();

View file

@ -1,9 +1,9 @@
import jsonwebtoken from "jsonwebtoken";
import { jwtSecret } from "./config.js";
export function createToken({ username, avatarURL, discordID, guildAccess }) {
export function createToken({ username, avatarURL, discordID, guildAccess, isSuperToken=false }) {
return new Promise((resolve, reject) => {
jsonwebtoken.sign({ username, avatarURL, discordID, guildAccess }, jwtSecret, (err, token) => {
jsonwebtoken.sign({ username, avatarURL, discordID, guildAccess, isSuperToken }, jwtSecret, (err, token) => {
if (err)
return reject(err);

View file

@ -388,6 +388,11 @@ raw-body@2.4.2:
iconv-lite "0.4.24"
unpipe "1.0.0"
rcon@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/rcon/-/rcon-1.1.0.tgz#82a27bbfadd4c13b3c5d828b55ce15bd606eb7c3"
integrity sha512-eotwcApOBjfadTjqQlrZVR4jzlwGCMNxmHhnFZx+g4kouwwRstRHkk1ON7DzkqrHNIjADSh0cU3gThSsDolUpg==
safe-buffer@5.2.1, safe-buffer@^5.0.1:
version "5.2.1"
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6"