sourcequery

This commit is contained in:
Cynthia Foxwell 2025-10-09 17:08:22 -06:00
parent fdff86e318
commit bad6d45963
Signed by: Cynosphere
SSH key fingerprint: SHA256:H3SM8ufP/uxqLwKSH7xY89TDnbR9uOHzjLoBr0tlajk
4 changed files with 595 additions and 5 deletions

View file

@ -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,

View file

@ -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] ?? `<unknown type: \`${type}\`>`);
if (appid == "4000") lines.push("**Gamemode:** " + game);
lines.push("**OS:** " + (OPERATING_SYSTEMS[os] ?? `<unknown OS: \`${os}\``));
lines.push(`**VAC:** ${vac ? "En" : "Dis"}abled`);
lines.push(`**Version:** \`${version}\``);
if (visibility == 1) lines.push("*Passworded*");
if (sourcetv?.name) {
lines.push("## SourceTV");
lines.push(`${sourcetv.name} (port ${sourcetv.port})`);
}
if (tags?.length > 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);

341
src/util/sourcequery.js Normal file
View file

@ -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);
});
}
};

View file

@ -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;
}
}