magma-connect
Version:
A smart magmastream plugin to connect nodes based on their region.
429 lines (428 loc) • 17.9 kB
JavaScript
"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;