37e9af4080
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
208 lines
6.6 KiB
JavaScript
208 lines
6.6 KiB
JavaScript
import { execFile } from "node:child_process";
|
|
import https from "node:https";
|
|
import { promisify } from "node:util";
|
|
import {
|
|
Client,
|
|
Events,
|
|
GatewayIntentBits,
|
|
REST,
|
|
Routes,
|
|
SlashCommandBuilder,
|
|
} from "discord.js";
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
|
|
const TOKEN = process.env.DISCORD_TOKEN;
|
|
const GUILD_ID = process.env.GUILD_ID || "";
|
|
const CHECK_INTERVAL_SEC = Number(process.env.WARP_CHECK_INTERVAL || 60);
|
|
|
|
// Cloudflare's trace endpoint reports the IP it sees us as and whether the
|
|
// request arrived over WARP. The request is made once per address family so
|
|
// both the IPv4 and the IPv6 Cloudflare egress IPs can be shown.
|
|
const TRACE_HOST = "www.cloudflare.com";
|
|
const TRACE_PATH = "/cdn-cgi/trace";
|
|
|
|
if (!TOKEN) {
|
|
console.error("[bot] DISCORD_TOKEN is not set. Exiting.");
|
|
process.exit(1);
|
|
}
|
|
|
|
function ts() {
|
|
return new Date().toISOString();
|
|
}
|
|
|
|
function log(scope, msg) {
|
|
console.log(`[${ts()}] [${scope}] ${msg}`);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// WARP status helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function parseTrace(text) {
|
|
const data = {};
|
|
for (const line of text.trim().split("\n")) {
|
|
const idx = line.indexOf("=");
|
|
if (idx > 0) data[line.slice(0, idx)] = line.slice(idx + 1);
|
|
}
|
|
return data;
|
|
}
|
|
|
|
/** GET the trace endpoint over a specific IP family (4 or 6). */
|
|
function fetchTrace(family) {
|
|
return new Promise((resolve, reject) => {
|
|
const req = https.get(
|
|
{ host: TRACE_HOST, path: TRACE_PATH, family, timeout: 10_000 },
|
|
(res) => {
|
|
let body = "";
|
|
res.setEncoding("utf8");
|
|
res.on("data", (c) => (body += c));
|
|
res.on("end", () => {
|
|
if (res.statusCode !== 200) return reject(new Error(`trace HTTP ${res.statusCode}`));
|
|
resolve(parseTrace(body));
|
|
});
|
|
}
|
|
);
|
|
req.on("timeout", () => req.destroy(new Error("timeout")));
|
|
req.on("error", reject);
|
|
});
|
|
}
|
|
|
|
/** Returns { ok, ip, warp, colo, loc, error } for one address family. */
|
|
async function traceFamily(family) {
|
|
try {
|
|
const t = await fetchTrace(family);
|
|
return { ok: t.warp === "on" || t.warp === "plus", ...t };
|
|
} catch (err) {
|
|
return { ok: false, error: err.message };
|
|
}
|
|
}
|
|
|
|
/** Local view from the WARP daemon inside this container. */
|
|
async function warpCliStatus() {
|
|
try {
|
|
const { stdout } = await execFileAsync(
|
|
"warp-cli",
|
|
["--accept-tos", "status"],
|
|
{ timeout: 10_000 }
|
|
);
|
|
return stdout.trim().replace(/\s+/g, " ");
|
|
} catch (err) {
|
|
return `unavailable (${err.message})`;
|
|
}
|
|
}
|
|
|
|
let lastWarp = null;
|
|
|
|
async function checkWarp(reason = "periodic") {
|
|
const [v4, v6, cliStatus] = await Promise.all([
|
|
traceFamily(4),
|
|
traceFamily(6),
|
|
warpCliStatus(),
|
|
]);
|
|
|
|
// Connected = Cloudflare sees WARP on at least one family (IPv4 is the one
|
|
// Discord uses, so it is required; IPv6 is reported when available).
|
|
const connected = v4.ok;
|
|
const fmt = (r) => (r.ok ? `${r.ip} (warp=${r.warp})` : `n/a (${r.error || `warp=${r.warp}`})`);
|
|
const colo = v4.colo || v6.colo || "?";
|
|
const loc = v4.loc || v6.loc || "?";
|
|
const summary = `ipv4=${fmt(v4)} ipv6=${fmt(v6)} colo=${colo} loc=${loc} | warp-cli: ${cliStatus}`;
|
|
|
|
if (connected) {
|
|
log("WARP", `CONNECTED via Cloudflare (${reason}) ${summary}`);
|
|
} else {
|
|
log("WARP", `NOT CONNECTED (${reason}) ${summary}`);
|
|
await tryReconnect();
|
|
}
|
|
|
|
lastWarp = { connected, v4, v6, colo, loc, cliStatus, checkedAt: ts() };
|
|
return lastWarp;
|
|
}
|
|
|
|
async function tryReconnect() {
|
|
try {
|
|
log("WARP", "attempting warp-cli connect");
|
|
await execFileAsync("warp-cli", ["--accept-tos", "connect"], { timeout: 15_000 });
|
|
} catch (err) {
|
|
log("WARP", `reconnect attempt failed: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Discord client
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const commands = [
|
|
new SlashCommandBuilder().setName("ping").setDescription("Replies with pong and latency"),
|
|
new SlashCommandBuilder()
|
|
.setName("warp")
|
|
.setDescription("Show the Cloudflare WARP tunnel status and egress IP of this bot"),
|
|
].map((c) => c.toJSON());
|
|
|
|
async function registerCommands(appId) {
|
|
const rest = new REST({ version: "10" }).setToken(TOKEN);
|
|
if (GUILD_ID) {
|
|
await rest.put(Routes.applicationGuildCommands(appId, GUILD_ID), { body: commands });
|
|
log("bot", `registered ${commands.length} guild slash commands in ${GUILD_ID}`);
|
|
} else {
|
|
await rest.put(Routes.applicationCommands(appId), { body: commands });
|
|
log("bot", `registered ${commands.length} global slash commands`);
|
|
}
|
|
}
|
|
|
|
const client = new Client({ intents: [GatewayIntentBits.Guilds] });
|
|
|
|
client.once(Events.ClientReady, async (c) => {
|
|
log("bot", `logged in as ${c.user.tag} (id ${c.user.id}) in ${c.guilds.cache.size} guild(s)`);
|
|
try {
|
|
await registerCommands(c.user.id);
|
|
} catch (err) {
|
|
log("bot", `failed to register slash commands: ${err.message}`);
|
|
}
|
|
await checkWarp("startup");
|
|
setInterval(() => checkWarp("periodic"), CHECK_INTERVAL_SEC * 1000);
|
|
log("bot", `WARP check scheduled every ${CHECK_INTERVAL_SEC}s`);
|
|
});
|
|
|
|
client.on(Events.InteractionCreate, async (interaction) => {
|
|
if (!interaction.isChatInputCommand()) return;
|
|
|
|
if (interaction.commandName === "ping") {
|
|
const sent = await interaction.reply({ content: "Pong!", fetchReply: true });
|
|
const rtt = sent.createdTimestamp - interaction.createdTimestamp;
|
|
await interaction.editReply(`Pong! Round-trip ${rtt}ms, gateway ${Math.round(client.ws.ping)}ms`);
|
|
return;
|
|
}
|
|
|
|
if (interaction.commandName === "warp") {
|
|
await interaction.deferReply();
|
|
const s = await checkWarp("slash-command");
|
|
const line = (label, r) =>
|
|
r.ok ? `${label}: \`${r.ip}\` (warp=${r.warp})` : `${label}: n/a (${r.error || `warp=${r.warp}`})`;
|
|
await interaction.editReply(
|
|
`**Cloudflare WARP: ${s.connected ? "connected" : "NOT connected"}**\n` +
|
|
`${line("IPv4", s.v4)}\n${line("IPv6", s.v6)}\n` +
|
|
`colo: \`${s.colo}\` location: \`${s.loc}\`\n` +
|
|
`warp-cli: \`${s.cliStatus}\`\nchecked: ${s.checkedAt}`
|
|
);
|
|
}
|
|
});
|
|
|
|
client.on(Events.Error, (err) => log("bot", `client error: ${err.message}`));
|
|
client.on(Events.Warn, (msg) => log("bot", `warn: ${msg}`));
|
|
|
|
for (const sig of ["SIGINT", "SIGTERM"]) {
|
|
process.on(sig, () => {
|
|
log("bot", `${sig} received, shutting down`);
|
|
client.destroy();
|
|
process.exit(0);
|
|
});
|
|
}
|
|
|
|
log("bot", "starting, connecting to Discord gateway through WARP tunnel");
|
|
client.login(TOKEN).catch((err) => {
|
|
log("bot", `login failed: ${err.message}`);
|
|
process.exit(1);
|
|
});
|