From bad6d459631bed85c210ae1ec7042ddbd98a6171 Mon Sep 17 00:00:00 2001 From: Cynthia Foxwell Date: Thu, 9 Oct 2025 17:08:22 -0600 Subject: [PATCH] sourcequery --- src/modules/fedimbed.js | 2 +- src/modules/misc/sourcequery.js | 242 +++++++++++++++++++++++ src/util/sourcequery.js | 341 ++++++++++++++++++++++++++++++++ src/util/table.js | 15 +- 4 files changed, 595 insertions(+), 5 deletions(-) create mode 100644 src/modules/misc/sourcequery.js create mode 100644 src/util/sourcequery.js diff --git a/src/modules/fedimbed.js b/src/modules/fedimbed.js index d24e362..8b4e436 100644 --- a/src/modules/fedimbed.js +++ b/src/modules/fedimbed.js @@ -749,7 +749,7 @@ async function bluesky(msg, url, spoiler = false, minimal = false) { return { response: { - flags: 1 << 15, + flags: MessageFlags.IS_COMPONENTS_V2, components: [warnings.length > 0 && warningText, container].filter((x) => !!x), allowedMentions: { repliedUser: false, diff --git a/src/modules/misc/sourcequery.js b/src/modules/misc/sourcequery.js new file mode 100644 index 0000000..758e399 --- /dev/null +++ b/src/modules/misc/sourcequery.js @@ -0,0 +1,242 @@ +const Command = require("#lib/command.js"); + +const SourceQuery = require("#util/sourcequery.js"); +const Table = require("#util/table.js"); +const {formatTime} = require("#util/time.js"); +const {MessageFlags} = require("#util/dconstants.js"); +const {pastelize, getTopColor} = require("#util/misc.js"); + +const FRIENDLY_USERAGENT = + "Mozilla/5.0 (compatible; HiddenPhox/sourcequery; +https://gitdab.com/Cynosphere/HiddenPhox) Discordbot/2.0"; + +const SERVER_TYPES = { + d: "Dedicated Server", + l: "Listen Server", + p: "SourceTV Relay", +}; +const OPERATING_SYSTEMS = { + l: "Linux", + w: "Windows", + m: "macOS", + o: "macOS", +}; + +const sourcequery = new Command("sourcequery"); +sourcequery.category = "misc"; +sourcequery.helpText = "Query a Source engine server"; +sourcequery.callback = async function (msg, line) { + const split = line.split(":"); + const ip = split[0]; + const port = split[1] ?? 27015; + + await msg.addReaction("\uD83C\uDFD3"); + + let data; + try { + const timeout = setTimeout(() => { + throw "timeout"; + }, 5000); + const query = new SourceQuery(ip, port); + const info = await query.getInfo(); + const players = await query.getPlayers(); + const rules = await query.getRules(); + + clearTimeout(timeout); + data = { + info, + players, + rules, + }; + } catch (err) { + await msg.removeReaction("\uD83C\uDFD3"); + if (err == "timeout") { + return "Failed to query server after 5 seconds."; + } else { + return `:warning: An error occured while querying:\n\`\`\`\n${err.message}\n\`\`\``; + } + } + + await msg.removeReaction("\uD83C\uDFD3"); + if (data?.info == null) { + return "Failed to query any data."; + } + + const components = []; + + const { + name, + map, + appid, + game, + folder, + players, + maxplayers, + bots, + type, + os, + visibility, + vac, + version, + sourcetv, + tags, + } = data.info; + + let hasBanner = false; + let bannerUrl; + if (map.startsWith("surf_")) { + const url = `https://ksf.surf/images/${map}.jpg`; + const valid = await fetch(url, { + method: "HEAD", + headers: {"User-Agent": FRIENDLY_USERAGENT}, + }).then((res) => res.ok); + + if (valid) { + hasBanner = true; + bannerUrl = url; + } + } + + if ( + !hasBanner && + (map.startsWith("surf_") || + map.startsWith("bhop_") || + map.startsWith("sj_") || + map.startsWith("rj") || + map.startsWith("ahop_") || + map.startsWith("df_") || + map.startsWith("conc_")) + ) { + const mmodData = await fetch(`https://api.momentum-mod.org/v1/maps/${map}?expand=info`, { + headers: {"User-Agent": FRIENDLY_USERAGENT}, + }) + .then((res) => res.json()) + .catch(() => {}); + const image = mmodData?.images?.[0]; + if (image) { + const url = image.xl ?? image.large ?? image.medium ?? image.small; + if (url) { + hasBanner = true; + bannerUrl = url; + } + } + } + + if (bannerUrl) { + components.push({ + type: 12, + items: [ + { + media: { + url: bannerUrl, + }, + }, + ], + }); + } + + let icon; + if (!hasBanner) { + const url = `https://gitlab.com/worldspawn/sourcemapicons/-/raw/main/icons/${map}.png`; + const valid = await fetch(url, { + method: "HEAD", + headers: {"User-Agent": FRIENDLY_USERAGENT}, + }).then((res) => res.ok); + + if (valid) icon = url; + } + + const lines = []; + lines.push(`# [${name}](https://sour-dani.github.io/steamconnect/?${ip}:${port})`); + lines.push(map); + lines.push(`-# [${appid == "4000" ? "Garry's Mod" : game}](https://steamdb.info/app/${appid}) (\`${folder}\`)`); + lines.push(""); + + let plycount = `${players}/${maxplayers} players`; + if (bots > 0) plycount += ` (${bots} bots)`; + lines.push(plycount); + + lines.push(SERVER_TYPES[type] ?? ``); + if (appid == "4000") lines.push("**Gamemode:** " + game); + lines.push("**OS:** " + (OPERATING_SYSTEMS[os] ?? ` 0) { + lines.push("## Tags"); + lines.push("```ansi\n" + tags + "\n```"); + } + + if (data.players?.length > 0) { + lines.push("## Players"); + + const plytbl = new Table(["Player Name", "Score", "Time"]); + plytbl.setChars("\u2502", "\u2500", "\u253c"); + for (const {name, score, time} of data.players) { + plytbl.addRow([name, score.toString(), formatTime(time * 1000)]); + } + + lines.push("```ansi\n" + plytbl.render() + "\n```"); + } + + const content = {type: 10, content: lines.join("\n")}; + + if (icon) { + components.push({ + type: 9, + accessory: { + type: 11, + media: { + url: icon, + }, + }, + components: [content], + }); + } else { + components.push(content); + } + + const m = await msg.channel.createMessage({ + flags: MessageFlags.IS_COMPONENTS_V2, + components: [ + { + type: 17, + accent_color: getTopColor(msg, hf.bot.user.id, pastelize(hf.bot.user.id)), + components, + }, + ], + allowedMentions: { + repliedUser: false, + }, + messageReference: { + messageID: msg.id, + }, + }); + + const cvars = Object.entries(data.rules); + if (cvars.length > 0) { + const cvarMsg = { + attachments: [ + { + file: Buffer.from(cvars.map(([name, value]) => `${name} "${value}"`).join("\n")), + filename: `cvars - ${ip}_${port}.txt`, + }, + ], + allowedMentions: { + repliedUser: false, + }, + messageReference: { + messageID: m.id, + }, + }; + await msg.channel.createMessage(cvarMsg); + } + + return null; +}; +hf.registerCommand(sourcequery); diff --git a/src/util/sourcequery.js b/src/util/sourcequery.js new file mode 100644 index 0000000..395190a --- /dev/null +++ b/src/util/sourcequery.js @@ -0,0 +1,341 @@ +const dgram = require("node:dgram"); + +const REQUEST_INFO = [ + 0xff, 0xff, 0xff, 0xff, 0x54, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x20, 0x45, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x20, + 0x51, 0x75, 0x65, 0x72, 0x79, 0x00, +]; +const REQUEST_RULES = [0xff, 0xff, 0xff, 0xff, 0x56]; +const REQUEST_PLAYER = [0xff, 0xff, 0xff, 0xff, 0x55]; + +module.exports = class SourceQuery { + constructor(ip, port) { + this.ip = ip; + this.port = port; + this.client = dgram.createSocket("udp4"); + this.rulePackets = []; + this.chunks = -1; + } + + _handlerInfo(message, resolve, reject, wrapper) { + const msg = new DataView(message.buffer); + + if (msg.getUint8(4) == 0x41) { + const challenge = [msg.getUint8(5), msg.getUint8(6), msg.getUint8(7), msg.getUint8(8)]; + + this.client.send(Buffer.from([...REQUEST_INFO, ...challenge]), this.port, this.ip, function (err) { + if (err) { + this.client.close(); + reject(err); + } + }); + } else if (msg.getUint8(4) == 0x49) { + const info = {}; + + const protocolVer = msg.getUint8(5); + info.protocolVersion = protocolVer; + + let idx = 6; + + const name = []; + + while (msg.getUint8(idx) != 0) { + name.push(msg.getUint8(idx)); + idx++; + } + + info.name = Buffer.from(name).toString("utf8"); + idx++; + + const map = []; + while (msg.getUint8(idx) != 0) { + map.push(msg.getUint8(idx)); + idx++; + } + + info.map = Buffer.from(map).toString("utf8"); + idx++; + + const folder = []; + while (msg.getUint8(idx) != 0) { + folder.push(msg.getUint8(idx)); + idx++; + } + + info.folder = Buffer.from(folder).toString("utf8"); + idx++; + + const game = []; + while (msg.getUint8(idx) != 0) { + game.push(msg.getUint8(idx)); + idx++; + } + + info.game = Buffer.from(game).toString("utf8"); + idx++; + + info.appid = msg.getInt16(idx, true); + idx += 2; + + info.players = msg.getUint8(idx); + idx++; + + info.maxplayers = msg.getUint8(idx); + idx++; + + info.bots = msg.getUint8(idx); + idx++; + + info.type = String.fromCharCode(msg.getUint8(idx)); + idx++; + + info.os = String.fromCharCode(msg.getUint8(idx)); + idx++; + + info.visibility = msg.getUint8(idx); + idx++; + + info.vac = msg.getUint8(idx); + idx++; + + const version = []; + while (msg.getUint8(idx) != 0) { + version.push(msg.getUint8(idx)); + idx++; + } + + info.version = Buffer.from(version).toString("utf8"); + idx++; + + const edf = msg.getUint8(idx); + info.edfRaw = edf; + info.edf = {}; + info.sourcetv = {}; + idx++; + + if (edf & 0x80) { + info.edf.port = msg.getInt16(idx, true); + idx += 2; + } + + if (edf & 0x10) { + info.edf.steamid = msg.getBigUint64(idx, true); + idx += 8; + } + + if (edf & 0x40) { + info.sourcetv.port = msg.getInt16(idx, true); + idx += 2; + + const stvname = []; + while (msg.getUint8(idx) != 0) { + stvname.push(msg.getUint8(idx)); + idx++; + } + + info.sourcetv.name = Buffer.from(stvname).toString("utf8"); + idx++; + } + + if (edf & 0x20) { + const tags = []; + while (msg.getUint8(idx) != 0) { + tags.push(msg.getUint8(idx)); + idx++; + } + + info.tags = Buffer.from(tags).toString("utf8"); + idx++; + } + + if (edf & 0x01) { + info.edf.appid = msg.getBigUint64(idx, true); + idx += 8; + } + + this.client.close(); + this.client.off("message", wrapper); + resolve(info); + } + } + + getInfo() { + return new Promise((resolve, reject) => { + this.client = dgram.createSocket("udp4"); + this.client.send(Buffer.from(REQUEST_INFO), this.port, this.ip, function (err) { + if (err) { + this.client.close(); + reject(err); + } + }); + + const wrapper = function wrapper(message) { + this._handlerInfo(message, resolve, reject, wrapper); + }.bind(this); + + this.client.on("message", wrapper); + }); + } + + _handlerPlayer(message, resolve, reject, wrapper) { + const msg = new DataView(message.buffer); + if (msg.getUint8(4) == 0x41) { + const challenge = [msg.getUint8(5), msg.getUint8(6), msg.getUint8(7), msg.getUint8(8)]; + + this.client.send(Buffer.from([...REQUEST_PLAYER, ...challenge]), this.port, this.ip, function (err) { + if (err) { + this.client.close(); + reject(err); + } + }); + } else if (msg.getUint8(4) == 0x44) { + const numPlayers = msg.getUint8(5); + let idx = 6; + const players = []; + for (let i = 0; i < numPlayers; i++) { + idx++; + + const _name = []; + while (msg.getUint8(idx) != 0) { + _name.push(msg.getUint8(idx)); + idx++; + } + idx++; + const name = Buffer.from(_name).toString("utf8"); + + const _score = []; + for (let j = 0; j < 4; j++) { + _score.push(msg.getUint8(idx)); + idx++; + } + const score = new DataView(new Uint8Array(_score.reverse()).buffer).getInt32(0); + + const _time = []; + for (let t = 0; t < 4; t++) { + _time.push(msg.getUint8(idx)); + idx++; + } + const time = Buffer.from(_time).readFloatLE(0); + + players.push({name, score, time}); + } + + this.client.close(); + this.client.off("message", wrapper); + resolve(players); + } + } + + getPlayers() { + return new Promise((resolve, reject) => { + this.client = dgram.createSocket("udp4"); + this.client.send(Buffer.from([...REQUEST_PLAYER, 0xff, 0xff, 0xff, 0xff]), this.port, this.ip, function (err) { + if (err) { + this.client.close(); + reject(err); + } + }); + + const wrapper = function wrapper(message) { + this._handlerPlayer(message, resolve, reject, wrapper); + }.bind(this); + + this.client.on("message", wrapper); + }); + } + + _processRules() { + const data = this.rulePackets.flat(); + const dataArray = new Uint8Array(data); + + const rulesMsg = new DataView(dataArray.buffer); + + if (rulesMsg.getUint8(4) == 0x45) { + let idx = 5; + + const rule_count = rulesMsg.getInt16(idx, true); + idx += 2; + + const rules = {}; + for (let i = 0; i < rule_count; i++) { + if (idx >= rulesMsg.byteLength) break; + + const _name = []; + while (rulesMsg.getUint8(idx) != 0) { + _name.push(rulesMsg.getUint8(idx)); + idx++; + } + idx++; + const name = String.fromCharCode(..._name); + + if (idx >= rulesMsg.byteLength) break; + const _value = []; + while (rulesMsg.getUint8(idx) != 0) { + _value.push(rulesMsg.getUint8(idx)); + idx++; + } + idx++; + const value = String.fromCharCode(..._value); + + rules[name] = value; + } + + return rules; + } else { + return {}; + } + } + + _handlerRules(message, resolve, reject, wrapper) { + const msg = new DataView(message.buffer); + + if (msg.getUint8(4) == 0x41) { + const challenge = [msg.getUint8(5), msg.getUint8(6), msg.getUint8(7), msg.getUint8(8)]; + + this.client.send(Buffer.from([...REQUEST_RULES, ...challenge]), this.port, this.ip, function (err) { + if (err) { + this.client.close(); + reject(err); + } + }); + } else if ( + msg.getUint8(0) == 0xfe && + msg.getUint8(1) == 0xff && + msg.getUint8(2) == 0xff && + msg.getUint8(3) == 0xff + ) { + this.chunks = msg.getUint8(8); + const chunkIdx = msg.getUint8(9); + const chunk = []; + for (let i = 12; i < msg.byteLength; i++) { + chunk.push(msg.getUint8(i)); + } + this.rulePackets[chunkIdx] = chunk; + } + + if (this.chunks != -1 && this.rulePackets.length == this.chunks) { + this.client.close(); + const rules = this._processRules(); + + this.client.off("message", wrapper); + resolve(rules); + } + } + + getRules() { + return new Promise((resolve, reject) => { + this.client = dgram.createSocket("udp4"); + this.client.send(Buffer.from([...REQUEST_RULES, 0xff, 0xff, 0xff, 0xff]), this.port, this.ip, function (err) { + if (err) { + this.client.close(); + reject(err); + } + }); + + const wrapper = function wrapper(message) { + this._handlerRules(message, resolve, reject, wrapper); + }.bind(this); + + this.client.on("message", wrapper); + }); + } +}; diff --git a/src/util/table.js b/src/util/table.js index e4d1564..c8f9240 100644 --- a/src/util/table.js +++ b/src/util/table.js @@ -7,6 +7,7 @@ module.exports = class Table { this._widths.push(this._rows[i][_i].length + 2); } } + this._chars = ["|", "-", "+"]; } _updateWidth(row) { @@ -24,6 +25,10 @@ module.exports = class Table { this._updateWidth(row); } + setChars(vert, horz, center) { + this._chars = [vert, horz, center]; + } + render() { function drawRow(ctx, row, index) { const columns = []; @@ -35,11 +40,13 @@ module.exports = class Table { return columns; } + const [vert, horz, center] = this._chars; + const toDraw = []; const queue = this._rows.splice(1); for (const row in queue) { const _row = drawRow(this, queue[row]); - toDraw.push(_row.join("|")); + toDraw.push(_row.join(vert)); } this._updateWidth(this._rows[0]); @@ -53,13 +60,13 @@ module.exports = class Table { trows.push(out); } - const title_row = trows.join("|"); + const title_row = trows.join(vert); let seperator_row = ""; for (const index in this._widths) { - seperator_row += "-".repeat(this._widths[index]); + seperator_row += horz.repeat(this._widths[index]); if (index != this._widths.length - 1) { - seperator_row += "+"; + seperator_row += center; } }