commit 95789a6931cf8d5e6f93e683b6179af3382cd0fc Author: sophie Date: Mon Jul 1 15:39:17 2024 +0300 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d3d6cdb --- /dev/null +++ b/.gitignore @@ -0,0 +1,176 @@ +# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore + +# Logs + +logs +_.log +npm-debug.log_ +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Caches + +.cache + +# Diagnostic reports (https://nodejs.org/api/report.html) + +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# Runtime data + +pids +_.pid +_.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover + +lib-cov + +# Coverage directory used by tools like istanbul + +coverage +*.lcov + +# nyc test coverage + +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) + +.grunt + +# Bower dependency directory (https://bower.io/) + +bower_components + +# node-waf configuration + +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) + +build/Release + +# Dependency directories + +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) + +web_modules/ + +# TypeScript cache + +*.tsbuildinfo + +# Optional npm cache directory + +.npm + +# Optional eslint cache + +.eslintcache + +# Optional stylelint cache + +.stylelintcache + +# Microbundle cache + +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history + +.node_repl_history + +# Output of 'npm pack' + +*.tgz + +# Yarn Integrity file + +.yarn-integrity + +# dotenv environment variable files + +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) + +.parcel-cache + +# Next.js build output + +.next +out + +# Nuxt.js build / generate output + +.nuxt +dist + +# Gatsby files + +# Comment in the public line in if your project uses Gatsby and not Next.js + +# https://nextjs.org/blog/next-9-1#public-directory-support + +# public + +# vuepress build output + +.vuepress/dist + +# vuepress v2.x temp and cache directory + +.temp + +# Docusaurus cache and generated files + +.docusaurus + +# Serverless directories + +.serverless/ + +# FuseBox cache + +.fusebox/ + +# DynamoDB Local files + +.dynamodb/ + +# TernJS port file + +.tern-port + +# Stores VSCode versions used for testing VSCode extensions + +.vscode-test + +# yarn v2 + +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store +config.json \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..0e8aabd --- /dev/null +++ b/README.md @@ -0,0 +1,17 @@ +# autowhitelist + +This ts project automatically whitelists people to a minecraft server from a discord. It's very very simple. +Supports multiple users in one message, e.g "USERONE", "USERTWO". This isn't configurable, but quite easy to remove. + +All whitelists can also be removed by just deleting the message, helping both users incase username changes or other issues, and staff. + +Run `bun i`, copy `config.example.json` to `config.json` and change all of the settings. The channel option is the channel ID, which you can copy by using developer mode on discord. Then, just start the bot. +If you get something as such from the bot, then everything's done right: +``` +RCON connected. +RCON authenicated. +Ready! Logged in as Slop Bridge#2702 +``` + +There is also an option to get rid of nicknames changes. Set "changeNicknames" to "false". +This project was created using `bun init` in bun v1.1.13. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime. diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000..2f27a09 Binary files /dev/null and b/bun.lockb differ diff --git a/config.example.json b/config.example.json new file mode 100644 index 0000000..2d98c1d --- /dev/null +++ b/config.example.json @@ -0,0 +1,10 @@ +{ + "token": "discord bot token", + "rcon": { + "port": 25567, + "host": "my.server.com", + "password": "very secure password" + }, + "channel": "channel id for my igns channel", + "changeNicknames": true +} \ No newline at end of file diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..4945b26 --- /dev/null +++ b/index.ts @@ -0,0 +1,128 @@ +import { Client, Events, GatewayIntentBits, TextChannel } from "discord.js"; +import { Rcon } from "./rcon"; +import config from "./config.json" assert { type: "json" }; + +const rcon = new Rcon(config.rcon.host, config.rcon.port, config.rcon.password); +const client = new Client({ + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildMessages, + GatewayIntentBits.MessageContent, + ], +}); + +rcon.on("connect", () => { + console.log("RCON connected."); +}); + +rcon.on("auth", () => { + console.log("RCON authenicated."); +}); + +client.once(Events.ClientReady, async (readyClient) => { + console.log(`Ready! Logged in as ${readyClient.user.tag}`); + let playerUsernames: string[] = []; + + const channel = (await client.channels.fetch(config.channel)) as TextChannel; + + const messages = await channel.messages.fetch(); + + for await (const f of messages) { + playerUsernames = playerUsernames.concat(f[1].content.split("\n")); + if (!f[1].reactions.resolve("✅")) await f[1].react("✅"); + let member = f[1].member; + + if (!member) member = await f[1].guild.members.fetch(f[1].author.id); + if (config.changeNicknames) { + if (member.nickname !== f[1].content.split("\n")[0]) { + try { + await member.setNickname(f[1].content.split("\n")[0], "whitelisted"); + } catch {} + } + } + } + + for (let i = 0; i < playerUsernames.length; i++) { + setTimeout(() => { + rcon.send("whitelist add " + playerUsernames[i]); + }, i * 20); + } + + console.log("Mass-whitelisted."); +}); + +client.on(Events.MessageCreate, async (message) => { + if ( + message.channelId == config.channel && + message.author!.id != client.user!.id + ) { + const channel = (await client.channels.fetch( + config.channel + )) as TextChannel; + + const messages = await channel.messages.fetch(); + + if (messages.filter((z) => z.author.id == message.author.id).size > 1) { + await message.delete(); + const deleteThisMessage = await channel.send( + `<@${message.author.id}>, you can only have one message in this channel.` + ); + + setTimeout(async () => { + await deleteThisMessage.delete(); + }, 5000); + return; + } + + const playerUsernames = message.content.split("\n"); + + let member = message.member; + + if (!member) member = await message.guild!.members.fetch(message.author.id); + if (config.changeNicknames) { + try { + await member.setNickname(playerUsernames[0]); + } catch {} + } + for (let i = 0; i < playerUsernames.length; i++) { + setTimeout(() => { + rcon.send("whitelist add " + playerUsernames[i]); + }, i * 20); + } + + await message.react("✅"); + } +}); + +client.on(Events.MessageDelete, async (message) => { + if ( + message.channelId == config.channel && + message.author!.id != client.user!.id + ) { + if (message.reactions.resolve("✅")) { + const deleteThisMessage = await message.channel.send( + `<@${message.author?.id}>, you are no longer whitelisted.` + ); + + const member = await message.guild!.members.fetch(message.author!.id); + if (config.changeNicknames) { + try { + await member.setNickname(null, "unwhitelisted"); + } catch {} + setTimeout(async () => { + await deleteThisMessage.delete(); + }, 5000); + } + const usernames = message.content?.split("\n") || []; + + for (let i = 0; i < usernames.length; i++) { + setTimeout(() => { + rcon.send("whitelist remove " + usernames[i]); + }, i * 20); + } + } + } +}); + +client.login(config.token); +rcon.connect(); diff --git a/package.json b/package.json new file mode 100644 index 0000000..6499df5 --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ +{ + "name": "autowhitelist", + "module": "index.ts", + "type": "module", + "devDependencies": { + "@types/bun": "latest", + "typed-emitter": "^2.1.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "dependencies": { + "bufferutil": "^4.0.8", + "discord.js": "^14.15.3", + "utf-8-validate": "^6.0.4", + "zlib-sync": "^0.1.9" + } +} \ No newline at end of file diff --git a/rcon.ts b/rcon.ts new file mode 100644 index 0000000..69de561 --- /dev/null +++ b/rcon.ts @@ -0,0 +1,235 @@ +import EventEmitter from "events"; +import * as net from "net"; +import * as dgram from "dgram"; +import { Buffer } from "buffer"; +import type TypedEmitter from "typed-emitter"; + +type Events = { + error: (error: Error) => void; + auth: () => void; + response: (response: string) => void; + connect: () => void; + end: () => void; + done: () => void; +}; + +export const PacketType = { + COMMAND: 0x02, + AUTH: 0x03, + RESPONSE_VALUE: 0x00, + RESPONSE_AUTH: 0x02, +}; + +interface Options { + tcp?: boolean; + challenge?: boolean; + id?: number; +} +export class Rcon extends (EventEmitter as new () => TypedEmitter) { + private host: string; + private port: number; + private password: string; + private rconId: number; + private hasAuthed: boolean; + private outstandingData: Uint8Array | null; + private tcp: boolean; + private challenge: boolean; + private _challengeToken: string; + private _tcpSocket!: net.Socket; + private _udpSocket!: dgram.Socket; + + constructor(host: string, port: number, password: string, options?: Options) { + super(); + options = options || {}; + this.host = host; + this.port = port; + this.password = password; + this.rconId = options.id || 0x0012d4a6; // This is arbitrary in most cases + this.hasAuthed = false; + this.outstandingData = null; + this.tcp = options.tcp ? options.tcp : true; + this.challenge = options.challenge ? options.challenge : true; + this._challengeToken = ""; + } + + public send = (data: string, cmd?: number, id?: number): void => { + let sendBuf: Buffer; + if (this.tcp) { + cmd = cmd || PacketType.COMMAND; + id = id || this.rconId; + + const length = Buffer.byteLength(data); + sendBuf = Buffer.alloc(length + 14); + sendBuf.writeInt32LE(length + 10, 0); + sendBuf.writeInt32LE(id, 4); + sendBuf.writeInt32LE(cmd, 8); + sendBuf.write(data, 12); + sendBuf.writeInt16LE(0, length + 12); + } else { + if (this.challenge && !this._challengeToken) { + this.emit("error", new Error("Not authenticated")); + return; + } + let str = "rcon "; + if (this._challengeToken) str += this._challengeToken + " "; + if (this.password) str += this.password + " "; + str += data + "\n"; + sendBuf = Buffer.alloc(4 + Buffer.byteLength(str)); + sendBuf.writeInt32LE(-1, 0); + sendBuf.write(str, 4); + } + this._sendSocket(sendBuf); + }; + + private _sendSocket = (buf: Buffer) => { + if (this._tcpSocket) { + this._tcpSocket.write(buf.toString("binary"), "binary"); + } else if (this._udpSocket) { + this._udpSocket.send(buf, 0, buf.length, this.port, this.host); + } + }; + + public connect = (): void => { + if (this.tcp) { + this._tcpSocket = net.createConnection(this.port, this.host); + this._tcpSocket + .on("data", (data) => { + this._tcpSocketOnData(data); + }) + .on("connect", () => { + this.socketOnConnect(); + }) + .on("error", (err) => { + this.emit("error", err); + }) + .on("end", () => { + this.socketOnEnd(); + }); + } else { + this._udpSocket = dgram.createSocket("udp4"); + this._udpSocket + .on("message", (data) => { + this._udpSocketOnData(data); + }) + .on("listening", () => { + this.socketOnConnect(); + }) + .on("error", (err) => { + this.emit("error", err); + }) + .on("close", () => { + this.socketOnEnd(); + }); + this._udpSocket.bind(0); + } + }; + + public disconnect = (): void => { + if (this._tcpSocket) this._tcpSocket.end(); + if (this._udpSocket) this._udpSocket.close(); + }; + + public setTimeout = (timeout: number, callback: () => void): void => { + if (!this._tcpSocket) return; + this._tcpSocket.setTimeout(timeout, () => { + this._tcpSocket.end(); + if (callback) callback(); + }); + }; + + private _udpSocketOnData = (data: Buffer) => { + const a = data.readUInt32LE(0); + if (a === 0xffffffff) { + const str = data.toString("utf-8", 4); + const tokens = str.split(" "); + if ( + tokens.length === 3 && + tokens[0] === "challenge" && + tokens[1] === "rcon" + ) { + this._challengeToken = tokens[2] + .substring(0, tokens[2].length - 1) + .trim(); + this.hasAuthed = true; + this.emit("auth"); + } else { + this.emit("response", str.substring(1, str.length - 2)); + } + } else { + this.emit("error", new Error("Received malformed packet")); + } + }; + + private _tcpSocketOnData = (data: Buffer) => { + if (this.outstandingData != null) { + data = Buffer.concat( + [this.outstandingData, data], + this.outstandingData.length + data.length + ); + this.outstandingData = null; + } + + while (data.length) { + const len = data.readInt32LE(0); + if (!len) return; + + const id = data.readInt32LE(4); + const type = data.readInt32LE(8); + + if (len >= 10 && data.length >= len + 4) { + if (id === this.rconId) { + if (!this.hasAuthed && type === PacketType.RESPONSE_AUTH) { + this.hasAuthed = true; + this.emit("auth"); + } else if (type === PacketType.RESPONSE_VALUE) { + // Read just the body of the packet (truncate the last null byte) + // See https://developer.valvesoftware.com/wiki/Source_RCON_Protocol for details + let str = data.toString("utf8", 12, 12 + len - 10); + + if (str.charAt(str.length - 1) === "\n") { + // Emit the response without the newline. + str = str.substring(0, str.length - 1); + } + + this.emit("response", str); + } + } else { + this.emit("error", new Error("Authentication failed")); + } + + data = data.slice(12 + len - 8); + } else { + // Keep a reference to the chunk if it doesn't represent a full packet + this.outstandingData = data; + break; + } + } + }; + + public socketOnConnect = (): void => { + this.emit("connect"); + + if (this.tcp) { + this.send(this.password, PacketType.AUTH); + } else if (this.challenge) { + const str = "challenge rcon\n"; + const sendBuf = Buffer.alloc(str.length + 4); + sendBuf.writeInt32LE(-1, 0); + sendBuf.write(str, 4); + this._sendSocket(sendBuf); + } else { + const sendBuf = Buffer.alloc(5); + sendBuf.writeInt32LE(-1, 0); + sendBuf.writeUInt8(0, 4); + this._sendSocket(sendBuf); + + this.hasAuthed = true; + this.emit("auth"); + } + }; + + public socketOnEnd = (): void => { + this.emit("end"); + this.hasAuthed = false; + }; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..238655f --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + // Enable latest features + "lib": ["ESNext", "DOM"], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +}