UNPKG

magma-connect

Version:

A smart magmastream plugin to connect nodes based on their region.

429 lines (428 loc) 17.9 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.MagmaConnect = void 0; const magmastream_1 = require("magmastream"); /** * Plugin that selects the nearest Lavalink node per guild using regional/geographic hints. * * It intercepts Manager.create (when nodeIdentifier is omitted) and chooses the closest node * based on cached guild region, user-provided guild resolver, or the bot host location. */ class MagmaConnect extends magmastream_1.Plugin { options; manager; interval; originalCreate; originalUpdateVoiceState; nodeGeo = new Map(); guildGeo = new Map(); selfGeo; // Bot host geolocation as a fallback /** * Creates a new MagmaConnect plugin instance. * @param options Plugin configuration. */ constructor(options = {}) { super('MagmaConnect'); this.options = options; } /** * Loads the plugin: caches manager reference, starts node geo refresh, and patches Manager methods. * @param manager MagmaStream Manager instance. */ load = (manager) => { this.manager = manager; this.log('Loading MagmaConnect plugin'); this.refreshAllNodeLocations().catch((err) => this.log('Node geo refresh error: ' + err.message)); if (this.options.refreshIntervalMs && this.options.refreshIntervalMs > 0) { this.interval = setInterval(() => { this.refreshAllNodeLocations().catch((err) => this.log('Node geo refresh error: ' + err.message)); }, this.options.refreshIntervalMs); } this.originalCreate = manager.create.bind(manager); manager.create = ((opts) => { const patched = { ...opts }; if (!patched.nodeIdentifier) { const target = () => this.getTargetForGuildSync(patched.guildId); const id = this.pickBestNodeIdentifier(target); if (id) patched.nodeIdentifier = id; void this.getTargetForGuild(patched.guildId).catch(() => void 0); } return this.originalCreate(patched); }); this.originalUpdateVoiceState = manager.updateVoiceState.bind(manager); manager.updateVoiceState = (async (data) => { try { const vs = this.extractVoiceServerUpdate(data); if (vs && vs.guild_id && vs.endpoint) { const region = this.parseDiscordRegionFromEndpoint(vs.endpoint); const latlon = region ? this.regionToLatLon(region) : undefined; if (latlon) { this.guildGeo.set(vs.guild_id, latlon); this.log(`Cached guild ${vs.guild_id} region ${region} => ${latlon.lat.toFixed(2)},${latlon.lon.toFixed(2)}`); } } } catch { // ignore } return this.originalUpdateVoiceState(data); }); this.log('MagmaConnect plugin loaded'); }; /** * Unloads the plugin: clears timers and restores patched Manager methods. */ unload = (_) => { this.log('Unloading MagmaConnect plugin'); if (this.interval) clearInterval(this.interval); if (this.manager && this.originalCreate) this.manager.create = this.originalCreate; if (this.manager && this.originalUpdateVoiceState) this.manager.updateVoiceState = this.originalUpdateVoiceState; this.log('MagmaConnect plugin unloaded'); }; /** * Chooses the closest node by great-circle distance to the provided target location. * Falls back to bot host location, then the first available node. * @param getTarget Function returning target Lat/Lon or a promise to it. * @returns The chosen node identifier/host or undefined if no nodes are present. */ pickBestNodeIdentifier = (getTarget) => { const m = this.manager; if (!m || m.nodes.size === 0) return undefined; const nodes = [...m.nodes.values()]; const target = this.resolveSyncOrAsync(getTarget()); const loc = target ?? this.getSelfLocationCached(); if (!loc) return nodes[0]?.options.identifier ?? nodes[0]?.options.host; // fallback nodes.forEach((n) => { const id = n.options.identifier ?? n.options.host; if (!this.nodeGeo.has(id)) void this.resolveNodeLocation(n.options).then((ll) => ll && this.nodeGeo.set(id, ll)); }); let best; for (const n of nodes) { const id = n.options.identifier ?? n.options.host; const ll = this.nodeGeo.get(id); if (!ll) continue; const d = this.haversineKm(loc, ll); if (!best || d < best.dist) best = { id, dist: d }; } return best?.id ?? nodes[0]?.options.identifier ?? nodes[0]?.options.host; }; /** * Resolves and caches geolocation for all configured nodes. */ refreshAllNodeLocations = async () => { const m = this.manager; if (!m) return; const promises = []; for (const n of m.nodes.values()) { const id = n.options.identifier ?? n.options.host; if (this.options.nodeLocations && this.options.nodeLocations[id]) { const ll = await this.normalizeLoc(this.options.nodeLocations[id]); if (ll) { this.nodeGeo.set(id, ll); this.log(`Node ${id} location set from override => ${ll.lat.toFixed(2)},${ll.lon.toFixed(2)}`); } else { this.log(`Node ${id} override provided but could not be normalized`); } continue; } promises.push(this.resolveNodeLocation(n.options) .then((ll) => { if (ll) { this.nodeGeo.set(id, ll); this.log(`Node ${id} resolved via host lookup => ${ll.lat.toFixed(2)},${ll.lon.toFixed(2)}`); } else { this.log(`Node ${id} location could not be determined (no override, host lookup failed)`); } }) .catch((e) => this.log(`Node ${id} location resolution error: ${e.message}`))); } await Promise.all(promises); }; /** * Determines the geolocation of a node using overrides or public IP geo services. * @param node Node options (host/identifier). */ resolveNodeLocation = async (node) => { const id = node.identifier ?? node.host; const override = this.options.nodeLocations?.[id]; if (override) return this.normalizeLoc(override); this.log(`Resolving node ${id} location via host ${node.host}`); const byHost = await this.geoByHost(node.host).catch((e) => { this.log(`Error during host geo lookup for ${id}: ${e.message}`); return undefined; }); if (byHost) return byHost; return undefined; }; /** * Queries free public APIs to geolocate an IP/hostname. * @param host Node host/IP. */ geoByHost = async (host) => { const urls = [ `https://ipwho.is/${encodeURIComponent(host)}?fields=success,latitude,longitude`, `http://ip-api.com/json/${encodeURIComponent(host)}?fields=status,lat,lon`, ]; for (const url of urls) { this.log(`Fetching geo for ${host} via ${url}`); const data = await this.fetchJson(url, 4500).catch((e) => { this.log(`Geo fetch error for ${host} via ${url}: ${e.message}`); return undefined; }); if (!data) continue; if ('success' in data && data.success && typeof data.latitude === 'number' && typeof data.longitude === 'number') return { lat: data.latitude, lon: data.longitude }; if ('status' in data && data.status === 'success' && typeof data.lat === 'number' && typeof data.lon === 'number') return { lat: data.lat, lon: data.lon }; } return undefined; }; /** * Computes the target location for a guild: cached voice region -> lat/lon, user resolver, or bot host. * @param guildId Guild ID. */ getTargetForGuild = async (guildId) => { const cached = this.guildGeo.get(guildId); if (cached) return cached; if (this.options.getGuildLocation) { const v = await this.options.getGuildLocation(guildId); if (v) { const ll = await this.normalizeLoc(v); if (ll) { this.guildGeo.set(guildId, ll); return ll; } } } return this.getSelfLocationCached(); }; /** * Fast, synchronous variant that returns only cached data for a guild's target location. * This avoids returning a Promise during Manager.create, which would otherwise force * a fallback path before async resolution completes. * * Order: cached guild region -> cached self (host) geolocation -> undefined */ getTargetForGuildSync = (guildId) => { return this.guildGeo.get(guildId) ?? this.selfGeo; }; /** * Returns cached bot host geolocation; kicks off async fetch on first call. */ getSelfLocationCached = () => { if (this.selfGeo) return this.selfGeo; this.log('Self geo not cached, triggering background fetch'); void this.getSelfLocation() .then((ll) => { this.selfGeo = ll; if (ll) this.log(`Self location cached => ${ll.lat.toFixed(2)},${ll.lon.toFixed(2)}`); }) .catch((e) => this.log(`Self geo fetch error: ${e.message}`)); return this.selfGeo; }; /** * Fetches bot host geolocation using public APIs. */ getSelfLocation = async () => { this.log('Fetching self geo from ipwho.is'); const data = await this.fetchJson('https://ipwho.is/?fields=success,latitude,longitude', 4500).catch((e) => { this.log(`Self geo fetch error (ipwho.is): ${e.message}`); return undefined; }); if (data && data.success && typeof data.latitude === 'number' && typeof data.longitude === 'number') return { lat: data.latitude, lon: data.longitude }; this.log('Fetching self geo from ip-api.com'); const data2 = await this.fetchJson('http://ip-api.com/json/?fields=status,lat,lon', 4500).catch((e) => { this.log(`Self geo fetch error (ip-api.com): ${e.message}`); return undefined; }); if (data2 && data2.status === 'success' && typeof data2.lat === 'number' && typeof data2.lon === 'number') return { lat: data2.lat, lon: data2.lon }; return undefined; }; /** * Great-circle distance between two coordinates using the Haversine formula. * @param a Point A * @param b Point B * @returns Distance in kilometers */ haversineKm = (a, b) => { const toRad = (x) => (x * Math.PI) / 180; const R = 6371; // km const dLat = toRad(b.lat - a.lat); const dLon = toRad(b.lon - a.lon); const lat1 = toRad(a.lat); const lat2 = toRad(b.lat); const sinDLat = Math.sin(dLat / 2); const sinDLon = Math.sin(dLon / 2); const h = sinDLat * sinDLat + Math.cos(lat1) * Math.cos(lat2) * sinDLon * sinDLon; return 2 * R * Math.asin(Math.min(1, Math.sqrt(h))); }; /** * Normalizes either a coordinate or a region code to a coordinate. * @param v Coordinate or region wrapper. */ normalizeLoc = async (v) => { if ('lat' in v) return v; const ll = this.regionToLatLon(v.region); return ll; }; /** * Extracts a Voice Server Update shape from multiple possible payloads. */ extractVoiceServerUpdate = (data) => { if (data && typeof data === 'object') { const anyData = data; // Discord gateway packet style if (anyData.t === 'VOICE_SERVER_UPDATE' && anyData.d && typeof anyData.d.endpoint === 'string' && typeof anyData.d.guild_id === 'string') { return { guild_id: anyData.d.guild_id, endpoint: anyData.d.endpoint }; } // Raw VoiceServer style if ('endpoint' in anyData && typeof anyData.endpoint === 'string' && 'guild_id' in anyData && typeof anyData.guild_id === 'string') { return { guild_id: anyData.guild_id, endpoint: anyData.endpoint }; } // VoiceState-like event wrapper if ('event' in anyData && anyData.event && typeof anyData.event.endpoint === 'string' && typeof anyData.event.guild_id === 'string') { return { guild_id: anyData.event.guild_id, endpoint: anyData.event.endpoint }; } } return undefined; }; /** * Parses a Discord media endpoint hostname into a region label. * @param endpoint e.g., "us-east123.discord.media:443" */ parseDiscordRegionFromEndpoint = (endpoint) => { const host = endpoint.split(':')[0]; const parts = host.split('.'); if (parts.length < 3) return undefined; const first = parts[0]; const region = first.replace(/\d+$/, ''); return region || undefined; }; /** * Maps common region names to approximate lat/lon coordinates. */ regionToLatLon = (region) => { const key = region.toLowerCase(); const map = { 'us-east': { lat: 39.0, lon: -77.0 }, 'us-west': { lat: 37.4, lon: -122.0 }, 'us-central': { lat: 41.6, lon: -93.6 }, 'us-south': { lat: 29.4, lon: -98.5 }, brazil: { lat: -23.5, lon: -46.6 }, singapore: { lat: 1.29, lon: 103.85 }, hongkong: { lat: 22.32, lon: 114.17 }, 'hong-kong': { lat: 22.32, lon: 114.17 }, russia: { lat: 55.75, lon: 37.62 }, europe: { lat: 50.11, lon: 8.68 }, 'eu-central': { lat: 50.11, lon: 8.68 }, 'eu-west': { lat: 48.86, lon: 2.35 }, sydney: { lat: -33.86, lon: 151.21 }, japan: { lat: 35.68, lon: 139.69 }, india: { lat: 19.08, lon: 72.88 }, southafrica: { lat: -26.2, lon: 28.04 }, 'south-africa': { lat: -26.2, lon: 28.04 }, dubai: { lat: 25.2, lon: 55.27 }, frankfurt: { lat: 50.11, lon: 8.68 }, london: { lat: 51.51, lon: -0.13 }, amsterdam: { lat: 52.37, lon: 4.9 }, mumbai: { lat: 19.08, lon: 72.88 }, chicago: { lat: 41.88, lon: -87.62 }, atlanta: { lat: 33.75, lon: -84.39 }, dallas: { lat: 32.78, lon: -96.8 }, miami: { lat: 25.77, lon: -80.19 }, newyork: { lat: 40.71, lon: -74.01 }, 'new-york': { lat: 40.71, lon: -74.01 }, paris: { lat: 48.86, lon: 2.35 }, stockholm: { lat: 59.33, lon: 18.06 }, seoul: { lat: 37.57, lon: 126.98 }, toronto: { lat: 43.65, lon: -79.38 }, montreal: { lat: 45.5, lon: -73.57 }, }; return map[key]; }; /** * Lightweight JSON GET using Node's http/https modules. * @param url Resource URL * @param timeoutMs Request timeout in milliseconds */ fetchJson = async (url, timeoutMs = 5000) => { return new Promise((resolve, reject) => { const u = new URL(url); const isHttp = u.protocol === 'http:'; const mod = isHttp ? require('http') : require('https'); const req = mod.request(u, { method: 'GET', timeout: timeoutMs, headers: { 'user-agent': 'magma-connect/0.1' }, }, (res) => { const statusCode = res.statusCode ?? 0; if (statusCode >= 400) { res.resume(); reject(new Error(`HTTP ${statusCode}`)); return; } const chunks = []; res.on('data', (c) => { if (c) chunks.push(c); }); res.on('end', () => { try { const body = Buffer.concat(chunks).toString('utf8'); resolve(body ? JSON.parse(body) : {}); } catch (e) { reject(e); } }); }); req.on('error', reject); req.on('timeout', () => req.destroy(new Error('Request timeout'))); req.end(); }); }; /** * If a promise-like is passed, returns undefined to avoid blocking a sync path. * Otherwise returns the value directly. */ resolveSyncOrAsync = (v) => { if (v && typeof v.then === 'function') return undefined; return v; }; /** * Debug logger for the plugin. * * Prefixed with [MAGMACONNECT] and only emits output when `options.debug` is true. * Use this for internal diagnostics; production users can disable by leaving `debug` unset/false. * * @param msg The message to print when debug logging is enabled. */ log = (msg) => { if (this.options.debug) console.log(`[MAGMACONNECT] ${msg}`); }; } exports.MagmaConnect = MagmaConnect;