Files
2025-10-10 18:51:23 -04:00

299 lines
10 KiB
JavaScript

const fetch = require('node-fetch');
// Resolve backend candidates (env or common local addresses). We'll try them in order
// for each request so the bot can still reach the backend even when it binds to
// a specific non-loopback IP.
const envBase = process.env.BACKEND_BASE ? process.env.BACKEND_BASE.replace(/\/$/, '') : null;
const host = process.env.BACKEND_HOST || process.env.HOST || '127.0.0.1';
const port = process.env.BACKEND_PORT || process.env.PORT || '3002';
const CANDIDATES = [envBase, `http://${host}:${port}`, `http://localhost:${port}`, `http://127.0.0.1:${port}`].filter(Boolean);
async function tryFetch(url, opts = {}) {
// Try each candidate base until one responds successfully
for (const base of CANDIDATES) {
const target = `${base.replace(/\/$/, '')}${url}`;
try {
const res = await fetch(target, opts);
if (res && (res.ok || res.status === 204)) {
return res;
}
// if this candidate returned a non-ok status, log and continue trying others
console.error(`Candidate ${base} returned ${res.status} ${res.statusText} for ${target}`);
} catch (e) {
// network error for this candidate; try next
// console.debug(`Candidate ${base} failed:`, e && e.message ? e.message : e);
}
}
// none of the candidates succeeded
return null;
}
async function safeFetchJsonPath(path, opts = {}) {
const res = await tryFetch(path, opts);
if (!res) return null;
try {
return await res.json();
} catch (e) {
console.error('Failed to parse JSON from backend response:', e && e.message ? e.message : e);
return null;
}
}
async function getServerSettings(guildId) {
const path = `/api/servers/${guildId}/settings`;
const json = await safeFetchJsonPath(path);
return json || {};
}
async function upsertServerSettings(guildId, settings) {
const path = `/api/servers/${guildId}/settings`;
try {
const res = await tryFetch(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings),
});
return res && res.ok;
} catch (e) {
console.error(`Failed to upsert settings for ${guildId}:`, e && e.message ? e.message : e);
return false;
}
}
async function getCommands(guildId) {
const path = `/api/servers/${guildId}/commands`;
const json = await safeFetchJsonPath(path);
return json || [];
}
async function toggleCommand(guildId, cmdName, enabled) {
const path = `/api/servers/${guildId}/commands/${cmdName}/toggle`;
try {
const res = await tryFetch(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled }),
});
return res && res.ok;
} catch (e) {
console.error(`Failed to toggle command ${cmdName} for ${guildId}:`, e && e.message ? e.message : e);
return false;
}
}
async function listInvites(guildId) {
const path = `/api/servers/${guildId}/invites`;
const json = await safeFetchJsonPath(path);
return json || [];
}
async function listReactionRoles(guildId) {
const path = `/api/servers/${guildId}/reaction-roles`;
const json = await safeFetchJsonPath(path);
return json || [];
}
async function addInvite(guildId, invite) {
const path = `/api/servers/${guildId}/invites`;
try {
// If invite is an object with code property, it's already created - send full data
// If it's just channelId/maxAge/etc, it's for creation
const isExistingInvite = invite && typeof invite === 'object' && invite.code;
const body = isExistingInvite ? {
code: invite.code,
url: invite.url,
channelId: invite.channelId,
maxUses: invite.maxUses,
maxAge: invite.maxAge,
temporary: invite.temporary,
createdAt: invite.createdAt
} : invite;
const res = await tryFetch(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
return res && res.ok;
} catch (e) {
console.error(`Failed to add invite for ${guildId}:`, e && e.message ? e.message : e);
return false;
}
}
async function deleteInvite(guildId, code) {
const path = `/api/servers/${guildId}/invites/${code}`;
try {
const res = await tryFetch(path, { method: 'DELETE' });
return res && res.ok;
} catch (e) {
console.error(`Failed to delete invite ${code} for ${guildId}:`, e && e.message ? e.message : e);
return false;
}
}
async function updateReactionRole(guildId, id, updates) {
const path = `/api/servers/${guildId}/reaction-roles/${id}`;
try {
const res = await tryFetch(path, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates),
});
if (!res) return null;
try { return await res.json(); } catch (e) { return null; }
} catch (e) {
console.error(`Failed to update reaction role ${id} for ${guildId}:`, e && e.message ? e.message : e);
return null;
}
}
async function deleteReactionRole(guildId, id) {
const path = `/api/servers/${guildId}/reaction-roles/${id}`;
try {
const res = await tryFetch(path, { method: 'DELETE' });
return res && res.ok;
} catch (e) {
console.error(`Failed to delete reaction role ${id} for ${guildId}:`, e && e.message ? e.message : e);
return false;
}
}
module.exports = { getServerSettings, upsertServerSettings, getCommands, toggleCommand, listInvites, addInvite, deleteInvite };
// Twitch users helpers
async function getTwitchUsers(guildId) {
const path = `/api/servers/${guildId}/twitch-users`;
const json = await safeFetchJsonPath(path);
return json || [];
}
async function addTwitchUser(guildId, username) {
const path = `/api/servers/${guildId}/twitch-users`;
try {
const res = await tryFetch(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username }),
});
return res && res.ok;
} catch (e) {
console.error(`Failed to add twitch user ${username} for ${guildId}:`, e && e.message ? e.message : e);
return false;
}
}
async function deleteTwitchUser(guildId, username) {
const path = `/api/servers/${guildId}/twitch-users/${encodeURIComponent(username)}`;
try {
const res = await tryFetch(path, { method: 'DELETE' });
return res && res.ok;
} catch (e) {
console.error(`Failed to delete twitch user ${username} for ${guildId}:`, e && e.message ? e.message : e);
return false;
}
}
// Fetch stream status via backend proxy endpoint /api/twitch/streams?users=a,b,c
async function tryFetchTwitchStreams(usersCsv) {
const path = `/api/twitch/streams?users=${encodeURIComponent(usersCsv || '')}`;
const json = await safeFetchJsonPath(path);
return json || [];
}
// Raw direct call helper (not used in most environments) — kept for legacy watcher
async function _rawGetTwitchStreams(usersCsv) {
// Try direct backend candidate first
const path = `/api/twitch/streams?users=${encodeURIComponent(usersCsv || '')}`;
const res = await tryFetch(path);
if (!res) return [];
try { return await res.json(); } catch (e) { return []; }
}
// Kick users helpers
async function getKickUsers(guildId) {
const path = `/api/servers/${guildId}/kick-users`;
const json = await safeFetchJsonPath(path);
return json || [];
}
async function addKickUser(guildId, username) {
const path = `/api/servers/${guildId}/kick-users`;
try {
const res = await tryFetch(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username }),
});
return res && res.ok;
} catch (e) {
console.error(`Failed to add kick user ${username} for ${guildId}:`, e && e.message ? e.message : e);
return false;
}
}
async function deleteKickUser(guildId, username) {
const path = `/api/servers/${guildId}/kick-users/${encodeURIComponent(username)}`;
try {
const res = await tryFetch(path, { method: 'DELETE' });
return res && res.ok;
} catch (e) {
console.error(`Failed to delete kick user ${username} for ${guildId}:`, e && e.message ? e.message : e);
return false;
}
}
async function getWelcomeLeaveSettings(guildId) {
const path = `/api/servers/${guildId}/welcome-leave-settings`;
const json = await safeFetchJsonPath(path);
return json || { welcome: { enabled: false }, leave: { enabled: false } };
}
async function getAutoroleSettings(guildId) {
const path = `/api/servers/${guildId}/autorole-settings`;
const json = await safeFetchJsonPath(path);
return json || { enabled: false, roleId: '' };
}
async function reconcileInvites(guildId, currentDiscordInvites) {
try {
// Get invites from database
const dbInvites = await listInvites(guildId) || [];
// Find invites in database that no longer exist in Discord
const discordInviteCodes = new Set(currentDiscordInvites.map(inv => inv.code));
const deletedInvites = dbInvites.filter(dbInv => !discordInviteCodes.has(dbInv.code));
// Delete each invite that no longer exists
for (const invite of deletedInvites) {
console.log(`🗑️ Reconciling deleted invite ${invite.code} for guild ${guildId}`);
await deleteInvite(guildId, invite.code);
// Publish SSE event for frontend update
try {
await tryFetch('/api/events/publish', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
event: 'inviteDeleted',
data: { code: invite.code, guildId }
})
});
} catch (sseErr) {
console.error('Failed to publish SSE event for reconciled invite deletion:', sseErr);
}
}
if (deletedInvites.length > 0) {
console.log(`✅ Reconciled ${deletedInvites.length} deleted invites for guild ${guildId}`);
}
return deletedInvites.length;
} catch (e) {
console.error(`Failed to reconcile invites for guild ${guildId}:`, e && e.message ? e.message : e);
return 0;
}
}
module.exports = { getServerSettings, upsertServerSettings, getCommands, toggleCommand, listInvites, addInvite, deleteInvite, listReactionRoles, updateReactionRole, deleteReactionRole, getTwitchUsers, addTwitchUser, deleteTwitchUser, tryFetchTwitchStreams, _rawGetTwitchStreams, getKickUsers, addKickUser, deleteKickUser, getWelcomeLeaveSettings, getAutoroleSettings, reconcileInvites };