sourcequery
This commit is contained in:
parent
fdff86e318
commit
bad6d45963
4 changed files with 595 additions and 5 deletions
|
@ -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,
|
||||
|
|
242
src/modules/misc/sourcequery.js
Normal file
242
src/modules/misc/sourcequery.js
Normal 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
341
src/util/sourcequery.js
Normal 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);
|
||||
});
|
||||
}
|
||||
};
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue